@inventure71/paddockjs 0.1.0 → 0.3.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 (40) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/INSTALL_AND_UPDATE.md +163 -0
  3. package/README.md +96 -16
  4. package/docs/architecture.md +55 -11
  5. package/docs/concepts.md +5 -1
  6. package/docs/data_contract.md +324 -8
  7. package/docs/data_model.md +1 -0
  8. package/docs/index.md +1 -0
  9. package/docs/rules.md +2 -0
  10. package/docs/system_specs.md +66 -11
  11. package/docs/training.md +104 -0
  12. package/examples/train-basic-policy.mjs +277 -0
  13. package/package.json +14 -7
  14. package/src/api/PaddockSimulatorController.js +64 -3
  15. package/src/app/BrowserExpertAdapter.js +32 -0
  16. package/src/app/F1SimulatorApp.js +308 -38
  17. package/src/app/domBindings.js +35 -11
  18. package/src/config/defaultOptions.js +33 -0
  19. package/src/data/championship.js +13 -0
  20. package/src/data/normalizeDrivers.js +7 -2
  21. package/src/environment/actions.js +45 -0
  22. package/src/environment/episode.js +17 -0
  23. package/src/environment/events.js +17 -0
  24. package/src/environment/index.d.ts +241 -0
  25. package/src/environment/index.js +2 -0
  26. package/src/environment/observations.js +112 -0
  27. package/src/environment/options.js +150 -0
  28. package/src/environment/rewards.js +69 -0
  29. package/src/environment/runtime.js +140 -0
  30. package/src/environment/sensors.js +257 -0
  31. package/src/environment/specs.js +79 -0
  32. package/src/index.d.ts +97 -4
  33. package/src/index.js +12 -2
  34. package/src/rendering/proceduralTrackAsset.js +7 -1
  35. package/src/simulation/raceSimulation.js +214 -1
  36. package/src/simulation/trackModel.js +21 -0
  37. package/src/styles.css +495 -2
  38. package/src/ui/componentTemplates.js +201 -4
  39. package/AGENTS.md +0 -86
  40. package/src/standalone.js +0 -15
@@ -2,14 +2,16 @@ import { Application, Assets, Container, Graphics, Sprite, Texture } from 'pixi.
2
2
  import { DRIVER_STAT_DEFINITIONS, formatDriverNumber, VEHICLE_STAT_DEFINITIONS } from '../data/championship.js';
3
3
  import { applyPaddockThemeCssVariables } from '../config/defaultOptions.js';
4
4
  import { normalizeCustomFields } from '../data/customFields.js';
5
+ import { getCarRayOrigin, getCarRayVector } from '../environment/sensors.js';
5
6
  import { ProceduralTrackAsset } from '../rendering/proceduralTrackAsset.js';
6
7
  import { createRenderSnapshot } from '../rendering/renderSnapshot.js';
7
- import { createRaceSimulation } from '../simulation/raceSimulation.js';
8
+ import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
8
9
  import { clamp } from '../simulation/simMath.js';
9
10
  import { offsetTrackPoint, pointAt, WORLD } from '../simulation/trackModel.js';
10
- import { querySimulatorDom, setText } from './domBindings.js';
11
+ import { metersToSimUnits } from '../simulation/units.js';
12
+ import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
13
+ import { querySimulatorDom, setText, setTextAll } from './domBindings.js';
11
14
 
12
- const FIXED_STEP = 1 / 60;
13
15
  const TARGET_RENDER_FPS = 60;
14
16
  const TARGET_FRAME_MS = 1000 / TARGET_RENDER_FPS;
15
17
  const FRAME_PACING_EPSILON_MS = 0.75;
@@ -34,6 +36,9 @@ const SHOW_ALL_TOP_RESERVED = 92;
34
36
  const SHOW_ALL_BOTTOM_RESERVED = 132;
35
37
  const DRS_TRAIL_TTL = 0.68;
36
38
  const DRS_TRAIL_MIN_DISTANCE = 10;
39
+ const SENSOR_RAY_TRACK_COLOR = 0xf1c65b;
40
+ const SENSOR_RAY_TRACK_ENTRY_COLOR = 0x68d8ff;
41
+ const SENSOR_RAY_CAR_COLOR = 0xff4d5f;
37
42
  const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
38
43
  const RADIO_BREAK_MIN_MS = 4800;
39
44
  const RADIO_BREAK_MAX_MS = 11800;
@@ -84,6 +89,23 @@ function getTireClass(tire) {
84
89
  return String(tire ?? 'M').toLowerCase();
85
90
  }
86
91
 
