@inventure71/paddockjs 2.0.0 → 3.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 (110) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/INSTALL_AND_UPDATE.md +10 -1
  3. package/README.md +92 -17
  4. package/docs/architecture.md +26 -16
  5. package/docs/component_inventory.md +72 -0
  6. package/docs/concepts.md +7 -5
  7. package/docs/custom_model_controller.md +2 -2
  8. package/docs/data_contract.md +121 -33
  9. package/docs/data_model.md +1 -1
  10. package/docs/index.md +10 -0
  11. package/docs/learnings.md +6 -6
  12. package/docs/rules.md +39 -9
  13. package/docs/sense_contract.md +59 -0
  14. package/docs/system_specs.md +76 -20
  15. package/docs/training.md +27 -20
  16. package/package.json +1 -1
  17. package/src/api/PaddockSimulatorController.js +75 -36
  18. package/src/app/BrowserExpertAdapter.js +9 -300
  19. package/src/app/BrowserExpertExternalRenderer.js +303 -0
  20. package/src/app/F1SimulatorApp.js +190 -68
  21. package/src/app/banners/raceDataBannerController.js +71 -36
  22. package/src/app/banners/stewardMessageController.js +5 -4
  23. package/src/app/camera/cameraConstants.js +2 -0
  24. package/src/app/camera/cameraController.js +86 -29
  25. package/src/app/camera/cameraGeometry.js +12 -1
  26. package/src/app/domBindings.js +89 -9
  27. package/src/app/layoutScheduler.js +27 -0
  28. package/src/app/layoutSupport.js +165 -0
  29. package/src/app/raceOverlayClearanceSupport.js +158 -0
  30. package/src/app/readouts/carOverviewRenderer.js +16 -8
  31. package/src/app/readouts/controlStateRenderer.js +1 -1
  32. package/src/app/readouts/raceStatusRenderer.js +30 -21
  33. package/src/app/readouts/readoutFormatters.js +2 -0
  34. package/src/app/readouts/telemetryRenderer.js +17 -8
  35. package/src/app/readouts/timingTowerRenderer.js +62 -10
  36. package/src/app/runtime/runtimeVisibility.js +66 -5
  37. package/src/config/cssValues.js +16 -0
  38. package/src/config/defaultOptions.js +51 -38
  39. package/src/config/restartOptions.js +34 -0
  40. package/src/config/themeOptions.js +825 -0
  41. package/src/config/timingGapMode.js +13 -0
  42. package/src/config/urlOptions.js +25 -0
  43. package/src/data/championship.js +14 -9
  44. package/src/environment/controllerLoop.js +34 -5
  45. package/src/environment/episode.js +14 -2
  46. package/src/environment/index.d.ts +17 -4
  47. package/src/environment/observationVector.js +200 -0
  48. package/src/environment/observations.js +58 -285
  49. package/src/environment/options.js +1 -1
  50. package/src/environment/recorder.js +13 -6
  51. package/src/environment/rewards.js +13 -2
  52. package/src/environment/runtime.js +65 -25
  53. package/src/environment/scenarios.js +22 -3
  54. package/src/environment/sensors/index.js +3 -3
  55. package/src/environment/sensors/opponentRadar.js +13 -3
  56. package/src/environment/sensors/sensorTargets.js +8 -2
  57. package/src/environment/sensors/surfaceRays.js +84 -12
  58. package/src/environment/sensors/trackRays.js +173 -15
  59. package/src/environment/specs.js +78 -40
  60. package/src/index.d.ts +112 -9
  61. package/src/index.js +26 -33
  62. package/src/simulation/driver/driverController.js +1 -1
  63. package/src/simulation/driver/edgeRecovery.js +1 -1
  64. package/src/simulation/driver/racingControls.js +2 -2
  65. package/src/simulation/driver/rejoinControls.js +2 -2
  66. package/src/simulation/pit/pitFlow.js +5 -0
  67. package/src/simulation/pit/pitKinematics.js +24 -0
  68. package/src/simulation/pit/pitQueue.js +5 -0
  69. package/src/simulation/pit/pitRouteMovement.js +7 -0
  70. package/src/simulation/pit/pitRouting.js +2 -4
  71. package/src/simulation/pit/pitService.js +2 -0
  72. package/src/simulation/pit/pitTireService.js +2 -0
  73. package/src/simulation/race/raceLifecycle.js +2 -6
  74. package/src/simulation/race/raceProgress.js +6 -1
  75. package/src/simulation/race/raceSetup.js +2 -6
  76. package/src/simulation/race/raceStep.js +2 -2
  77. package/src/simulation/race/redFlag.js +3 -3
  78. package/src/simulation/race/safetyCar.js +10 -4
  79. package/src/simulation/race/stalledDnf.js +8 -17
  80. package/src/simulation/raceSimulation.js +1 -0
  81. package/src/simulation/rules/rulesReview.js +3 -0
  82. package/src/simulation/snapshots/raceSnapshots.js +1 -0
  83. package/src/simulation/timing/lapTelemetry.js +28 -9
  84. package/src/simulation/track/pitLaneLayout.js +7 -3
  85. package/src/simulation/track/spatialQueries.js +5 -28
  86. package/src/simulation/track/trackQueryGrid.js +94 -0
  87. package/src/simulation/track/trackQueryIndex.js +193 -434
  88. package/src/simulation/track/trackQueryPitIndex.js +140 -0
  89. package/src/simulation/track/trackQueryScratch.js +46 -0
  90. package/src/simulation/track/trackQueryStats.js +28 -0
  91. package/src/simulation/track/trackStatePolicy.js +0 -1
  92. package/src/simulation/vehicle/contactResolution.js +67 -2
  93. package/src/simulation/vehicle/runoffResponse.js +11 -12
  94. package/src/simulation/vehicle/vehicleKinematics.js +37 -0
  95. package/src/simulation/vehicle/vehiclePhysics.js +5 -4
  96. package/src/simulation/vehicle/vehicleSnapshots.js +22 -0
  97. package/src/simulation/vehicle/vehicleState.js +36 -2
  98. package/src/simulation/vehicle/wheelSurface.js +3 -1
  99. package/src/simulation/warmup/runtimeWarmup.js +0 -1
  100. package/src/styles.css +1185 -136
  101. package/src/ui/bannerTemplates.js +38 -28
  102. package/src/ui/cameraControlsTemplate.js +16 -5
  103. package/src/ui/carOverviewTemplate.js +12 -5
  104. package/src/ui/raceCanvasTemplate.js +31 -8
  105. package/src/ui/raceControlsTemplate.js +27 -10
  106. package/src/ui/shellTemplate.js +3 -1
  107. package/src/ui/telemetryDrawerTemplate.js +23 -10
  108. package/src/ui/telemetryTemplates.js +58 -32
  109. package/src/ui/templateUtils.js +48 -0
  110. package/src/ui/timingTowerTemplate.js +29 -12
