@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
@@ -1,18 +1,11 @@
1
1
  import { createEnvironmentRuntime } from '../environment/runtime.js';
2
2
  import { resolveEnvironmentOptions } from '../environment/options.js';
3
- import { renderTrackSurface } from './rendering/trackRenderer.js';
3
+ import { createBrowserExpertExternalRendererBridge } from './BrowserExpertExternalRenderer.js';
4
4
 
5
5
  export function createBrowserExpertAdapter(app, expertOptions = {}) {
6
6
  let resolvedOptions = resolveBrowserExpertOptions(app.options, expertOptions);
7
7
  let frameRenderSuppressed = false;
8
- let externalRendererUnsubscribe = null;
9
- let externalRendererAttached = false;
10
- let externalRendererLastMeta = null;
11
- let externalRendererLastFrameAt = null;
12
- let externalRendererLastError = null;
13
- let externalRendererDriverIdMap = new Map();
14
- let externalRendererTrackFingerprint = null;
15
- let externalRendererAppliedTrackSurface = false;
8
+ const externalRenderer = createBrowserExpertExternalRendererBridge(app);
16
9
 
17
10
  function resolveBrowserExpertOptions(appOptions, nextExpertOptions = {}) {
18
11
  const options = resolveEnvironmentOptions({
@@ -53,215 +46,6 @@ export function createBrowserExpertAdapter(app, expertOptions = {}) {
53
46
 
54
47
  resolvedOptions = initialResolvedOptions();
55
48
 
56
- function localDriverRecords() {
57
- const source = Array.isArray(app.drivers) && app.drivers.length > 0
58
- ? app.drivers
59
- : app.options?.drivers;
60
- return Array.isArray(source) ? source : [];
61
- }
62
-
63
- function localDriverIds() {
64
- return localDriverRecords()
65
- .map((driver) => driver?.id)
66
- .filter((id) => typeof id === 'string' && id.length > 0);
67
- }
68
-
69
- function localDriverById() {
70
- return new Map(localDriverRecords().map((driver) => [driver.id, driver]));
71
- }
72
-
73
- function rewriteExternalFrame(frame) {
74
- if (!frame || typeof frame !== 'object') return frame;
75
- const snapshot = frame.snapshot;
76
- if (!snapshot || typeof snapshot !== 'object' || !Array.isArray(snapshot.cars)) return frame;
77
-
78
- const knownLocalIds = localDriverIds();
79
- if (knownLocalIds.length === 0) return frame;
80
- const knownLocalIdSet = new Set(knownLocalIds);
81
-
82
- const activeRawIds = new Set();
83
- const usedLocalIds = new Set();
84
-
85
- snapshot.cars.forEach((car, index) => {
86
- const rawId = typeof car?.id === 'string' && car.id.length > 0
87
- ? car.id
88
- : `external-${String(index)}`;
89
- activeRawIds.add(rawId);
90
- if (knownLocalIdSet.has(rawId) && !usedLocalIds.has(rawId)) {
91
- externalRendererDriverIdMap.set(rawId, rawId);
92
- usedLocalIds.add(rawId);
93
- return;
94
- }
95
- const mapped = externalRendererDriverIdMap.get(rawId);
96
- if (typeof mapped === 'string' && mapped.length > 0 && !usedLocalIds.has(mapped)) {
97
- usedLocalIds.add(mapped);
98
- return;
99
- }
100
- const nextLocalId = knownLocalIds.find((id) => !usedLocalIds.has(id));
101
- if (nextLocalId) {
102
- externalRendererDriverIdMap.set(rawId, nextLocalId);
103
- usedLocalIds.add(nextLocalId);
104
- return;
105
- }
106
- externalRendererDriverIdMap.set(rawId, rawId);
107
- usedLocalIds.add(rawId);
108
- });
109
-
110
- for (const rawId of Array.from(externalRendererDriverIdMap.keys())) {
111
- if (!activeRawIds.has(rawId)) externalRendererDriverIdMap.delete(rawId);
112
- }
113
-
114
- const mapId = (value) => {
115
- if (typeof value !== 'string' || value.length === 0) return value;
116
- return externalRendererDriverIdMap.get(value) ?? value;
117
- };
118
-
119
- const drivers = localDriverById();
120
- const mappedCars = snapshot.cars.map((car, index) => {
121
- const rawId = typeof car?.id === 'string' && car.id.length > 0
122
- ? car.id
123
- : `external-${String(index)}`;
124
- const mappedId = mapId(rawId);
125
- const driver = drivers.get(mappedId);
126
- return {
127
- ...car,
128
- id: mappedId,
129
- code: car?.code ?? driver?.code ?? mappedId,
130
- timingCode: car?.timingCode ?? driver?.timingCode ?? driver?.code ?? mappedId,
131
- name: car?.name ?? driver?.name ?? mappedId,
132
- color: car?.color ?? driver?.color ?? '#9ca3af',
133
- icon: car?.icon ?? driver?.icon ?? (driver?.code ?? mappedId),
134
- team: car?.team ?? driver?.team ?? null,
135
- };
136
- });
137
-
138
- const mappedEvents = Array.isArray(snapshot.events)
139
- ? snapshot.events.map((event) => ({
140
- ...event,
141
- carId: mapId(event?.carId),
142
- otherCarId: mapId(event?.otherCarId),
143
- winnerId: mapId(event?.winnerId),
144
- }))
145
- : snapshot.events;
146
-
147
- const mappedPenalties = Array.isArray(snapshot.penalties)
148
- ? snapshot.penalties.map((penalty) => ({
149
- ...penalty,
150
- driverId: mapId(penalty?.driverId),
151
- }))
152
- : snapshot.penalties;
153
-
154
- const raceControl = snapshot.raceControl && typeof snapshot.raceControl === 'object'
155
- ? {
156
- ...snapshot.raceControl,
157
- winner: snapshot.raceControl.winner && typeof snapshot.raceControl.winner === 'object'
158
- ? {
159
- ...snapshot.raceControl.winner,
160
- id: mapId(snapshot.raceControl.winner.id),
161
- }
162
- : snapshot.raceControl.winner,
163
- classification: Array.isArray(snapshot.raceControl.classification)
164
- ? snapshot.raceControl.classification.map((entry) => ({
165
- ...entry,
166
- id: mapId(entry?.id),
167
- driverId: mapId(entry?.driverId),
168
- }))
169
- : snapshot.raceControl.classification,
170
- }
171
- : snapshot.raceControl;
172
-
173
- let mappedObservation = frame.observation;
174
- if (frame.observation && typeof frame.observation === 'object' && !Array.isArray(frame.observation)) {
175
- mappedObservation = {};
176
- Object.entries(frame.observation).forEach(([driverId, value]) => {
177
- mappedObservation[mapId(driverId)] = value;
178
- });
179
- }
180
-
181
- return {
182
- ...frame,
183
- snapshot: {
184
- ...snapshot,
185
- cars: mappedCars,
186
- events: mappedEvents,
187
- penalties: mappedPenalties,
188
- raceControl,
189
- },
190
- observation: mappedObservation,
191
- meta: {
192
- ...(frame.meta && typeof frame.meta === 'object' ? frame.meta : {}),
193
- externalDriverMap: Object.fromEntries(externalRendererDriverIdMap.entries()),
194
- },
195
- };
196
- }
197
-
198
- function trackFingerprint(snapshot) {
199
- const track = snapshot?.track;
200
- if (!track || typeof track !== 'object') return null;
201
- const drs = Array.isArray(track.drsZones)
202
- ? track.drsZones.map((zone) => [zone?.id ?? null, zone?.start ?? null, zone?.end ?? null])
203
- : [];
204
- const firstSample = Array.isArray(track.samples) && track.samples.length > 0 ? track.samples[0] : null;
205
- const lastSample = Array.isArray(track.samples) && track.samples.length > 0
206
- ? track.samples[track.samples.length - 1]
207
- : null;
208
- return JSON.stringify({
209
- name: track.name ?? null,
210
- length: track.length ?? null,
211
- width: track.width ?? null,
212
- sampleCount: track.sampleCount ?? null,
213
- finish: track.finish
214
- ? {
215
- x: track.finish.x ?? null,
216
- y: track.finish.y ?? null,
217
- heading: track.finish.heading ?? null,
218
- }
219
- : null,
220
- firstSample: firstSample
221
- ? {
222
- x: firstSample.x ?? null,
223
- y: firstSample.y ?? null,
224
- heading: firstSample.heading ?? null,
225
- }
226
- : null,
227
- lastSample: lastSample
228
- ? {
229
- x: lastSample.x ?? null,
230
- y: lastSample.y ?? null,
231
- heading: lastSample.heading ?? null,
232
- }
233
- : null,
234
- drs,
235
- });
236
- }
237
-
238
- function syncExternalTrackSurface(snapshot) {
239
- if (!snapshot || typeof snapshot !== 'object') return;
240
- const nextFingerprint = trackFingerprint(snapshot);
241
- if (!nextFingerprint) return;
242
- if (nextFingerprint === externalRendererTrackFingerprint) return;
243
- if (
244
- !app.trackAsset ||
245
- !app.drsLayer ||
246
- !app.sensorLayer ||
247
- !app.pitLaneStatusLayer
248
- ) {
249
- externalRendererTrackFingerprint = nextFingerprint;
250
- return;
251
- }
252
- app.pitLaneStatusRenderer?.reset?.();
253
- app.cameraController?.invalidateTrackCaches?.();
254
- renderTrackSurface({
255
- drsLayer: app.drsLayer,
256
- sensorLayer: app.sensorLayer,
257
- pitLaneStatusLayer: app.pitLaneStatusLayer,
258
- trackAsset: app.trackAsset,
259
- snapshot,
260
- });
261
- externalRendererTrackFingerprint = nextFingerprint;
262
- externalRendererAppliedTrackSurface = true;
263
- }
264
-
265
49
  const runtime = createEnvironmentRuntime({
266
50
  getSimulation: () => app.sim,
267
51
  setSimulation(nextSim) {
@@ -287,104 +71,29 @@ export function createBrowserExpertAdapter(app, expertOptions = {}) {
287
71
  },
288
72
  });
289
73
 
290
- function assertExternalRendererDetached(method) {
291
- if (!externalRendererAttached) return;
292
- throw new Error(`Browser expert ${method}() is disabled while external renderer mode is attached.`);
293
- }
294
-
295
- function detachExternalRenderer() {
296
- if (typeof externalRendererUnsubscribe === 'function') {
297
- try {
298
- externalRendererUnsubscribe();
299
- } catch (error) {
300
- externalRendererLastError = error instanceof Error ? error.message : String(error);
301
- }
302
- }
303
- externalRendererUnsubscribe = null;
304
- externalRendererAttached = false;
305
- externalRendererDriverIdMap = new Map();
306
- if (externalRendererAppliedTrackSurface && typeof app.renderTrack === 'function') {
307
- try {
308
- app.renderTrack();
309
- } catch (error) {
310
- externalRendererLastError = error instanceof Error ? error.message : String(error);
311
- }
312
- }
313
- externalRendererTrackFingerprint = null;
314
- externalRendererAppliedTrackSurface = false;
315
- }
316
-
317
- function attachExternalRenderer(source) {
318
- if (!source || typeof source.subscribe !== 'function') {
319
- throw new Error('attachExternalRenderer(source) requires a source with subscribe(onFrame).');
320
- }
321
- detachExternalRenderer();
322
- externalRendererLastError = null;
323
- const unsubscribe = source.subscribe((frame) => {
324
- if (!frame || typeof frame !== 'object') {
325
- externalRendererLastError = 'Invalid external frame payload.';
326
- return;
327
- }
328
- const rewrittenFrame = rewriteExternalFrame(frame);
329
- const snapshot = rewrittenFrame.snapshot;
330
- if (!snapshot || typeof snapshot !== 'object') {
331
- externalRendererLastError = 'External frame is missing snapshot.';
332
- return;
333
- }
334
- try {
335
- syncExternalTrackSurface(snapshot);
336
- app.renderExpertFrame(snapshot, {
337
- forceDomUpdate: true,
338
- observation: rewrittenFrame.observation && typeof rewrittenFrame.observation === 'object'
339
- ? rewrittenFrame.observation
340
- : {},
341
- });
342
- externalRendererLastMeta = rewrittenFrame.meta ?? null;
343
- externalRendererLastFrameAt = Date.now();
344
- externalRendererLastError = null;
345
- } catch (error) {
346
- externalRendererLastError = error instanceof Error ? error.message : String(error);
347
- }
348
- });
349
- if (typeof unsubscribe !== 'function') {
350
- throw new Error('attachExternalRenderer(source) subscribe(onFrame) must return an unsubscribe function.');
351
- }
352
- externalRendererUnsubscribe = unsubscribe;
353
- externalRendererAttached = true;
354
- }
355
-
356
- function getExternalRendererState() {
357
- return {
358
- attached: externalRendererAttached,
359
- lastMeta: externalRendererLastMeta,
360
- lastFrameAt: externalRendererLastFrameAt,
361
- lastError: externalRendererLastError,
362
- };
363
- }
364
-
365
74
  return {
366
75
  ...runtime,
367
76
  reset(options = {}) {
368
- assertExternalRendererDetached('reset');
77
+ externalRenderer.assertDetached('reset');
369
78
  return runtime.reset(options);
370
79
  },
371
80
  step(actions = {}) {
372
- assertExternalRendererDetached('step');
81
+ externalRenderer.assertDetached('step');
373
82
  return runtime.step(actions);
374
83
  },
375
84
  resetDrivers(placements = {}, resultOptions = {}) {
376
- assertExternalRendererDetached('resetDrivers');
85
+ externalRenderer.assertDetached('resetDrivers');
377
86
  return runtime.resetDrivers(placements, resultOptions);
378
87
  },
379
88
  destroy() {
380
- detachExternalRenderer();
89
+ externalRenderer.detach();
381
90
  runtime.destroy();
382
91
  },
383
92
  setFrameRenderSuppressed(suppressed) {
384
93
  frameRenderSuppressed = Boolean(suppressed);
385
94
  },
386
- attachExternalRenderer,
387
- detachExternalRenderer,
388
- getExternalRendererState,
95
+ attachExternalRenderer: externalRenderer.attach,
96
+ detachExternalRenderer: externalRenderer.detach,
97
+ getExternalRendererState: externalRenderer.getState,
389
98
  };
390
99
  }
@@ -0,0 +1,303 @@
1
+ import { renderTrackSurface } from './rendering/trackRenderer.js';
2
+
3
+ export function createBrowserExpertExternalRendererBridge(app) {
4
+ let unsubscribeExternalRenderer = null;
5
+ let attached = false;
6
+ let lastMeta = null;
7
+ let lastFrameAt = null;
8
+ let lastError = null;
9
+ let driverIdMap = new Map();
10
+ let trackFingerprint = null;
11
+ let appliedTrackSurface = false;
12
+
13
+ function localDriverRecords() {
14
+ const source = Array.isArray(app.drivers) && app.drivers.length > 0
15
+ ? app.drivers
16
+ : app.options?.drivers;
17
+ return Array.isArray(source) ? source : [];
18
+ }
19
+
20
+ function localDriverIds() {
21
+ return localDriverRecords()
22
+ .map((driver) => driver?.id)
23
+ .filter((id) => typeof id === 'string' && id.length > 0);
24
+ }
25
+
26
+ function localDriverById() {
27
+ return new Map(localDriverRecords().map((driver) => [driver.id, driver]));
28
+ }
29
+
30
+ function rewriteFrame(frame) {
31
+ if (!frame || typeof frame !== 'object') return frame;
32
+ const snapshot = frame.snapshot;
33
+ if (!snapshot || typeof snapshot !== 'object' || !Array.isArray(snapshot.cars)) return frame;
34
+
35
+ const knownLocalIds = localDriverIds();
36
+ if (knownLocalIds.length === 0) return frame;
37
+ const knownLocalIdSet = new Set(knownLocalIds);
38
+
39
+ const activeRawIds = new Set();
40
+ const usedLocalIds = new Set();
41
+
42
+ snapshot.cars.forEach((car, index) => {
43
+ const rawId = typeof car?.id === 'string' && car.id.length > 0
44
+ ? car.id
45
+ : `external-${String(index)}`;
46
+ activeRawIds.add(rawId);
47
+ if (knownLocalIdSet.has(rawId) && !usedLocalIds.has(rawId)) {
48
+ driverIdMap.set(rawId, rawId);
49
+ usedLocalIds.add(rawId);
50
+ return;
51
+ }
52
+ const mapped = driverIdMap.get(rawId);
53
+ if (typeof mapped === 'string' && mapped.length > 0 && !usedLocalIds.has(mapped)) {
54
+ usedLocalIds.add(mapped);
55
+ return;
56
+ }
57
+ const nextLocalId = knownLocalIds.find((id) => !usedLocalIds.has(id));
58
+ if (nextLocalId) {
59
+ driverIdMap.set(rawId, nextLocalId);
60
+ usedLocalIds.add(nextLocalId);
61
+ return;
62
+ }
63
+ driverIdMap.set(rawId, rawId);
64
+ usedLocalIds.add(rawId);
65
+ });
66
+
67
+ for (const rawId of Array.from(driverIdMap.keys())) {
68
+ if (!activeRawIds.has(rawId)) driverIdMap.delete(rawId);
69
+ }
70
+
71
+ const mapId = (value) => {
72
+ if (typeof value !== 'string' || value.length === 0) return value;
73
+ return driverIdMap.get(value) ?? value;
74
+ };
75
+
76
+ const drivers = localDriverById();
77
+ const mappedCars = snapshot.cars.map((car, index) => {
78
+ const rawId = typeof car?.id === 'string' && car.id.length > 0
79
+ ? car.id
80
+ : `external-${String(index)}`;
81
+ const mappedId = mapId(rawId);
82
+ const driver = drivers.get(mappedId);
83
+ return {
84
+ ...car,
85
+ id: mappedId,
86
+ code: car?.code ?? driver?.code ?? mappedId,
87
+ timingCode: car?.timingCode ?? driver?.timingCode ?? driver?.code ?? mappedId,
88
+ name: car?.name ?? driver?.name ?? mappedId,
89
+ color: car?.color ?? driver?.color ?? '#9ca3af',
90
+ icon: car?.icon ?? driver?.icon ?? (driver?.code ?? mappedId),
91
+ team: car?.team ?? driver?.team ?? null,
92
+ };
93
+ });
94
+
95
+ const mappedEvents = Array.isArray(snapshot.events)
96
+ ? snapshot.events.map((event) => ({
97
+ ...event,
98
+ carId: mapId(event?.carId),
99
+ otherCarId: mapId(event?.otherCarId),
100
+ winnerId: mapId(event?.winnerId),
101
+ }))
102
+ : snapshot.events;
103
+
104
+ const mappedPenalties = Array.isArray(snapshot.penalties)
105
+ ? snapshot.penalties.map((penalty) => ({
106
+ ...penalty,
107
+ driverId: mapId(penalty?.driverId),
108
+ }))
109
+ : snapshot.penalties;
110
+
111
+ const raceControl = snapshot.raceControl && typeof snapshot.raceControl === 'object'
112
+ ? {
113
+ ...snapshot.raceControl,
114
+ winner: snapshot.raceControl.winner && typeof snapshot.raceControl.winner === 'object'
115
+ ? {
116
+ ...snapshot.raceControl.winner,
117
+ id: mapId(snapshot.raceControl.winner.id),
118
+ }
119
+ : snapshot.raceControl.winner,
120
+ classification: Array.isArray(snapshot.raceControl.classification)
121
+ ? snapshot.raceControl.classification.map((entry) => ({
122
+ ...entry,
123
+ id: mapId(entry?.id),
124
+ driverId: mapId(entry?.driverId),
125
+ }))
126
+ : snapshot.raceControl.classification,
127
+ }
128
+ : snapshot.raceControl;
129
+
130
+ let mappedObservation = frame.observation;
131
+ if (frame.observation && typeof frame.observation === 'object' && !Array.isArray(frame.observation)) {
132
+ mappedObservation = {};
133
+ Object.entries(frame.observation).forEach(([driverId, value]) => {
134
+ mappedObservation[mapId(driverId)] = value;
135
+ });
136
+ }
137
+
138
+ return {
139
+ ...frame,
140
+ snapshot: {
141
+ ...snapshot,
142
+ cars: mappedCars,
143
+ events: mappedEvents,
144
+ penalties: mappedPenalties,
145
+ raceControl,
146
+ },
147
+ observation: mappedObservation,
148
+ meta: {
149
+ ...(frame.meta && typeof frame.meta === 'object' ? frame.meta : {}),
150
+ externalDriverMap: Object.fromEntries(driverIdMap.entries()),
151
+ },
152
+ };
153
+ }
154
+
155
+ function fingerprintTrack(snapshot) {
156
+ const track = snapshot?.track;
157
+ if (!track || typeof track !== 'object') return null;
158
+ const drs = Array.isArray(track.drsZones)
159
+ ? track.drsZones.map((zone) => [zone?.id ?? null, zone?.start ?? null, zone?.end ?? null])
160
+ : [];
161
+ const firstSample = Array.isArray(track.samples) && track.samples.length > 0 ? track.samples[0] : null;
162
+ const lastSample = Array.isArray(track.samples) && track.samples.length > 0
163
+ ? track.samples[track.samples.length - 1]
164
+ : null;
165
+ return JSON.stringify({
166
+ name: track.name ?? null,
167
+ length: track.length ?? null,
168
+ width: track.width ?? null,
169
+ sampleCount: track.sampleCount ?? null,
170
+ finish: track.finish
171
+ ? {
172
+ x: track.finish.x ?? null,
173
+ y: track.finish.y ?? null,
174
+ heading: track.finish.heading ?? null,
175
+ }
176
+ : null,
177
+ firstSample: firstSample
178
+ ? {
179
+ x: firstSample.x ?? null,
180
+ y: firstSample.y ?? null,
181
+ heading: firstSample.heading ?? null,
182
+ }
183
+ : null,
184
+ lastSample: lastSample
185
+ ? {
186
+ x: lastSample.x ?? null,
187
+ y: lastSample.y ?? null,
188
+ heading: lastSample.heading ?? null,
189
+ }
190
+ : null,
191
+ drs,
192
+ });
193
+ }
194
+
195
+ function syncTrackSurface(snapshot) {
196
+ if (!snapshot || typeof snapshot !== 'object') return;
197
+ const nextFingerprint = fingerprintTrack(snapshot);
198
+ if (!nextFingerprint) return;
199
+ if (nextFingerprint === trackFingerprint) return;
200
+ if (
201
+ !app.trackAsset ||
202
+ !app.drsLayer ||
203
+ !app.sensorLayer ||
204
+ !app.pitLaneStatusLayer
205
+ ) {
206
+ trackFingerprint = nextFingerprint;
207
+ return;
208
+ }
209
+ app.pitLaneStatusRenderer?.reset?.();
210
+ app.cameraController?.invalidateTrackCaches?.();
211
+ renderTrackSurface({
212
+ drsLayer: app.drsLayer,
213
+ sensorLayer: app.sensorLayer,
214
+ pitLaneStatusLayer: app.pitLaneStatusLayer,
215
+ trackAsset: app.trackAsset,
216
+ snapshot,
217
+ });
218
+ trackFingerprint = nextFingerprint;
219
+ appliedTrackSurface = true;
220
+ }
221
+
222
+ function assertDetached(method) {
223
+ if (!attached) return;
224
+ throw new Error(`Browser expert ${method}() is disabled while external renderer mode is attached.`);
225
+ }
226
+
227
+ function detach() {
228
+ if (typeof unsubscribeExternalRenderer === 'function') {
229
+ try {
230
+ unsubscribeExternalRenderer();
231
+ } catch (error) {
232
+ lastError = error instanceof Error ? error.message : String(error);
233
+ }
234
+ }
235
+ unsubscribeExternalRenderer = null;
236
+ attached = false;
237
+ driverIdMap = new Map();
238
+ if (appliedTrackSurface && typeof app.renderTrack === 'function') {
239
+ try {
240
+ app.renderTrack();
241
+ } catch (error) {
242
+ lastError = error instanceof Error ? error.message : String(error);
243
+ }
244
+ }
245
+ trackFingerprint = null;
246
+ appliedTrackSurface = false;
247
+ }
248
+
249
+ function attach(source) {
250
+ if (!source || typeof source.subscribe !== 'function') {
251
+ throw new Error('attachExternalRenderer(source) requires a source with subscribe(onFrame).');
252
+ }
253
+ detach();
254
+ lastError = null;
255
+ const unsubscribe = source.subscribe((frame) => {
256
+ if (!frame || typeof frame !== 'object') {
257
+ lastError = 'Invalid external frame payload.';
258
+ return;
259
+ }
260
+ const rewrittenFrame = rewriteFrame(frame);
261
+ const snapshot = rewrittenFrame.snapshot;
262
+ if (!snapshot || typeof snapshot !== 'object') {
263
+ lastError = 'External frame is missing snapshot.';
264
+ return;
265
+ }
266
+ try {
267
+ syncTrackSurface(snapshot);
268
+ app.renderExpertFrame(snapshot, {
269
+ forceDomUpdate: true,
270
+ observation: rewrittenFrame.observation && typeof rewrittenFrame.observation === 'object'
271
+ ? rewrittenFrame.observation
272
+ : {},
273
+ });
274
+ lastMeta = rewrittenFrame.meta ?? null;
275
+ lastFrameAt = Date.now();
276
+ lastError = null;
277
+ } catch (error) {
278
+ lastError = error instanceof Error ? error.message : String(error);
279
+ }
280
+ });
281
+ if (typeof unsubscribe !== 'function') {
282
+ throw new Error('attachExternalRenderer(source) subscribe(onFrame) must return an unsubscribe function.');
283
+ }
284
+ unsubscribeExternalRenderer = unsubscribe;
285
+ attached = true;
286
+ }
287
+
288
+ function getState() {
289
+ return {
290
+ attached,
291
+ lastMeta,
292
+ lastFrameAt,
293
+ lastError,
294
+ };
295
+ }
296
+
297
+ return {
298
+ assertDetached,
299
+ attach,
300
+ detach,
301
+ getState,
302
+ };
303
+ }