92
+ function formatTelemetryTime(value) {
93
+ if (!Number.isFinite(value)) return '--';
94
+ const clamped = Math.max(0, value);
95
+ if (clamped >= 60) {
96
+ const minutes = Math.floor(clamped / 60);
97
+ const seconds = clamped - minutes * 60;
98
+ return `${minutes}:${seconds.toFixed(3).padStart(6, '0')}`;
99
+ }
100
+ return `${clamped.toFixed(3)}s`;
101
+ }
102
+
103
+ function setPerformanceClass(node, status) {
104
+ ['overall-best', 'personal-best', 'slower'].forEach((name) => {
105
+ node?.classList?.toggle?.(`is-${name}`, status === name);
106
+ });
107
+ }
108
+
87
109
  function colorToTint(color) {
88
110
  if (TINT_BY_COLOR.has(color)) return TINT_BY_COLOR.get(color);
89
111
  const tint = Number.parseInt(String(color ?? '').replace('#', ''), 16);
@@ -99,6 +121,43 @@ function smoothAngle(current, target, amount) {
99
121
  return current + diff * amount;
100
122
  }
101
123
 
124
+ function destroyDisplayChildren(container) {
125
+ container?.removeChildren?.().forEach((child) => {
126
+ child.destroy?.({ children: true, texture: false, textureSource: false });
127
+ });
128
+ }
129
+
130
+ function expertVisualizesRays(expertOptions) {
131
+ const setting = expertOptions?.visualizeSensors;
132
+ if (setting === true) return true;
133
+ if (!setting || setting === false) return false;
134
+ return Boolean(setting.rays);
135
+ }
136
+
137
+ function pointFromRay(origin, ray, distance) {
138
+ return {
139
+ x: origin.x + ray.x * distance,
140
+ y: origin.y + ray.y * distance,
141
+ };
142
+ }
143
+
144
+ function hasOwnOption(options, key) {
145
+ return Object.hasOwn(options ?? {}, key);
146
+ }
147
+
148
+ function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
149
+ return nextExpertOptions !== currentExpertOptions;
150
+ }
151
+
152
+ function assetSetsEqual(first = {}, second = {}) {
153
+ const firstTrackTextures = first.trackTextures ?? {};
154
+ const secondTrackTextures = second.trackTextures ?? {};
155
+ const assetKeys = ['car', 'carOverview', 'driverHelmet', 'safetyCar', 'broadcastPanel', 'f1Logo'];
156
+ const textureKeys = new Set([...Object.keys(firstTrackTextures), ...Object.keys(secondTrackTextures)]);
157
+ return assetKeys.every((key) => first[key] === second[key]) &&
158
+ [...textureKeys].every((key) => firstTrackTextures[key] === secondTrackTextures[key]);
159
+ }
160
+
102
161
  export class F1SimulatorApp {
103
162
  constructor(root, options) {
104
163
  this.root = root;
@@ -117,12 +176,15 @@ export class F1SimulatorApp {
117
176
  this.visibilityChangeHandler = null;
118
177
  this.visibilityObserver = null;
119
178
  this.tickerCallback = null;
179
+ this.expert = null;
180
+ this.expertMode = Boolean(options.expert?.enabled);
120
181
  this.sim = null;
121
182
  this.app = null;
122
183
  this.worldLayer = null;
123
184
  this.trackAsset = null;
124
185
  this.drsLayer = null;
125
186
  this.trailLayer = null;
187
+ this.sensorLayer = null;
126
188
  this.carLayer = null;
127
189
  this.textures = {};
128
190
  this.carSprites = new Map();
@@ -155,6 +217,9 @@ export class F1SimulatorApp {
155
217
  this.lastDomUpdateTime = 0;
156
218
  this.lastTimingRenderTime = 0;
157
219
  this.lastTimingRaceMode = null;
220
+ this.lastTimingMarkup = null;
221
+ this.lastOverviewRenderKey = null;
222
+ this.lastFinishClassificationMarkup = null;
158
223
  this.runtimeViewportVisible = true;
159
224
  this.runtimeDocumentVisible = typeof document === 'undefined' ? true : document.visibilityState !== 'hidden';
160
225
  this.runtimeTickerRunning = true;
@@ -167,6 +232,9 @@ export class F1SimulatorApp {
167
232
  this.emittedRaceEventKeys = new Set();
168
233
  this.raceFinishEmitted = false;
169
234
  this.timingGapMode = 'interval';
235
+ this.telemetryDrawerOpen = Boolean(
236
+ this.readouts.telemetryDrawerWorkbench?.classList?.contains?.('is-telemetry-open'),
237
+ );
170
238
  }
171
239
 
172
240
  async init() {
@@ -190,29 +258,36 @@ export class F1SimulatorApp {
190
258
  this.worldLayer = new Container();
191
259
  this.drsLayer = new Container();
192
260
  this.trailLayer = new Graphics();
261
+ this.sensorLayer = new Graphics();
193
262
  this.carLayer = new Container();
194
263
  this.app.stage.addChild(this.worldLayer);
195
264
 
196
265
  await this.loadAssets();
197
266
  this.trackAsset = new ProceduralTrackAsset({ textures: this.textures, world: WORLD });
198
- this.worldLayer.addChild(this.trackAsset.container, this.drsLayer, this.trailLayer, this.carLayer);
267
+ this.worldLayer.addChild(this.trackAsset.container, this.drsLayer, this.trailLayer, this.sensorLayer, this.carLayer);
199
268
  this.createCars();
200
269
  this.bindControls();
201
270
  this.renderTrack();
202
271
  const snapshot = this.sim.snapshot();
203
272
  this.updateDom(snapshot);
273
+ if (this.expertMode) {
274
+ this.expert = createBrowserExpertAdapter(this, this.options.expert);
275
+ }
204
276
  this.completeComponentLoading();
205
277
  this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
206
278
  this.emitHostCallback('onReady', { snapshot });
207
279
  this.resizeHandler = () => this.applyCamera(this.sim.snapshot());
208
280
  window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
209
- this.tickerCallback = () => this.tick();
210
- this.app.ticker.add(this.tickerCallback);
211
281
  this.observeLayoutResize();
212
- this.observeRuntimeVisibility();
282
+ if (!this.expertMode) {
283
+ this.tickerCallback = () => this.tick();
284
+ this.app.ticker.add(this.tickerCallback);
285
+ this.observeRuntimeVisibility();
286
+ }
213
287
  } catch (error) {
214
288
  this.emitHostCallback('onLoadingChange', { loading: false, phase: 'error' });
215
289
  this.emitHostCallback('onError', error, { phase: 'init' });
290
+ this.destroy();
216
291
  throw error;
217
292
  }
218
293
  }
@@ -241,12 +316,12 @@ export class F1SimulatorApp {
241
316
  });
242
317
  }
243
318
 
244
- createRaceSimulation() {
319
+ createRaceSimulation(options = this.options) {
245
320
  return createRaceSimulation({
246
- seed: this.options.seed,
247
- trackSeed: this.trackSeed,
248
- drivers: this.drivers,
249
- totalLaps: this.options.totalLaps,
321
+ seed: options.seed,
322
+ trackSeed: options.trackSeed ?? this.trackSeed,
323
+ drivers: options.drivers ?? this.drivers,
324
+ totalLaps: options.totalLaps,
250
325
  });
251
326
  }
252
327
 
@@ -391,6 +466,27 @@ export class F1SimulatorApp {
391
466
  if (!driver) return;
392
467
  this.options.onDriverOpen?.(driver);
393
468
  }, eventOptions);
469
+
470
+ this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
471
+ this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
472
+ }, eventOptions);
473
+ }
474
+
475
+ setTelemetryDrawerOpen(open) {
476
+ this.telemetryDrawerOpen = Boolean(open);
477
+ this.readouts.telemetryDrawerWorkbench?.classList?.toggle?.('is-telemetry-open', this.telemetryDrawerOpen);
478
+ if (this.telemetryDrawerOpen) {
479
+ this.readouts.telemetryDrawer?.removeAttribute?.('inert');
480
+ this.readouts.telemetryDrawer?.setAttribute?.('aria-hidden', 'false');
481
+ } else {
482
+ this.readouts.telemetryDrawer?.setAttribute?.('inert', '');
483
+ this.readouts.telemetryDrawer?.setAttribute?.('aria-hidden', 'true');
484
+ }
485
+ this.readouts.telemetryDrawerToggle?.setAttribute?.('aria-expanded', String(this.telemetryDrawerOpen));
486
+ if (this.readouts.telemetryDrawerToggle) {
487
+ this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close telemetry' : 'Telemetry';
488
+ }
489
+ this.invalidateCameraSafeArea();
394
490
  }