@@ -3,10 +3,12 @@ import { VEHICLE_GEOMETRY } from '../../simulation/vehicleGeometry.js';
3
3
 
4
4
  export const CAMERA_TARGET_LERP = 0.12;
5
5
  export const CAMERA_SCALE_LERP = 0.12;
6
+ export const CAMERA_ROTATION_LERP = 0.16;
6
7
  export const CAMERA_PRESETS = {
7
8
  overview: 1,
8
9
  leader: 18,
9
10
  selected: 24,
11
+ driver: 34,
10
12
  'show-all': 1,
11
13
  pit: 2.35,
12
14
  };
@@ -1,7 +1,8 @@
1
1
  import { WORLD } from '../../simulation/trackModel.js';
2
- import { clamp } from '../../simulation/simMath.js';
2
+ import { clamp, normalizeAngle } from '../../simulation/simMath.js';
3
3
  import {
4
4
  CAMERA_PRESETS,
5
+ CAMERA_ROTATION_LERP,
5
6
  CAMERA_SCALE_LERP,
6
7
  CAMERA_TARGET_LERP,
7
8
  CAMERA_ZOOM_STEP,
@@ -27,16 +28,47 @@ export function createCameraState(initialCameraMode) {
27
28
  initialized: false,
28
29
  x: WORLD.width / 2,
29
30
  y: WORLD.height / 2,
31
+ rotation: 0,
30
32
  free: false,
31
33
  freeTarget: null,
32
34
  };
33
35
  }
34
36
 
