@inventure71/paddockjs 3.0.0 → 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 (90) hide show
  1. package/CHANGELOG.md +146 -1
  2. package/INSTALL_AND_UPDATE.md +15 -4
  3. package/README.md +84 -562
  4. package/docs/architecture.md +28 -18
  5. package/docs/composable-layouts.md +77 -0
  6. package/docs/custom_model_controller.md +4 -4
  7. package/docs/data-helpers.md +50 -0
  8. package/docs/data_contract.md +9 -7
  9. package/docs/getting-started.md +75 -0
  10. package/docs/headless-environment.md +68 -0
  11. package/docs/index.md +17 -4
  12. package/docs/rules.md +6 -2
  13. package/docs/system_specs.md +49 -6
  14. package/docs/theming.md +64 -0
  15. package/docs/training.md +20 -14
  16. package/docs/troubleshooting.md +76 -0
  17. package/docs/upgrading-hosts-to-3.md +42 -0
  18. package/examples/python/README.md +6 -0
  19. package/examples/python/base_policy_server.py +19 -0
  20. package/package.json +10 -3
  21. package/src/api/PaddockSimulatorController.js +78 -22
  22. package/src/api/runtimeTheme.js +59 -0
  23. package/src/app/F1SimulatorApp.js +24 -3
  24. package/src/app/readouts/timingTowerRenderer.js +203 -42
  25. package/src/app/runtime/frameLoop.js +5 -1
  26. package/src/config/defaultOptions.js +8 -1
  27. package/src/config/themeOptions.js +5 -0
  28. package/src/data/index.d.ts +212 -0
  29. package/src/data/index.js +14 -0
  30. package/src/environment/episode.js +11 -1
  31. package/src/environment/metrics.js +59 -16
  32. package/src/environment/observationVector.js +50 -33
  33. package/src/environment/observations.js +89 -20
  34. package/src/environment/runtime.js +41 -0
  35. package/src/environment/sensors/carRays.js +48 -37
  36. package/src/environment/sensors/index.js +501 -97
  37. package/src/environment/sensors/indexedRayBands.js +134 -46
  38. package/src/environment/sensors/rayBandTrace.js +1171 -0
  39. package/src/environment/sensors/rayChannels.js +108 -0
  40. package/src/environment/sensors/rayConfig.js +71 -25
  41. package/src/environment/sensors/rayGeometry.js +23 -17
  42. package/src/environment/sensors/sensorTargets.js +67 -1
  43. package/src/environment/sensors/surfaceRays.js +130 -89
  44. package/src/environment/sensors/trackRays.js +115 -1
  45. package/src/index.d.ts +43 -0
  46. package/src/index.js +27 -2
  47. package/src/simulation/collisionGeometry.js +260 -96
  48. package/src/simulation/pit/pitFlow.js +21 -9
  49. package/src/simulation/pit/pitQueue.js +5 -3
  50. package/src/simulation/pit/pitRouteMovement.js +9 -4
  51. package/src/simulation/pit/pitRouting.js +77 -42
  52. package/src/simulation/pit/pitState.js +1 -0
  53. package/src/simulation/pit/pitTireService.js +7 -3
  54. package/src/simulation/race/raceFinish.js +25 -8
  55. package/src/simulation/race/raceOrder.js +189 -32
  56. package/src/simulation/race/raceProgress.js +129 -37
  57. package/src/simulation/race/raceSetup.js +2 -0
  58. package/src/simulation/race/raceStep.js +25 -13
  59. package/src/simulation/raceSimulation.js +20 -17
  60. package/src/simulation/rules/collisionSteward.js +40 -23
  61. package/src/simulation/rules/rulesReview.js +77 -56
  62. package/src/simulation/snapshots/raceSnapshots.js +37 -0
  63. package/src/simulation/timing/drsTiming.js +143 -30
  64. package/src/simulation/timing/gapEstimation.js +25 -3
  65. package/src/simulation/timing/lapTelemetry.js +115 -36
  66. package/src/simulation/timing/raceTiming.js +5 -1
  67. package/src/simulation/timing/sectorPerformance.js +104 -26
  68. package/src/simulation/timing/timingHistory.js +229 -14
  69. package/src/simulation/timing/timingLines.js +65 -7
  70. package/src/simulation/track/pitLaneLayout.js +23 -0
  71. package/src/simulation/track/pitLaneState.js +218 -19
  72. package/src/simulation/track/spatialQueries.js +67 -11
  73. package/src/simulation/track/trackMath.js +91 -24
  74. package/src/simulation/track/trackModel.js +2 -0
  75. package/src/simulation/track/trackQueryIndex.js +1334 -100
  76. package/src/simulation/track/trackQueryPitIndex.js +138 -14
  77. package/src/simulation/track/trackQueryScratch.js +7 -0
  78. package/src/simulation/track/trackQueryStats.js +25 -0
  79. package/src/simulation/track/trackSnapshotJson.js +139 -0
  80. package/src/simulation/track/trackStatePolicy.js +249 -1
  81. package/src/simulation/vehicle/contactResolution.js +96 -59
  82. package/src/simulation/vehicle/mainTrackWheelSurface.js +73 -34
  83. package/src/simulation/vehicle/pitWheelSurface.js +81 -28
  84. package/src/simulation/vehicle/runoffResponse.js +54 -29
  85. package/src/simulation/vehicle/vehicleGeometry.js +245 -51
  86. package/src/simulation/vehicle/vehiclePhysics.js +42 -27
  87. package/src/simulation/vehicle/vehicleSnapshots.js +23 -0
  88. package/src/simulation/vehicle/vehicleState.js +6 -0
  89. package/src/simulation/vehicle/wheelSurface.js +807 -92
  90. package/src/styles.css +497 -497