395
491
 
396
492
  observeLayoutResize() {
@@ -436,6 +532,7 @@ export class F1SimulatorApp {
436
532
  }
437
533
 
438
534
  syncRuntimeTicker() {
535
+ if (this.expertMode) return;
439
536
  if (!this.app?.ticker) return;
440
537
 
441
538
  const shouldRun = this.runtimeViewportVisible && this.runtimeDocumentVisible;
@@ -459,6 +556,7 @@ export class F1SimulatorApp {
459
556
  }
460
557
 
461
558
  tick() {
559
+ if (this.expertMode) return;
462
560
  const now = performance.now();
463
561
  if (now < this.nextGameFrameTime - FRAME_PACING_EPSILON_MS) return;
464
562
 
@@ -501,8 +599,20 @@ export class F1SimulatorApp {
501
599
  }
502
600
  }
503
601
 
602
+ renderExpertFrame(snapshot = this.sim?.snapshot(), { observation } = {}) {
603
+ if (!snapshot) return;
604
+ const renderSnapshot = createRenderSnapshot(snapshot, 0);
605
+ this.applyCamera(renderSnapshot);
606
+ this.renderDrsTrails(renderSnapshot);
607
+ this.renderExpertSensorRays(renderSnapshot, observation);
608
+ this.renderCars(renderSnapshot);
609
+ this.updateDom(snapshot, { emitLifecycle: false });
610
+ this.lastDomUpdateTime = performance.now();
611
+ }
612
+
504
613
  renderTrack() {
505
- this.drsLayer.removeChildren();
614
+ destroyDisplayChildren(this.drsLayer);
615
+ this.sensorLayer?.clear();
506
616
  const snapshot = this.sim.snapshot();
507
617
  const track = snapshot.track;
508
618
 
@@ -558,6 +668,60 @@ export class F1SimulatorApp {
558
668
  }
559
669
  }
560
670
 
671
+ renderExpertSensorRays(snapshot, observation) {
672
+ if (!this.sensorLayer) return;
673
+ this.sensorLayer.clear();
674
+ if (!this.expertMode || !expertVisualizesRays(this.options.expert)) return;
675
+
676
+ const controlledDrivers = this.options.expert?.controlledDrivers ?? [];
677
+ if (!controlledDrivers.length || !observation) return;
678
+
679
+ const carsById = new Map(snapshot.cars.map((car) => [car.id, car]));
680
+ controlledDrivers.forEach((driverId) => {
681
+ const car = carsById.get(driverId);
682
+ const rays = observation?.[driverId]?.object?.rays;
683
+ if (!car || !Array.isArray(rays) || rays.length === 0) return;
684
+
685
+ const origin = getCarRayOrigin(car);
686
+
687
+ rays.forEach((ray) => {
688
+ const rayVector = getCarRayVector(car, Number(ray.angleDegrees) || 0);
689
+ const totalDistanceMeters = Math.max(0, Number(ray.lengthMeters) || 0);
690
+ const trackDistanceMeters = Math.max(0, Number(ray.track?.distanceMeters) || totalDistanceMeters);
691
+ const carDistanceMeters = Math.max(0, Number(ray.car?.distanceMeters) || totalDistanceMeters);
692
+ const trackHit = Boolean(ray.track?.hit) && trackDistanceMeters <= totalDistanceMeters;
693
+ const carHit = Boolean(ray.car?.hit) && carDistanceMeters <= totalDistanceMeters;
694
+ const fullEnd = pointFromRay(origin, rayVector, metersToSimUnits(totalDistanceMeters));
695
+
696
+ this.sensorLayer
697
+ .moveTo(origin.x, origin.y)
698
+ .lineTo(fullEnd.x, fullEnd.y)
699
+ .stroke({ width: 2, color: 0xffffff, alpha: 0.18, cap: 'round' });
700
+
701
+ if (!trackHit && !carHit) return;
702
+
703
+ const carIsClosest = carHit && (!trackHit || carDistanceMeters <= trackDistanceMeters);
704
+ const hitDistanceMeters = carIsClosest ? carDistanceMeters : trackDistanceMeters;
705
+ const hitEnd = pointFromRay(origin, rayVector, metersToSimUnits(hitDistanceMeters));
706
+ const hitColor = carIsClosest
707
+ ? SENSOR_RAY_CAR_COLOR
708
+ : ray.track?.kind === 'entry'
709
+ ? SENSOR_RAY_TRACK_ENTRY_COLOR
710
+ : SENSOR_RAY_TRACK_COLOR;
711
+
712
+ this.sensorLayer
713
+ .moveTo(origin.x, origin.y)
714
+ .lineTo(hitEnd.x, hitEnd.y)
715
+ .stroke({ width: carIsClosest ? 5 : 3.4, color: hitColor, alpha: carIsClosest ? 0.9 : 0.76, cap: 'round' });
716
+ this.sensorLayer
717
+ .circle(hitEnd.x, hitEnd.y, carIsClosest ? 7 : 5)
718
+ .fill({ color: hitColor, alpha: 0.92 })
719
+ .circle(hitEnd.x, hitEnd.y, carIsClosest ? 10 : 8)
720
+ .stroke({ width: 1.5, color: 0x10131a, alpha: 0.72 });
721
+ });
722
+ });
723
+ }
724
+
561
725
  renderDrsTrails(snapshot) {
562
726
  if (!this.trailLayer) return;
563
727
 
@@ -637,8 +801,17 @@ export class F1SimulatorApp {
637
801
  this.cameraSafeAreaCache = { width, safeArea };
638
802
  return safeArea;
639
803
  }
640
- const overlapsCanvas = towerRect.right > canvasRect.left && towerRect.left < canvasRect.right;
641
- if (!overlapsCanvas) {
804
+ const overlapsCanvasHorizontally = towerRect.right > canvasRect.left && towerRect.left < canvasRect.right;
805
+ const hasVerticalBounds = Number.isFinite(canvasRect.top) &&
806
+ Number.isFinite(canvasRect.bottom) &&
807
+ Number.isFinite(towerRect.top) &&
808
+ Number.isFinite(towerRect.bottom);
809
+ const overlapsCanvasVertically = !hasVerticalBounds ||
810
+ (towerRect.bottom > canvasRect.top && towerRect.top < canvasRect.bottom);
811
+ const canvasWidth = Math.max(1, canvasRect.right - canvasRect.left || width);
812
+ const towerWidth = Math.max(0, towerRect.right - towerRect.left);
813
+ const isSideGutter = towerWidth < canvasWidth * 0.6;
814
+ if (!overlapsCanvasHorizontally || !overlapsCanvasVertically || !isSideGutter) {
642
815
  const safeArea = { left: 0, width };
643
816
  this.cameraSafeAreaCache = { width, safeArea };
644
817
  return safeArea;
@@ -837,12 +1010,16 @@ export class F1SimulatorApp {
837
1010
 
838
1011
  if (this.readouts.finishClassification) {
839
1012
  const topThree = (snapshot.raceControl.classification ?? []).slice(0, 3);
840
- this.readouts.finishClassification.innerHTML = topThree.map((entry) => `
1013
+ const classificationMarkup = topThree.map((entry) => `
841
1014
  <li>
842
1015
  <span>P${escapeHtml(entry.rank)}</span>
843
1016
  <strong>${escapeHtml(entry.timingCode ?? entry.code ?? entry.id)}</strong>
844
1017
  </li>
845
1018
  `).join('');
1019
+ if (classificationMarkup !== this.lastFinishClassificationMarkup) {
1020
+ this.readouts.finishClassification.innerHTML = classificationMarkup;
1021
+ this.lastFinishClassificationMarkup = classificationMarkup;
1022
+ }
846
1023
  }
847
1024
  }
848
1025
 
@@ -903,7 +1080,7 @@ export class F1SimulatorApp {
903
1080
  renderTiming(cars, raceMode) {
904
1081
  if (!this.timingList) return;
905
1082
  this.syncTimingGapModeControls();
906
- this.timingList.innerHTML = cars.map((car) => {
1083
+ const timingMarkup = cars.map((car) => {
907
1084
  const driver = this.driverById.get(car.id);
908
1085
  let gap = 'Leader';
909
1086
  if (raceMode === 'finished') {
@@ -935,6 +1112,10 @@ export class F1SimulatorApp {
935
1112
  </li>
936
1113
  `;
937
1114
  }).join('');
1115
+ if (timingMarkup !== this.lastTimingMarkup) {
1116
+ this.timingList.innerHTML = timingMarkup;
1117
+ this.lastTimingMarkup = timingMarkup;
1118
+ }
938
1119
  }
939
1120
 
940
1121
  formatTimingGap(car) {
@@ -962,22 +1143,27 @@ export class F1SimulatorApp {
962
1143
  const drsState = car.drsActive ? 'OPEN' : car.drsEligible ? 'READY' : 'OFF';
963
1144
  const surface = (car.surface ?? 'track').toUpperCase();
964
1145
 
965
- setText(this.readouts.selectedCode, car.code);
966
- if (this.readouts.selectedCode) this.readouts.selectedCode.style.color = car.color;
967
- setText(this.readouts.selectedName, car.name);
968
- setText(this.readouts.speed, `${Math.round(car.speedKph)} km/h`);
969
- setText(this.readouts.throttle, `${Math.round(car.throttle * 100)}%`);
970
- setText(this.readouts.brake, `${Math.round(car.brake * 100)}%`);
971
- setText(this.readouts.tyres, `${Math.round(car.tireEnergy ?? 100)}%`);
972
- setText(this.readouts.selectedDrs, drsState);
973
- setText(this.readouts.surface, surface);
974
- setText(
1146
+ setTextAll(this.readouts.selectedCode, car.code);
1147
+ this.readouts.selectedCode?.forEach?.((node) => {
1148
+ node.style.color = car.color;
1149
+ });
1150
+ this.readouts.telemetrySectorBanners?.forEach?.((node) => {
1151
+ node.style.setProperty('--driver-color', car.color);
1152
+ });
1153
+ setTextAll(this.readouts.selectedName, car.name);
1154
+ setTextAll(this.readouts.speed, `${Math.round(car.speedKph)} km/h`);
1155
+ setTextAll(this.readouts.throttle, `${Math.round(car.throttle * 100)}%`);
1156
+ setTextAll(this.readouts.brake, `${Math.round(car.brake * 100)}%`);
1157
+ setTextAll(this.readouts.tyres, `${Math.round(car.tireEnergy ?? 100)}%`);
1158
+ setTextAll(this.readouts.selectedDrs, drsState);
1159
+ setTextAll(this.readouts.surface, surface);
1160
+ setTextAll(
975
1161
  this.readouts.gap,
976
1162
  car.rank === 1 || !Number.isFinite(car.gapAheadSeconds)
977
1163
  ? '--'
978
1164
  : `${car.gapAheadSeconds.toFixed(2)}s`,
979
1165
  );
980
- setText(
1166
+ setTextAll(
981
1167
  this.readouts.leaderGap,
982
1168
  car.rank === 1 || !Number.isFinite(car.leaderGapSeconds)
983
1169
  ? '--'
@@ -985,6 +1171,56 @@ export class F1SimulatorApp {
985
1171
  );
986
1172
 
987
1173
  this.renderCarDriverOverview(car);
1174
+ this.renderLapTelemetry(car.lapTelemetry);
1175
+ }
1176
+
1177
+ renderLapTelemetry(telemetry) {
1178
+ if (!telemetry) return;
1179
+
1180
+ setTextAll(this.readouts.currentSector, `S${telemetry.currentSector ?? 1}`);
1181
+ setTextAll(this.readouts.completedLaps, `${telemetry.completedLaps ?? 0} laps`);
1182
+ setTextAll(this.readouts.currentLapTime, formatTelemetryTime(telemetry.currentLapTime));
1183
+ setTextAll(this.readouts.lastLapTime, formatTelemetryTime(telemetry.lastLapTime));
1184
+ setTextAll(this.readouts.bestLapTime, formatTelemetryTime(telemetry.bestLapTime));
1185
+
1186
+ this.readouts.telemetrySectorBars?.forEach((bar) => {
1187
+ const sector = Number(bar.dataset.telemetrySectorBar);
1188
+ const index = sector - 1;
1189
+ const isActive = sector === telemetry.currentSector;
1190
+ const sectorComplete = Number.isFinite(telemetry.currentSectors?.[index]);
1191
+ const fill = sectorComplete
1192
+ ? 100
1193
+ : (isActive ? clamp((telemetry.currentSectorProgress ?? 0) * 100, 0, 100) : 0);
1194
+ const fillValue = `${fill.toFixed(1)}%`;
1195
+ if (bar.style.getPropertyValue('--sector-fill') !== fillValue) {
1196
+ bar.style.setProperty('--sector-fill', fillValue);
1197
+ }
1198
+ bar.classList.toggle('is-active', isActive);
1199
+ bar.classList.toggle('is-complete', sectorComplete);
1200
+ setPerformanceClass(bar, telemetry.sectorPerformance?.current?.[index]);
1201
+ });
1202
+
1203
+ this.readouts.telemetrySectorTimes?.forEach((node) => {
1204
+ const sector = Number(node.dataset.telemetrySectorTime);
1205
+ const index = sector - 1;
1206
+ const value = sector === telemetry.currentSector && !Number.isFinite(telemetry.currentSectors?.[index])
1207
+ ? telemetry.currentSectorElapsed
1208
+ : telemetry.currentSectors?.[index];
1209
+ setText(node, formatTelemetryTime(value));
1210
+ setPerformanceClass(node, telemetry.sectorPerformance?.current?.[index]);
1211
+ });
1212
+
1213
+ this.readouts.telemetrySectorLast?.forEach((node) => {
1214
+ const index = Number(node.dataset.telemetrySectorLast) - 1;
1215
+ setText(node, formatTelemetryTime(telemetry.lastSectors?.[index]));
1216
+ setPerformanceClass(node, telemetry.sectorPerformance?.last?.[index]);
1217
+ });
1218
+
1219
+ this.readouts.telemetrySectorBest?.forEach((node) => {
1220
+ const index = Number(node.dataset.telemetrySectorBest) - 1;
1221
+ setText(node, formatTelemetryTime(telemetry.bestSectors?.[index]));
1222
+ setPerformanceClass(node, telemetry.sectorPerformance?.best?.[index]);
1223
+ });
988
1224
  }
989
1225
 
990
1226
  getOverviewFields(driver, mode) {
@@ -1024,6 +1260,25 @@ export class F1SimulatorApp {
1024
1260
  const displayFields = fields.length > 0
1025
1261
  ? fields
1026
1262
  : [{ label: mode === 'driver' ? 'Driver fields' : 'Car fields', value: 'No custom fields' }];
1263
+ const imageSrc = mode === 'driver'
1264
+ ? (driver?.driverImage ?? driver?.portrait ?? driver?.avatar ?? this.assets.driverHelmet)
1265
+ : this.assets.carOverview;
1266
+ const overviewCode = mode === 'driver'
1267
+ ? `${car.code} driver`
1268
+ : `${driver?.vehicle?.name ?? car.vehicleName ?? car.code}`;
1269
+ const overviewKey = JSON.stringify({
1270
+ id: car.id,
1271
+ mode,
1272
+ color: car.color,
1273
+ code: car.code,
1274
+ icon,
1275
+ driverNumber,
1276
+ imageSrc,
1277
+ overviewCode,
1278
+ fields: displayFields.map((field) => [field.label, field.value]),
1279
+ });
1280
+ if (overviewKey === this.lastOverviewRenderKey) return;
1281
+ this.lastOverviewRenderKey = overviewKey;
1027
1282
 
1028
1283
  this.readouts.carOverview?.style.setProperty('--driver-color', car.color);
1029
1284
  this.readouts.carOverviewDiagram?.style.setProperty('--driver-color', car.color);
@@ -1031,15 +1286,11 @@ export class F1SimulatorApp {
1031
1286
  this.readouts.carOverviewTitle.textContent = mode === 'driver' ? 'Driver overview' : 'Car overview';
1032
1287
  }
1033
1288
  if (this.readouts.carOverviewCode) {
1034
- this.readouts.carOverviewCode.textContent = mode === 'driver'
1035
- ? `${car.code} driver`
1036
- : `${driver?.vehicle?.name ?? car.vehicleName ?? car.code}`;
1289
+ this.readouts.carOverviewCode.textContent = overviewCode;
1037
1290
  }
1038
1291
  if (this.readouts.carOverviewIcon) this.readouts.carOverviewIcon.textContent = icon;
1039
1292
  if (this.readouts.carOverviewImage) {
1040
- this.readouts.carOverviewImage.src = mode === 'driver'
1041
- ? (driver?.driverImage ?? driver?.portrait ?? driver?.avatar ?? this.assets.driverHelmet)
1042
- : this.assets.carOverview;
1293
+ this.readouts.carOverviewImage.src = imageSrc;
1043
1294
  }
1044
1295
  if (this.readouts.carOverviewNumber) this.readouts.carOverviewNumber.textContent = driverNumber;
1045
1296
  if (this.readouts.carOverviewCoreStat) this.readouts.carOverviewCoreStat.textContent = mode === 'driver' ? 'Driver' : 'Car';
@@ -1222,7 +1473,7 @@ export class F1SimulatorApp {
1222
1473
  });
1223
1474
  }
1224
1475
 
1225
- restart(nextOptions = {}) {
1476
+ applyExpertOptions(nextOptions = {}) {
1226
1477
  this.options = {
1227
1478
  ...this.options,
1228
1479
  ...nextOptions,
@@ -1244,17 +1495,36 @@ export class F1SimulatorApp {
1244
1495
  },
1245
1496
  };
1246
1497
  this.assets = this.options.assets;
1247
- this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
1248
- this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
1249
- applyPaddockThemeCssVariables(this.root, this.options.theme);
1498
+ if (hasOwnOption(nextOptions, 'trackSeed')) {
1499
+ this.trackSeed = this.options.trackSeed ?? createMountTrackSeed();
1500
+ }
1250
1501
  if (nextOptions.drivers) {
1251
1502
  this.drivers = nextOptions.drivers;
1252
1503
  this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
1253
1504
  this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
1254
1505
  this.createCars();
1255
1506
  }
1507
+ }
1508
+
1509
+ restart(nextOptions = {}) {
1510
+ if (hasOwnOption(nextOptions, 'assets') && !assetSetsEqual(nextOptions.assets, this.options.assets)) {
1511
+ throw new Error('PaddockJS restart() does not support changing assets. Destroy and mount a new simulator with the new assets.');
1512
+ }
1513
+ if (hasOwnOption(nextOptions, 'expert') && expertOptionsChanged(nextOptions.expert, this.options.expert)) {
1514
+ throw new Error('PaddockJS restart() does not support changing expert mode. Destroy and mount a new simulator with the new expert options.');
1515
+ }
1516
+ this.applyExpertOptions(nextOptions);
1517
+ this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
1518
+ this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
1519
+ applyPaddockThemeCssVariables(this.root, this.options.theme);
1256
1520
  this.sim = this.createRaceSimulation();
1521
+ if (this.expertMode) {
1522
+ this.expert = createBrowserExpertAdapter(this, this.options.expert);
1523
+ }
1257
1524
  this.selectedId = this.drivers[0]?.id ?? null;
1525
+ this.lastTimingMarkup = null;
1526
+ this.lastOverviewRenderKey = null;
1527
+ this.lastFinishClassificationMarkup = null;
1258
1528
  this.resetRaceDataBannerState(performance.now());
1259
1529
  this.lastTimingRenderTime = 0;
1260
1530
  this.lastTimingRaceMode = null;
@@ -13,16 +13,26 @@ export function querySimulatorDom(root) {
13
13
  contacts: root.querySelector('[data-contact-readout]'),
14
14
  camera: root.querySelector('[data-camera-readout]'),
15
15
  fps: root.querySelector('[data-fps-readout]'),
16
- selectedCode: root.querySelector('[data-selected-code]'),
17
- selectedName: root.querySelector('[data-selected-name]'),
18
- speed: root.querySelector('[data-telemetry-speed]'),
19
- throttle: root.querySelector('[data-telemetry-throttle]'),
20
- brake: root.querySelector('[data-telemetry-brake]'),
21
- tyres: root.querySelector('[data-telemetry-tyres]'),
22
- selectedDrs: root.querySelector('[data-telemetry-drs]'),
23
- surface: root.querySelector('[data-telemetry-surface]'),
24
- gap: root.querySelector('[data-telemetry-gap]'),
25
- leaderGap: root.querySelector('[data-telemetry-leader-gap]'),
16
+ selectedCode: root.querySelectorAll('[data-selected-code]'),
17
+ selectedName: root.querySelectorAll('[data-selected-name]'),
18
+ speed: root.querySelectorAll('[data-telemetry-speed]'),
19
+ throttle: root.querySelectorAll('[data-telemetry-throttle]'),
20
+ brake: root.querySelectorAll('[data-telemetry-brake]'),
21
+ tyres: root.querySelectorAll('[data-telemetry-tyres]'),
22
+ selectedDrs: root.querySelectorAll('[data-telemetry-drs]'),
23
+ surface: root.querySelectorAll('[data-telemetry-surface]'),
24
+ gap: root.querySelectorAll('[data-telemetry-gap]'),
25
+ leaderGap: root.querySelectorAll('[data-telemetry-leader-gap]'),
26
+ currentSector: root.querySelectorAll('[data-telemetry-current-sector]'),
27
+ completedLaps: root.querySelectorAll('[data-telemetry-completed-laps]'),
28
+ currentLapTime: root.querySelectorAll('[data-telemetry-current-lap-time]'),
29
+ lastLapTime: root.querySelectorAll('[data-telemetry-last-lap-time]'),
30
+ bestLapTime: root.querySelectorAll('[data-telemetry-best-lap-time]'),
31
+ telemetrySectorBars: root.querySelectorAll('[data-telemetry-sector-bar]'),
32
+ telemetrySectorTimes: root.querySelectorAll('[data-telemetry-sector-time]'),
33
+ telemetrySectorLast: root.querySelectorAll('[data-telemetry-sector-last]'),
34
+ telemetrySectorBest: root.querySelectorAll('[data-telemetry-sector-best]'),
35
+ telemetrySectorBanners: root.querySelectorAll('[data-telemetry-sector-banner]'),
26
36
  carOverview: root.querySelector('[data-paddock-component="car-driver-overview"]'),
27
37
  carOverviewTitle: root.querySelector('[data-car-overview-title]'),
28
38
  carOverviewDiagram: root.querySelector('.car-overview-diagram'),
@@ -32,6 +42,9 @@ export function querySimulatorDom(root) {
32
42
  carOverviewNumber: root.querySelector('[data-car-overview-number]'),
33
43
  carOverviewCoreStat: root.querySelector('[data-car-overview-core-stat]'),
34
44
  carOverviewFields: root.querySelectorAll('[data-overview-field]'),
45
+ telemetryDrawerWorkbench: root.querySelector('[data-race-telemetry-drawer]'),
46
+ telemetryDrawer: root.querySelector('[data-telemetry-drawer]'),
47
+ telemetryDrawerToggle: root.querySelector('[data-telemetry-drawer-toggle]'),
35
48
  raceDataPanel: root.querySelector('[data-race-data-panel]'),
36
49
  raceDataKicker: root.querySelector('[data-race-data-kicker]'),
37
50
  raceDataTitle: root.querySelector('[data-race-data-title]'),
@@ -60,5 +73,16 @@ export function querySimulatorDom(root) {
60
73
  }
61
74
 
62
75
  export function setText(node, value) {
63
- if (node) node.textContent = value;
76
+ if (!node) return;
77
+ const nextValue = String(value ?? '');
78
+ if (node.textContent !== nextValue) node.textContent = nextValue;
79
+ }
80
+
81
+ export function setTextAll(nodes, value) {
82
+ if (!nodes) return;
83
+ if (typeof nodes.forEach === 'function') {
84
+ nodes.forEach((node) => setText(node, value));
85
+ return;
86
+ }
87
+ setText(nodes, value);
64
88
  }