37
+ function timingTowerCandidates(readouts) {
38
+ const nodes = [];
39
+ readouts?.timingTowers?.forEach?.((node) => {
40
+ if (node && !nodes.includes(node)) nodes.push(node);
41
+ });
42
+ if (readouts?.timingTower && !nodes.includes(readouts.timingTower)) {
43
+ nodes.push(readouts.timingTower);
44
+ }
45
+ return nodes;
46
+ }
47
+
48
+ function timingTowerFrameForCanvas(readouts, canvasRect, canvasWidth) {
49
+ for (const timingTower of timingTowerCandidates(readouts)) {
50
+ const towerRect = timingTower?.getBoundingClientRect?.();
51
+ if (!towerRect) continue;
52
+ const overlapsCanvasHorizontally = towerRect.right > canvasRect.left && towerRect.left < canvasRect.right;
53
+ const hasVerticalBounds = Number.isFinite(canvasRect.top) &&
54
+ Number.isFinite(canvasRect.bottom) &&
55
+ Number.isFinite(towerRect.top) &&
56
+ Number.isFinite(towerRect.bottom);
57
+ const overlapsCanvasVertically = !hasVerticalBounds ||
58
+ (towerRect.bottom > canvasRect.top && towerRect.top < canvasRect.bottom);
59
+ const towerWidth = Math.max(0, towerRect.right - towerRect.left);
60
+ const isSideGutter = towerWidth < canvasWidth * 0.6;
61
+ if (overlapsCanvasHorizontally && overlapsCanvasVertically && isSideGutter) return towerRect;
62
+ }
63
+ return null;
64
+ }
65
+
35
66
  export class CameraController {
36
- constructor({ canvasHost, readouts, initialMode }) {
67
+ constructor({ canvasHost, readouts, initialMode, driverCamera = false }) {
37
68
  this.canvasHost = canvasHost;
38
69
  this.readouts = readouts;
39
70
  this.camera = createCameraState(initialMode);
71
+ this.driverCamera = Boolean(driverCamera);
40
72
  this.safeAreaCache = null;
41
73
  this.pitBoundsCache = null;
42
74
  this.trackBoundsCache = null;
@@ -72,23 +104,14 @@ export class CameraController {
72
104
  }
73
105
 
74
106
  const canvasRect = this.canvasHost?.getBoundingClientRect?.();
75
- const towerRect = this.readouts?.timingTower?.getBoundingClientRect?.();
76
- if (!canvasRect || !towerRect) {
107
+ if (!canvasRect) {
77
108
  const safeArea = { left: 0, width };
78
109
  this.safeAreaCache = { width, safeArea };
79
110
  return safeArea;
80
111
  }
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
112
  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) {
113
+ const towerRect = timingTowerFrameForCanvas(this.readouts, canvasRect, canvasWidth);
114
+ if (!towerRect) {
92
115
  const safeArea = { left: 0, width };
93
116
  this.safeAreaCache = { width, safeArea };
94
117
  return safeArea;
@@ -149,12 +172,16 @@ export class CameraController {
149
172
  }
150
173
 
151
174
  isModeAvailable(mode, snapshot) {
152
- return mode !== 'pit' || this.hasPitCamera(snapshot);
175
+ if (!Object.hasOwn(CAMERA_PRESETS, mode)) return false;
176
+ if (mode === 'pit') return this.hasPitCamera(snapshot);
177
+ if (mode === 'driver') return this.driverCamera && Boolean(this.getFollowTarget(snapshot));
178
+ return true;
153
179
  }
154
180
 
155
181
  getFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }, selectedId = null) {
182
+ const activeSnapshot = snapshot ?? {};
156
183
  const screenCenterX = safeArea.left + safeArea.width / 2;
157
- const trackBounds = this.getTrackBounds(snapshot.track);
184
+ const trackBounds = this.getTrackBounds(activeSnapshot.track);
158
185
  const trackFitScale = this.getBoundsFitScale(trackBounds, height, safeArea);
159
186
 
160
187
  if (this.camera.mode === 'overview') {
@@ -179,7 +206,7 @@ export class CameraController {
179
206
 
180
207
  if (this.camera.mode === 'show-all') {
181
208
  return this.applyFreeFrame(getShowAllCameraFrame({
182
- cars: snapshot.cars,
209
+ cars: activeSnapshot.cars ?? [],
183
210
  cameraZoom: this.camera.zoom,
184
211
  height,
185
212
  safeArea,
@@ -188,17 +215,29 @@ export class CameraController {
188
215
  }));
189
216
  }
190
217
 
191
- if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
218
+ if (this.camera.mode === 'pit' && this.hasPitCamera(activeSnapshot)) {
192
219
  return this.applyFreeFrame(
193
- this.getPitFrame(snapshot.track.pitLane, height, baseScale, safeArea, screenCenterX, trackFitScale),
220
+ this.getPitFrame(activeSnapshot.track.pitLane, height, baseScale, safeArea, screenCenterX, trackFitScale),
194
221
  );
195
222
  }
196
223
 
224
+ if (this.camera.mode === 'driver' && this.driverCamera) {
225
+ const target = this.getFollowTarget(activeSnapshot, selectedId);
226
+ return this.applyFreeFrame({
227
+ target,
228
+ scale: clampCameraScale(baseScale * this.camera.zoom, baseScale, trackFitScale),
229
+ screenX: screenCenterX,
230
+ screenY: height * 0.68,
231
+ rotation: Number.isFinite(target?.heading) ? normalizeAngle(-Math.PI / 2 - target.heading) : 0,
232
+ });
233
+ }
234
+
197
235
  return this.applyFreeFrame({
198
- target: this.getTarget(snapshot, selectedId),
236
+ target: this.getTarget(activeSnapshot, selectedId),
199
237
  scale: clampCameraScale(baseScale * this.camera.zoom, baseScale, trackFitScale),
200
238
  screenX: screenCenterX,
201
239
  screenY: height / 2,
240
+ rotation: 0,
202
241
  });
203
242
  }
204
243
 
@@ -211,19 +250,27 @@ export class CameraController {
211
250
  }
212
251
 
213
252
  getTarget(snapshot, selectedId = null) {
253
+ const activeSnapshot = snapshot ?? {};
214
254
  if (this.camera.free && this.camera.freeTarget) return this.camera.freeTarget;
215
- if (this.camera.mode === 'overview') return this.getTrackTarget(snapshot.track);
255
+ if (this.camera.mode === 'overview') return this.getTrackTarget(activeSnapshot.track);
216
256
 
217
- if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
218
- return this.getPitTarget(snapshot.track.pitLane);
257
+ if (this.camera.mode === 'pit' && this.hasPitCamera(activeSnapshot)) {
258
+ return this.getPitTarget(activeSnapshot.track.pitLane);
219
259
  }
220
260
 
221
- if (this.camera.mode === 'selected') {
222
- const selected = snapshot.cars.find((car) => car.id === selectedId);
223
- if (selected) return selected;
261
+ if (this.camera.mode === 'selected' || this.camera.mode === 'driver') {
262
+ return this.getFollowTarget(activeSnapshot, selectedId);
224
263
  }
225
264
 
226
- const leader = snapshot.cars[0];
265
+ return this.getFollowTarget(activeSnapshot);
266
+ }
267
+
268
+ getFollowTarget(snapshot, selectedId = null) {
269
+ if (selectedId != null) {
270
+ const selected = snapshot?.cars?.find((car) => car.id === selectedId);
271
+ if (selected) return selected;
272
+ }
273
+ const leader = snapshot?.cars?.[0];
227
274
  return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
228
275
  }
229
276
 
@@ -244,6 +291,7 @@ export class CameraController {
244
291
  const frame = this.getFrame(snapshot, width, height, baseScale, safeArea, selectedId);
245
292
  const scale = frame.scale;
246
293
  const target = frame.target;
294
+ const targetRotation = Number.isFinite(frame.rotation) ? frame.rotation : 0;
247
295
  const snapCamera = immediate || !this.camera.initialized;
248
296
  this.camera.x = snapCamera
249
297
  ? target.x
@@ -254,12 +302,21 @@ export class CameraController {
254
302
  const activeScale = snapCamera || this.camera.scale === null
255
303
  ? scale
256
304
  : this.camera.scale + (scale - this.camera.scale) * CAMERA_SCALE_LERP;
305
+ const activeRotation = snapCamera
306
+ ? targetRotation
307
+ : this.camera.rotation + normalizeAngle(targetRotation - this.camera.rotation) * CAMERA_ROTATION_LERP;
257
308
  this.camera.initialized = true;
258
309
  this.camera.scale = activeScale;
310
+ this.camera.rotation = activeRotation;
259
311
  worldLayer.scale.set(activeScale);
312
+ worldLayer.rotation = activeRotation;
313
+ const cos = Math.cos(activeRotation);
314
+ const sin = Math.sin(activeRotation);
315
+ const transformedX = this.camera.x * cos - this.camera.y * sin;
316
+ const transformedY = this.camera.x * sin + this.camera.y * cos;
260
317
  worldLayer.position.set(
261
- frame.screenX - this.camera.x * activeScale,
262
- frame.screenY - this.camera.y * activeScale,
318
+ frame.screenX - transformedX * activeScale,
319
+ frame.screenY - transformedY * activeScale,
263
320
  );
264
321
  }
265
322
  }
@@ -74,7 +74,18 @@ export function getTrackCameraTarget(trackBounds) {
74
74
  }
75
75
 
76
76
  export function getShowAllCameraFrame({ cars, cameraZoom, height, safeArea, screenCenterX, trackFitScale }) {
77
- const bounds = cars.reduce((box, car) => ({
77
+ const safeCars = Array.isArray(cars) ? cars : [];
78
+ if (safeCars.length === 0) {
79
+ const fallbackScale = clampCameraScale(trackFitScale ?? 1, trackFitScale ?? 1, trackFitScale);
80
+ return {
81
+ target: { x: WORLD.width / 2, y: WORLD.height / 2 },
82
+ scale: fallbackScale,
83
+ screenX: screenCenterX,
84
+ screenY: height / 2,
85
+ };
86
+ }
87
+
88
+ const bounds = safeCars.reduce((box, car) => ({
78
89
  minX: Math.min(box.minX, car.x),
79
90
  minY: Math.min(box.minY, car.y),
80
91
  maxX: Math.max(box.maxX, car.x),
@@ -1,15 +1,90 @@
1
+ function toArray(nodes) {
2
+ return [...(nodes ?? [])];
3
+ }
4
+
5
+ export function resolveNodes(nodes, fallbackNode = null) {
6
+ const resolved = [];
7
+ if (typeof nodes?.forEach === 'function') {
8
+ nodes.forEach((node) => {
9
+ if (node && !resolved.includes(node)) resolved.push(node);
10
+ });
11
+ }
12
+ if (resolved.length === 0 && fallbackNode) resolved.push(fallbackNode);
13
+ return resolved;
14
+ }
15
+
16
+ function queryRaceDataPanelBindings(root) {
17
+ const panelSelector = '[data-race-data-panel]';
18
+ const panels = toArray(root.querySelectorAll(panelSelector));
19
+ const fallbackPanel = panels.length ? null : root.querySelector(panelSelector);
20
+ const raceDataPanels = panels.length ? panels : (fallbackPanel ? [fallbackPanel] : []);
21
+
22
+ const findInPanel = (panel, selector) => panel?.querySelector?.(selector) ?? null;
23
+ const findFallback = (panel, selector) => findInPanel(panel, selector) ?? root.querySelector(selector);
24
+ const bindings = raceDataPanels.map((panel) => ({
25
+ panel,
26
+ kicker: findFallback(panel, '[data-race-data-kicker]'),
27
+ title: findFallback(panel, '[data-race-data-title]'),
28
+ number: findFallback(panel, '[data-race-data-number]'),
29
+ subtitle: findFallback(panel, '[data-race-data-subtitle]'),
30
+ open: findFallback(panel, '[data-race-data-open]'),
31
+ dismiss: findFallback(panel, '[data-race-data-dismiss]'),
32
+ }));
33
+
34
+ const first = bindings[0] ?? {};
35
+ const fallbackOpen = first.open ?? root.querySelector('[data-race-data-open]');
36
+ const fallbackDismiss = first.dismiss ?? root.querySelector('[data-race-data-dismiss]');
37
+ return {
38
+ raceDataPanelBindings: bindings,
39
+ raceDataPanels,
40
+ raceDataKickers: bindings.map((binding) => binding.kicker).filter(Boolean),
41
+ raceDataTitles: bindings.map((binding) => binding.title).filter(Boolean),
42
+ raceDataNumbers: bindings.map((binding) => binding.number).filter(Boolean),
43
+ raceDataSubtitles: bindings.map((binding) => binding.subtitle).filter(Boolean),
44
+ raceDataOpens: bindings.map((binding) => binding.open).filter(Boolean),
45
+ raceDataDismisses: bindings.map((binding) => binding.dismiss).filter(Boolean),
46
+ raceDataPanel: first.panel ?? null,
47
+ raceDataKicker: first.kicker ?? null,
48
+ raceDataTitle: first.title ?? null,
49
+ raceDataNumber: first.number ?? null,
50
+ raceDataSubtitle: first.subtitle ?? null,
51
+ raceDataOpen: fallbackOpen ?? null,
52
+ raceDataDismiss: fallbackDismiss ?? null,
53
+ };
54
+ }
55
+
56
+ function queryCarOverviewFieldBindings(root) {
57
+ const fields = toArray(root.querySelectorAll('[data-overview-field]'));
58
+ return fields.map((field) => ({
59
+ field,
60
+ label: field.querySelector?.('[data-overview-field-label]') ?? null,
61
+ value: field.querySelector?.('[data-overview-field-value]') ?? null,
62
+ }));
63
+ }
64
+
1
65
  export function querySimulatorDom(root) {
66
+ const raceDataBindings = queryRaceDataPanelBindings(root);
67
+ const carOverviewFieldBindings = queryCarOverviewFieldBindings(root);
2
68
  const readouts = {
3
69
  timingTower: root.querySelector('[data-timing-tower]'),
70
+ timingTowers: root.querySelectorAll('[data-timing-tower]'),
71
+ timingPanelToggle: root.querySelector('[data-timing-panel-toggle]'),
72
+ timingPanelToggles: root.querySelectorAll('[data-timing-panel-toggle]'),
4
73
  mode: root.querySelector('[data-race-mode]'),
5
74
  startLights: root.querySelector('[data-start-lights]'),
6
75
  startLightsLabel: root.querySelector('[data-start-lights-label]'),
7
76
  towerLap: root.querySelector('[data-tower-lap-readout]'),
77
+ towerLaps: root.querySelectorAll('[data-tower-lap-readout]'),
8
78
  towerTotalLaps: root.querySelector('[data-tower-total-laps]'),
79
+ towerTotalLapsAll: root.querySelectorAll('[data-tower-total-laps]'),
9
80
  towerRaceControlBanner: root.querySelector('[data-tower-race-control-banner]'),
81
+ towerRaceControlBanners: root.querySelectorAll('[data-tower-race-control-banner]'),
10
82
  towerRaceControlKicker: root.querySelector('[data-tower-race-control-kicker]'),
83
+ towerRaceControlKickers: root.querySelectorAll('[data-tower-race-control-kicker]'),
11
84
  towerRaceControlTitle: root.querySelector('[data-tower-race-control-title]'),
85
+ towerRaceControlTitles: root.querySelectorAll('[data-tower-race-control-title]'),
12
86
  timingGapLabel: root.querySelector('[data-timing-gap-label]'),
87
+ timingGapLabels: root.querySelectorAll('[data-timing-gap-label]'),
13
88
  lap: root.querySelector('[data-lap-readout]'),
14
89
  drs: root.querySelector('[data-drs-readout]'),
15
90
  contacts: root.querySelector('[data-contact-readout]'),
@@ -48,6 +123,7 @@ export function querySimulatorDom(root) {
48
123
  carOverviewNumber: root.querySelector('[data-car-overview-number]'),
49
124
  carOverviewCoreStat: root.querySelector('[data-car-overview-core-stat]'),
50
125
  carOverviewFields: root.querySelectorAll('[data-overview-field]'),
126
+ carOverviewFieldBindings,
51
127
  telemetryDrawerWorkbench: root.querySelector('[data-race-telemetry-drawer]'),
52
128
  telemetryDrawer: root.querySelector('[data-telemetry-drawer]'),
53
129
  telemetryDrawerToggle: root.querySelector('[data-telemetry-drawer-toggle]'),
@@ -55,13 +131,7 @@ export function querySimulatorDom(root) {
55
131
  stewardMessageKicker: root.querySelector('[data-steward-message-kicker]'),
56
132
  stewardMessageTitle: root.querySelector('[data-steward-message-title]'),
57
133
  stewardMessageDetail: root.querySelector('[data-steward-message-detail]'),
58
- raceDataPanel: root.querySelector('[data-race-data-panel]'),
59
- raceDataKicker: root.querySelector('[data-race-data-kicker]'),
60
- raceDataTitle: root.querySelector('[data-race-data-title]'),
61
- raceDataNumber: root.querySelector('[data-race-data-number]'),
62
- raceDataSubtitle: root.querySelector('[data-race-data-subtitle]'),
63
- raceDataOpen: root.querySelector('[data-race-data-open]'),
64
- raceDataDismiss: root.querySelector('[data-race-data-dismiss]'),
134
+ ...raceDataBindings,
65
135
  finishPanel: root.querySelector('[data-race-finish-panel]'),
66
136
  finishWinner: root.querySelector('[data-race-finish-winner]'),
67
137
  finishClassification: root.querySelector('[data-race-finish-classification]'),
@@ -71,15 +141,16 @@ export function querySimulatorDom(root) {
71
141
  canvasHost: root.querySelector('[data-track-canvas]'),
72
142
  safetyButtons: root.querySelectorAll('[data-safety-car]'),
73
143
  restartButton: root.querySelector('[data-restart-race]'),
74
- openButton: root.querySelector('[data-race-data-open]'),
144
+ openButton: raceDataBindings.raceDataOpen,
75
145
  timingList: root.querySelector('[data-timing-list]'),
76
- timingGapModeButtons: root.querySelectorAll('[data-timing-gap-mode]'),
146
+ timingGapModeButtons: root.querySelectorAll('[data-timing-gap-toggle]'),
77
147
  cameraButtons: root.querySelectorAll('[data-camera-mode]'),
78
148
  overviewModeButtons: root.querySelectorAll('[data-overview-mode]'),
79
149
  bannerMuteButtons: root.querySelectorAll('[data-race-data-banners-muted]'),
80
150
  simulationSpeedButtons: root.querySelectorAll('[data-simulation-speed]'),
81
151
  zoomInButton: root.querySelector('[data-zoom-in]'),
82
152
  zoomOutButton: root.querySelector('[data-zoom-out]'),
153
+ timingLists: root.querySelectorAll('[data-timing-list]'),
83
154
  readouts,
84
155
  startLightNodes: [...(readouts.startLights?.querySelectorAll('.start-lights__gantry span') ?? [])],
85
156
  };
@@ -91,6 +162,15 @@ export function setText(node, value) {
91
162
  if (node.textContent !== nextValue) node.textContent = nextValue;
92
163
  }
93
164
 
165
+ export function setStyleProperty(node, name, value) {
166
+ if (!node?.style?.setProperty) return;
167
+ const nextValue = String(value ?? '');
168
+ const currentValue = typeof node.style.getPropertyValue === 'function'
169
+ ? node.style.getPropertyValue(name)
170
+ : null;
171
+ if (currentValue !== nextValue) node.style.setProperty(name, nextValue);
172
+ }
173
+
94
174
  export function setTextAll(nodes, value) {
95
175
  if (!nodes) return;
96
176
  if (typeof nodes.forEach === 'function') {
@@ -0,0 +1,27 @@
1
+ export function createRafScheduler(sync) {
2
+ let frame = null;
3
+
4
+ const run = () => {
5
+ frame = null;
6
+ sync();
7
+ };
8
+
9
+ return {
10
+ runNow: sync,
11
+ queue() {
12
+ if (frame !== null) return;
13
+ if (typeof requestAnimationFrame !== 'function') {
14
+ sync();
15
+ return;
16
+ }
17
+ frame = requestAnimationFrame(run);
18
+ },
19
+ cancel() {
20
+ if (frame === null) return;
21
+ if (typeof cancelAnimationFrame === 'function') {
22
+ cancelAnimationFrame(frame);
23
+ }
24
+ frame = null;
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,165 @@
1
+ import { createRafScheduler } from './layoutScheduler.js';
2
+
3
+ export const PADDOCK_MIN_SUPPORTED_INLINE_SIZE = 320;
4
+
5
+ const MIN_INLINE_SIZE_BY_COMPONENT = {
6
+ 'race-controls': 260,
7
+ 'camera-controls': 160,
8
+ 'safety-car-control': 44,
9
+ 'timing-tower': 255,
10
+ 'race-canvas': 180,
11
+ 'race-telemetry-drawer': 280,
12
+ 'telemetry-stack': 260,
13
+ 'telemetry-core': 260,
14
+ 'telemetry-sectors': 240,
15
+ 'telemetry-lap-times': 240,
16
+ 'telemetry-sector-times': 240,
17
+ 'telemetry-sector-banner': 240,
18
+ 'car-driver-overview': 260,
19
+ 'race-data-panel': 260,
20
+ 'steward-message': 240,
21
+ };
22
+
23
+ const MIN_BLOCK_SIZE_BY_COMPONENT = {
24
+ 'race-controls': 88,
25
+ 'camera-controls': 56,
26
+ 'safety-car-control': 44,
27
+ 'timing-tower': 220,
28
+ 'race-canvas': 180,
29
+ 'race-telemetry-drawer': 340,
30
+ 'telemetry-stack': 180,
31
+ 'telemetry-core': 150,
32
+ 'telemetry-sectors': 96,
33
+ 'telemetry-lap-times': 120,
34
+ 'telemetry-sector-times': 140,
35
+ 'telemetry-sector-banner': 88,
36
+ 'car-driver-overview': 180,
37
+ 'race-data-panel': 88,
38
+ 'steward-message': 64,
39
+ };
40
+
41
+ function componentMinimumInlineSize(element) {
42
+ if (element.hasAttribute('data-f1-simulator-shell')) return PADDOCK_MIN_SUPPORTED_INLINE_SIZE;
43
+ const componentName = element.getAttribute('data-paddock-component');
44
+ if (componentName && MIN_INLINE_SIZE_BY_COMPONENT[componentName]) {
45
+ return MIN_INLINE_SIZE_BY_COMPONENT[componentName];
46
+ }
47
+ return PADDOCK_MIN_SUPPORTED_INLINE_SIZE;
48
+ }
49
+
50
+ function componentMinimumBlockSize(element) {
51
+ const componentName = element.getAttribute('data-paddock-component');
52
+ if (componentName && MIN_BLOCK_SIZE_BY_COMPONENT[componentName]) {
53
+ return MIN_BLOCK_SIZE_BY_COMPONENT[componentName];
54
+ }
55
+ return element.hasAttribute('data-f1-simulator-shell') ? 360 : 0;
56
+ }
57
+
58
+ function isRendered(element) {
59
+ if (
60
+ element.hidden ||
61
+ element.getAttribute('aria-hidden') === 'true' ||
62
+ element.classList?.contains('is-hidden')
63
+ ) {
64
+ return false;
65
+ }
66
+ const rect = element.getBoundingClientRect();
67
+ const style = getComputedStyle(element);
68
+ return rect.width > 0 &&
69
+ rect.height > 0 &&
70
+ style.display !== 'none' &&
71
+ style.visibility !== 'hidden';
72
+ }
73
+
74
+ function clearElementSupportState(element) {
75
+ if (element.dataset.paddockSizeUnsupported !== 'false') {
76
+ element.dataset.paddockSizeUnsupported = 'false';
77
+ }
78
+ delete element.dataset.paddockSizeUnsupportedReason;
79
+ }
80
+
81
+ function syncElementSupportState(element) {
82
+ if (!isRendered(element)) {
83
+ clearElementSupportState(element);
84
+ return;
85
+ }
86
+ const rect = element.getBoundingClientRect();
87
+ const minInlineSize = componentMinimumInlineSize(element);
88
+ const minBlockSize = componentMinimumBlockSize(element);
89
+ const inlineUnsupported = rect.width < minInlineSize;
90
+ const blockUnsupported = minBlockSize > 0 && rect.height < minBlockSize;
91
+ const unsupported = inlineUnsupported || blockUnsupported;
92
+ const unsupportedValue = unsupported ? 'true' : 'false';
93
+ if (element.dataset.paddockSizeUnsupported !== unsupportedValue) {
94
+ element.dataset.paddockSizeUnsupported = unsupportedValue;
95
+ }
96
+ if (inlineUnsupported) {
97
+ if (element.dataset.paddockSizeUnsupportedReason !== 'inline') {
98
+ element.dataset.paddockSizeUnsupportedReason = 'inline';
99
+ }
100
+ } else if (blockUnsupported) {
101
+ if (element.dataset.paddockSizeUnsupportedReason !== 'block') {
102
+ element.dataset.paddockSizeUnsupportedReason = 'block';
103
+ }
104
+ } else {
105
+ delete element.dataset.paddockSizeUnsupportedReason;
106
+ }
107
+ }
108
+
109
+ function layoutSupportElements(root) {
110
+ const queryRoot = root?.children ? root : null;
111
+ if (!queryRoot) return [];
112
+ const elements = [];
113
+ if (root.matches?.('[data-f1-simulator-shell], [data-paddock-component]')) {
114
+ elements.push(root);
115
+ } else {
116
+ elements.push(
117
+ ...[...queryRoot.children].filter((element) => (
118
+ element.matches?.('[data-f1-simulator-shell], [data-paddock-component]')
119
+ )),
120
+ );
121
+ }
122
+ return [...new Set(elements)];
123
+ }
124
+
125
+ export function installLayoutSupport(root) {
126
+ const elements = layoutSupportElements(root);
127
+ const syncElements = () => elements.forEach(syncElementSupportState);
128
+ const scheduler = createRafScheduler(syncElements);
129
+ scheduler.runNow();
130
+
131
+ if (typeof ResizeObserver !== 'function') {
132
+ return () => {};
133
+ }
134
+
135
+ const observer = new ResizeObserver((entries) => {
136
+ if (typeof requestAnimationFrame !== 'function') {
137
+ entries.forEach((entry) => syncElementSupportState(entry.target));
138
+ return;
139
+ }
140
+ scheduler.queue();
141
+ });
142
+ elements.forEach((element) => observer.observe(element));
143
+
144
+ const mutationObserver = typeof MutationObserver === 'function'
145
+ ? new MutationObserver(scheduler.queue)
146
+ : null;
147
+ elements.forEach((element) => {
148
+ mutationObserver?.observe(element, {
149
+ attributes: true,
150
+ attributeFilter: ['aria-hidden', 'class', 'hidden', 'style'],
151
+ childList: true,
152
+ subtree: true,
153
+ });
154
+ });
155
+
156
+ if (typeof requestAnimationFrame === 'function') {
157
+ scheduler.queue();
158
+ }
159
+
160
+ return () => {
161
+ observer.disconnect();
162
+ mutationObserver?.disconnect();
163
+ scheduler.cancel();
164
+ };
165
+ }