@@ -14,6 +14,8 @@ It is not a trainer and not a model/checkpoint format owner. It only shows trans
14
14
  - Preview latest-frame HTTP endpoint (for polling visualizers)
15
15
  - `http://127.0.0.1:8787/preview/frame`
16
16
 
17
+ Policy HTTP uses protocol version `2`. `POST /policy/reset` receives static metadata (`actionSpec`, `observationSpec`, configuration) once per session. `POST /policy/decide-batch` is compact and receives `driverIds`, `vectors`, `previousActions`, `metrics`, and `events`; it does not receive rich observation objects, schemas, snapshots, or track metadata every decision.
18
+
17
19
  ## Run
18
20
 
19
21
  ```bash
@@ -55,6 +57,10 @@ Subclass `BasePolicyServer` and override:
55
57
 
56
58
  Use `broadcast_preview_frame(snapshot, observation, meta)` to push authoritative frames to the browser renderer.
57
59
 
60
+ Inside `decide_batch(ctx)`, compact per-driver fields are aligned arrays:
61
+ `ctx["vectors"][index]` belongs to `ctx["driverIds"][index]`. The base example's
62
+ `compact_values_by_driver(ctx, "vectors")` helper maps them back to driver ids.
63
+
58
64
  ## Important Boundary
59
65
 
60
66
  - This server does not step the simulator by itself.
@@ -10,6 +10,9 @@ This file is intentionally minimal and transport-focused:
10
10
  - /preview (broadcasts {snapshot, observation, meta})
11
11
 
12
12
  Extend `BasePolicyServer` and override hook methods to plug your own model/runtime.
13
+ Policy HTTP uses protocolVersion 2. Reset receives static specs/configuration;
14
+ decide-batch receives compact per-driver vectors instead of rich observations.
15
+ Compact per-driver fields are arrays aligned with driverIds.
13
16
  """
14
17
 
15
18
  from __future__ import annotations
@@ -33,6 +36,20 @@ def _zero_action() -> Dict[str, float]:
33
36
  }
34
37
 
35
38
 
39
+ def compact_values_by_driver(context: Mapping[str, Any], field: str) -> Dict[str, Any]:
40
+ """Map a compact aligned field such as vectors/metrics back to driver ids."""
41
+ driver_ids = list(context.get("driverIds") or [])
42
+ values = context.get(field) or []
43
+ if isinstance(values, Mapping):
44
+ return {driver_id: values.get(driver_id) for driver_id in driver_ids}
45
+ if not isinstance(values, list):
46
+ return {driver_id: None for driver_id in driver_ids}
47
+ return {
48
+ driver_id: values[index] if index < len(values) else None
49
+ for index, driver_id in enumerate(driver_ids)
50
+ }
51
+
52
+
36
53
  @dataclass
37
54
  class ServerState:
38
55
  session_id: int = 0
@@ -70,6 +87,8 @@ class BasePolicyServer:
70
87
  async def decide_batch(self, context: Mapping[str, Any]) -> Dict[str, Dict[str, float]]:
71
88
  """Return per-driver controls for one batched policy step."""
72
89
  driver_ids = list(context.get("driverIds") or [])
90
+ vectors_by_driver = compact_values_by_driver(context, "vectors")
91
+ _ = vectors_by_driver
73
92
  return {driver_id: _zero_action() for driver_id in driver_ids}
74
93
 
75
94
  async def publish_preview_frame(self, frame: Mapping[str, Any]) -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inventure71/paddockjs",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "type": "module",
5
5
  "description": "A data-driven browser F1 simulator component for host websites.",
6
6
  "license": "Apache-2.0",
@@ -45,6 +45,11 @@
45
45
  "import": "./src/environment/index.js",
46
46
  "default": "./src/environment/index.js"
47
47
  },
48
+ "./data": {
49
+ "types": "./src/data/index.d.ts",
50
+ "import": "./src/data/index.js",
51
+ "default": "./src/data/index.js"
52
+ },
48
53
  "./styles.css": "./src/styles.css"
49
54
  },
50
55
  "files": [
@@ -75,13 +80,15 @@
75
80
  "test": "node scripts/run-vitest.mjs --slow",
76
81
  "test:fast": "node scripts/run-vitest.mjs",
77
82
  "types:check": "tsc -p tsconfig.types.json --noEmit",
78
- "check": "npm run test:fast && npm run types:check && npm run pack:dry && npm run consumer:smoke && npm run showcase:ci && npm run browser:smoke:quick -- --skip-build",
79
- "check:release": "npm test && npm run types:check && npm run pack:dry && npm run consumer:smoke && npm run showcase:ci && npm run browser:smoke:full -- --skip-build",
83
+ "docs:check": "node scripts/docs-check.mjs",
84
+ "check": "npm run docs:check && npm run test:fast && npm run types:check && npm run pack:dry && npm run consumer:smoke && npm run showcase:ci && npm run browser:smoke:quick -- --skip-build",
85
+ "check:release": "npm run docs:check && npm test && npm run types:check && npm run pack:dry && npm run consumer:smoke && npm run showcase:ci && npm run browser:smoke:full -- --skip-build",
80
86
  "pack:dry": "npm pack --dry-run",
81
87
  "consumer:smoke": "node scripts/consumer-package-smoke.mjs",
82
88
  "browser:smoke": "node smoke/browser-smoke.mjs",
83
89
  "browser:smoke:quick": "node smoke/browser-smoke.mjs --quick",
84
90
  "browser:smoke:full": "node smoke/browser-smoke.mjs --full",
91
+ "benchmark:runtime": "node scripts/benchmark-runtime-efficiency.mjs",
85
92
  "showcase:install": "npm --prefix local-preview ci",
86
93
  "showcase:dev": "npm --prefix local-preview run dev",
87
94
  "showcase:build": "npm --prefix local-preview run build",
@@ -22,6 +22,7 @@ import { applyPaddockThemeCssVariables, resolveF1SimulatorOptions } from '../con
22
22
  import { formatCssUrl } from '../config/cssValues.js';
23
23
  import { mergeRestartOptions } from '../config/restartOptions.js';
24
24
  import { getNextTimingGapMode, normalizeTimingGapMode } from '../config/timingGapMode.js';
25
+ import { createThemeSync, resolveRuntimeThemeModeOptions, resolveRuntimeThemeOptions } from './runtimeTheme.js';
25
26
 
26
27
  function assertMountTarget(root, label) {
27
28
  if (!root || typeof root !== 'object' || !('innerHTML' in root)) {
@@ -29,10 +30,10 @@ function assertMountTarget(root, label) {
29
30
  }
30
31
  }
31
32
 
32
- function setPackageCssVariables(root, assets, theme) {
33
+ function setPackageCssVariables(root, assets, theme, context = {}) {
33
34
  root.classList?.add?.('f1-sim-component');
34
35
  root.style?.setProperty?.('--broadcast-panel-surface', formatCssUrl(assets.broadcastPanel));
35
- applyPaddockThemeCssVariables(root, theme);
36
+ applyPaddockThemeCssVariables(root, theme, context);
36
37
  }
37
38
 
38
39
  function createCompositeRoot(getRoots, getOptions) {
@@ -41,6 +42,9 @@ function createCompositeRoot(getRoots, getOptions) {
41
42
  setProperty(name, value) {
42
43
  getRoots().forEach((root) => root.style?.setProperty?.(name, value));
43
44
  },
45
+ removeProperty(name) {
46
+ getRoots().forEach((root) => root.style?.removeProperty?.(name));
47
+ },
44
48
  },
45
49
  querySelector(selector) {
46
50
  for (const root of getRoots()) {
@@ -58,9 +62,9 @@ function createCompositeRoot(getRoots, getOptions) {
58
62
  removeAttribute(name) {
59
63
  getRoots().forEach((root) => root.removeAttribute?.(name));
60
64
  },
61
- applyCssVariables() {
65
+ applyCssVariables(context = {}) {
62
66
  const options = getOptions();
63
- getRoots().forEach((root) => setPackageCssVariables(root, options.assets, options.theme));
67
+ getRoots().forEach((root) => setPackageCssVariables(root, options.assets, options.theme, context));
64
68
  },
65
69
  };
66
70
  }
@@ -69,38 +73,41 @@ export class PaddockSimulatorController {
69
73
  constructor(options = {}) {
70
74
  this.options = resolveF1SimulatorOptions(options);
71
75
  this.roots = new Map();
76
+ this.mountRenderers = new Map();
72
77
  this.layoutSupportCleanups = new Map();
73
78
  this.app = null;
74
79
  this.compositeRoot = createCompositeRoot(() => [...this.roots.values()], () => this.options);
75
80
  }
76
81
 
77
- mountComponent(root, key, markup) {
82
+ mountComponent(root, key, createMarkup) {
78
83
  assertMountTarget(root, `mount ${key}`);
79
84
  if (this.app) {
80
85
  throw new Error('Mount PaddockJS components before calling start().');
81
86
  }
82
- root.innerHTML = markup;
87
+ const render = typeof createMarkup === 'function' ? createMarkup : () => createMarkup;
88
+ root.innerHTML = render();
83
89
  setPackageCssVariables(root, this.options.assets, this.options.theme);
84
90
  this.layoutSupportCleanups.get(key)?.();
85
91
  this.layoutSupportCleanups.set(key, installLayoutSupport(root));
86
92
  this.roots.set(key, root);
93
+ this.mountRenderers.set(key, render);
87
94
  return root;
88
95
  }
89
96
 
90
97
  mountRaceControls(root) {
91
- return this.mountComponent(root, 'race-controls', createRaceControlsMarkup(this.options));
98
+ return this.mountComponent(root, 'race-controls', () => createRaceControlsMarkup(this.options));
92
99
  }
93
100
 
94
101
  mountCameraControls(root) {
95
- return this.mountComponent(root, 'camera-controls', createCameraControlsMarkup(this.options));
102
+ return this.mountComponent(root, 'camera-controls', () => createCameraControlsMarkup(this.options));
96
103
  }
97
104
 
98
105
  mountSafetyCarControl(root) {
99
- return this.mountComponent(root, 'safety-car-control', createSafetyCarControlMarkup(this.options));
106
+ return this.mountComponent(root, 'safety-car-control', () => createSafetyCarControlMarkup(this.options));
100
107
  }
101
108
 
102
109
  mountTimingTower(root) {
103
- return this.mountComponent(root, 'timing-tower', createTimingTowerMarkup(this.options));
110
+ return this.mountComponent(root, 'timing-tower', () => createTimingTowerMarkup(this.options));
104
111
  }
105
112
 
106
113
  mountRaceCanvas(root, {
@@ -110,7 +117,7 @@ export class PaddockSimulatorController {
110
117
  timingTowerVerticalFit,
111
118
  responsiveNarrowLayout,
112
119
  } = {}) {
113
- return this.mountComponent(root, 'race-canvas', createRaceCanvasMarkup({
120
+ return this.mountComponent(root, 'race-canvas', () => createRaceCanvasMarkup({
114
121
  ...this.options,
115
122
  includeRaceDataPanel,
116
123
  includeTimingTower,
@@ -121,39 +128,39 @@ export class PaddockSimulatorController {
121
128
  }
122
129
 
123
130
  mountTelemetryPanel(root, { includeOverview } = {}) {
124
- return this.mountComponent(root, 'telemetry-stack', createTelemetryPanelMarkup(this.options, { includeOverview }));
131
+ return this.mountComponent(root, 'telemetry-stack', () => createTelemetryPanelMarkup(this.options, { includeOverview }));
125
132
  }
126
133
 
127
134
  mountTelemetryCore(root) {
128
- return this.mountComponent(root, 'telemetry-core', createTelemetryCoreMarkup(this.options));
135
+ return this.mountComponent(root, 'telemetry-core', () => createTelemetryCoreMarkup(this.options));
129
136
  }
130
137
 
131
138
  mountTelemetrySectors(root) {
132
- return this.mountComponent(root, 'telemetry-sectors', createTelemetrySectorsMarkup(this.options));
139
+ return this.mountComponent(root, 'telemetry-sectors', () => createTelemetrySectorsMarkup(this.options));
133
140
  }
134
141
 
135
142
  mountTelemetryLapTimes(root) {
136
- return this.mountComponent(root, 'telemetry-lap-times', createTelemetryLapTimesMarkup(this.options));
143
+ return this.mountComponent(root, 'telemetry-lap-times', () => createTelemetryLapTimesMarkup(this.options));
137
144
  }
138
145
 
139
146
  mountTelemetrySectorTimes(root) {
140
- return this.mountComponent(root, 'telemetry-sector-times', createTelemetrySectorTimesMarkup(this.options));
147
+ return this.mountComponent(root, 'telemetry-sector-times', () => createTelemetrySectorTimesMarkup(this.options));
141
148
  }
142
149
 
143
150
  mountTelemetrySectorBanner(root) {
144
- return this.mountComponent(root, 'telemetry-sector-banner', createTelemetrySectorBannerMarkup(this.options));
151
+ return this.mountComponent(root, 'telemetry-sector-banner', () => createTelemetrySectorBannerMarkup(this.options));
145
152
  }
146
153
 
147
154
  mountRaceTelemetryDrawer(root, options = {}) {
148
- return this.mountComponent(root, 'race-telemetry-drawer', createRaceTelemetryDrawerMarkup(this.options, options));
155
+ return this.mountComponent(root, 'race-telemetry-drawer', () => createRaceTelemetryDrawerMarkup(this.options, options));
149
156
  }
150
157
 
151
158
  mountCarDriverOverview(root) {
152
- return this.mountComponent(root, 'car-driver-overview', createCarDriverOverviewMarkup(this.options));
159
+ return this.mountComponent(root, 'car-driver-overview', () => createCarDriverOverviewMarkup(this.options));
153
160
  }
154
161
 
155
162
  mountRaceDataPanel(root) {
156
- return this.mountComponent(root, 'race-data-panel', createRaceDataPanelMarkup({
163
+ return this.mountComponent(root, 'race-data-panel', () => createRaceDataPanelMarkup({
157
164
  ...this.options,
158
165
  standalone: true,
159
166
  }));
@@ -188,6 +195,17 @@ export class PaddockSimulatorController {
188
195
  root.innerHTML = '';
189
196
  });
190
197
  this.roots.clear();
198
+ this.mountRenderers.clear();
199
+ }
200
+
201
+ rerenderMountedComponents() {
202
+ this.roots.forEach((root, key) => {
203
+ const render = this.mountRenderers.get(key);
204
+ if (render) root.innerHTML = render();
205
+ setPackageCssVariables(root, this.options.assets, this.options.theme);
206
+ this.layoutSupportCleanups.get(key)?.();
207
+ this.layoutSupportCleanups.set(key, installLayoutSupport(root));
208
+ });
191
209
  }
192
210
 
193
211
  syncOptionsFromRunningApp() {
@@ -207,12 +225,50 @@ export class PaddockSimulatorController {
207
225
  const currentOptions = this.app ? this.syncOptionsFromRunningApp() : this.options;
208
226
  const nextResolvedOptions = resolveF1SimulatorOptions(mergeRestartOptions(currentOptions, nextOptions));
209
227
  if (this.app) {
210
- this.app.restart(nextResolvedOptions);
228
+ const previousOptions = this.options;
211
229
  this.options = nextResolvedOptions;
230
+ try {
231
+ this.app.restart(nextResolvedOptions);
232
+ } catch (error) {
233
+ this.options = previousOptions;
234
+ throw error;
235
+ }
212
236
  return;
213
237
  }
214
238
  this.options = nextResolvedOptions;
215
- this.compositeRoot.applyCssVariables();
239
+ this.rerenderMountedComponents();
240
+ }
241
+
242
+ setTheme(themeInput = {}) {
243
+ const currentOptions = this.app ? this.syncOptionsFromRunningApp() : this.options;
244
+ const nextResolvedOptions = resolveRuntimeThemeOptions(currentOptions, themeInput);
245
+ this.options = nextResolvedOptions;
246
+ if (this.app?.setTheme) {
247
+ this.app.setTheme(nextResolvedOptions.theme);
248
+ } else {
249
+ this.compositeRoot.applyCssVariables();
250
+ }
251
+ return nextResolvedOptions.theme;
252
+ }
253
+
254
+ setThemeMode(mode) {
255
+ const currentOptions = this.app ? this.syncOptionsFromRunningApp() : this.options;
256
+ const nextResolvedOptions = resolveRuntimeThemeModeOptions(currentOptions, mode);
257
+ this.options = nextResolvedOptions;
258
+ if (this.app?.setTheme) {
259
+ this.app.setTheme(nextResolvedOptions.theme);
260
+ } else {
261
+ this.compositeRoot.applyCssVariables();
262
+ }
263
+ return nextResolvedOptions.theme;
264
+ }
265
+
266
+ getTheme() {
267
+ return this.options.theme;
268
+ }
269
+
270
+ syncThemeFrom(source, options = {}) {
271
+ return createThemeSync(this, source, options);
216
272
  }
217
273
 
218
274
  selectDriver(driverId) {
@@ -0,0 +1,59 @@
1
+ import { resolveF1SimulatorOptions } from '../config/defaultOptions.js';
2
+ import { mergeThemeInputs } from '../config/themeOptions.js';
3
+
4
+ export function resolveRuntimeThemeOptions(currentOptions, themeInput = {}) {
5
+ return resolveF1SimulatorOptions({
6
+ ...currentOptions,
7
+ theme: mergeThemeInputs(currentOptions?.theme, themeInput),
8
+ });
9
+ }
10
+
11
+ export function resolveRuntimeThemeModeOptions(currentOptions, mode) {
12
+ return resolveRuntimeThemeOptions(currentOptions, { mode });
13
+ }
14
+
15
+ export function createThemeSync(simulator, source, {
16
+ attribute = 'data-theme',
17
+ map,
18
+ } = {}) {
19
+ if (!source || typeof source !== 'object') {
20
+ throw new Error('syncThemeFrom requires a DOM element or attribute source.');
21
+ }
22
+
23
+ const readValue = () => {
24
+ const rawValue = typeof source.getAttribute === 'function'
25
+ ? source.getAttribute(attribute)
26
+ : source?.dataset?.[attributeToDatasetKey(attribute)];
27
+ if (map && Object.hasOwn(map, rawValue)) return map[rawValue];
28
+ return rawValue;
29
+ };
30
+
31
+ const sync = () => {
32
+ const value = readValue();
33
+ if (value && typeof value === 'object') {
34
+ simulator.setTheme(value);
35
+ return;
36
+ }
37
+ simulator.setThemeMode(typeof value === 'string' && value !== '' ? value : 'dark');
38
+ };
39
+
40
+ sync();
41
+
42
+ const Observer = globalThis.MutationObserver;
43
+ if (typeof Observer !== 'function' || typeof source.getAttribute !== 'function') {
44
+ return () => {};
45
+ }
46
+
47
+ const observer = new Observer(sync);
48
+ observer.observe(source, {
49
+ attributes: true,
50
+ attributeFilter: [attribute],
51
+ });
52
+ return () => observer.disconnect();
53
+ }
54
+
55
+ function attributeToDatasetKey(attribute) {
56
+ return String(attribute)
57
+ .replace(/^data-/, '')
58
+ .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
59
+ }
@@ -301,7 +301,8 @@ export class F1SimulatorApp {
301
301
  this.emitHostCallback('onReady', { snapshot });
302
302
  this.resizeHandler = () => {
303
303
  this.syncTimingPanelDisclosureState();
304
- this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
304
+ this.renderSourceSnapshotBuffer ??= {};
305
+ this.applyCamera(this.sim.snapshotRenderInto?.(this.renderSourceSnapshotBuffer) ?? this.sim.snapshotRender?.() ?? this.sim.snapshot());
305
306
  };
306
307
  window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
307
308
  this.observeLayoutResize();
@@ -606,7 +607,8 @@ export class F1SimulatorApp {
606
607
 
607
608
  syncRendererToCurrentLayout({ render = false } = {}) {
608
609
  this.resizeRendererToCanvasHost();
609
- const snapshot = this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
610
+ this.renderSourceSnapshotBuffer ??= {};
611
+ const snapshot = this.sim?.snapshotRenderInto?.(this.renderSourceSnapshotBuffer) ?? this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
610
612
  if (!snapshot) return;
611
613
  const renderSnapshot = interpolateRenderSnapshotInto(
612
614
  this.renderSnapshotBuffer,
@@ -1191,7 +1193,26 @@ export class F1SimulatorApp {
1191
1193
  const contextKey = selectedTeamId ?? '';
1192
1194
  if (this.lastThemeContextKey === contextKey) return;
1193
1195
  this.lastThemeContextKey = contextKey;
1194
- applyPaddockThemeCssVariables(this.root, this.options.theme, { selectedTeamId });
1196
+ const context = { selectedTeamId };
1197
+ if (typeof this.root?.applyCssVariables === 'function') {
1198
+ this.root.applyCssVariables(context);
1199
+ return;
1200
+ }
1201
+ applyPaddockThemeCssVariables(this.root, this.options.theme, context);
1202
+ }
1203
+
1204
+ setTheme(theme) {
1205
+ this.options = {
1206
+ ...this.options,
1207
+ theme,
1208
+ };
1209
+ this.lastThemeContextKey = null;
1210
+ this.syncThemeContext(this.getSnapshot());
1211
+ return this.options.theme;
1212
+ }
1213
+
1214
+ getTheme() {
1215
+ return this.options.theme;
1195
1216
  }
1196
1217
 
1197
1218
  getSelectedTeamId(snapshot = null) {
@@ -125,6 +125,195 @@ export function syncTimingGapModeControls({ readouts, buttons, timingGapMode })
125
125
  });
126
126
  }
127
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);
314
+ });
315
+ }
316
+
128
317
  export function renderTimingTower({
129
318
  timingList,
130
319
  cars,
@@ -138,48 +327,20 @@ export function renderTimingTower({
138
327
  }) {
139
328
  if (!timingList) return lastTimingMarkup;
140
329
  const penaltyByDriver = timingPenaltyBadgesEnabled ? getPenaltyByDriver(penalties) : new Map();
141
- const timingMarkup = cars.map((car) => {
142
- const driver = driverById.get(car.id);
143
- const dnf = isDnfCar(car);
144
- let gap = 'Leader';
145
- if (dnf) {
146
- gap = 'DNF';
147
- } else if (isWavedFlagCar(car) && raceMode !== 'finished') {
148
- gap = 'WAVED';
149
- } else if (raceMode === 'finished') {
150
- gap = car.rank === 1 ? 'Winner' : 'FIN';
151
- } else if (raceMode === 'safety-car' && car.rank > 1) {
152
- gap = 'SC';
153
- } else if (raceMode === 'pre-start') {
154
- gap = car.rank === 1 ? 'Pole' : 'Grid';
155
- } else if (car.rank > 1) {
156
- gap = formatRaceGap(car, timingGapMode);
157
- }
158
- const tire = car.tire ?? driver?.tire ?? 'M';
159
- const timingCode = car.timingCode ?? driver?.timingCode ?? car.code;
160
- const team = car.team ?? driver?.team ?? null;
161
- const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
162
- const driverColor = formatCssColor(car.color);
163
- const iconColor = formatCssColor(team?.color ?? car.color, driverColor);
164
- const penalty = penaltyByDriver.get(car.id);
165
- const penaltyBadge = penalty
166
- ? `<span class="timing-penalty-badge" aria-label="${escapeHtml(formatPenaltyBadgeLabel(penalty))}" title="${escapeHtml(formatPenaltyBadgeLabel(penalty))}">!</span>`
167
- : '';
168
-
169
- return `
170
- <li>
171
- <button class="timing-row ${car.id === selectedId ? 'is-selected' : ''} ${dnf ? 'is-dnf' : ''}" type="button"
172
- data-driver-id="${escapeHtml(car.id)}" aria-label="Select ${escapeHtml(car.name)}"
173
- style="--driver-color: ${escapeHtml(driverColor)}">
174
- <span class="timing-position">${car.rank}</span>
175
- <span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
176
- <span class="timing-name" title="${escapeHtml(car.name)}"><span>${escapeHtml(timingCode)}</span>${penaltyBadge}</span>
177
- <span class="timing-gap">${escapeHtml(gap)}</span>
178
- <span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
179
- </button>
180
- </li>
181
- `;
182
- }).join('');
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('');
183
344
  if (timingMarkup !== lastTimingMarkup || timingList.innerHTML !== timingMarkup) {
184
345
  timingList.innerHTML = timingMarkup;
185
346
  return timingMarkup;