@inventure71/paddockjs 3.0.0 → 4.1.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 (97) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/INSTALL_AND_UPDATE.md +21 -4
  3. package/README.md +103 -551
  4. package/docs/architecture.md +28 -18
  5. package/docs/component_inventory.md +5 -4
  6. package/docs/composable-layouts.md +79 -0
  7. package/docs/custom_model_controller.md +4 -4
  8. package/docs/data-helpers.md +50 -0
  9. package/docs/data_contract.md +13 -7
  10. package/docs/getting-started.md +105 -0
  11. package/docs/headless-environment.md +68 -0
  12. package/docs/index.md +17 -4
  13. package/docs/rules.md +6 -2
  14. package/docs/system_specs.md +60 -7
  15. package/docs/theming.md +64 -0
  16. package/docs/training.md +20 -14
  17. package/docs/troubleshooting.md +76 -0
  18. package/docs/upgrading-hosts-to-3.md +42 -0
  19. package/examples/python/README.md +6 -0
  20. package/examples/python/base_policy_server.py +19 -0
  21. package/package.json +21 -5
  22. package/src/api/PaddockSimulatorController.js +78 -22
  23. package/src/api/runtimeTheme.js +59 -0
  24. package/src/app/F1SimulatorApp.js +24 -3
  25. package/src/app/readouts/timingTowerRenderer.js +203 -42
  26. package/src/app/runtime/frameLoop.js +5 -1
  27. package/src/config/defaultOptions.js +8 -1
  28. package/src/config/themeOptions.js +5 -0
  29. package/src/data/index.d.ts +212 -0
  30. package/src/data/index.js +14 -0
  31. package/src/environment/episode.js +11 -1
  32. package/src/environment/metrics.js +59 -16
  33. package/src/environment/observationVector.js +50 -33
  34. package/src/environment/observations.js +89 -20
  35. package/src/environment/runtime.js +41 -0
  36. package/src/environment/sensors/carRays.js +48 -37
  37. package/src/environment/sensors/index.js +501 -97
  38. package/src/environment/sensors/indexedRayBands.js +134 -46
  39. package/src/environment/sensors/rayBandTrace.js +1171 -0
  40. package/src/environment/sensors/rayChannels.js +108 -0
  41. package/src/environment/sensors/rayConfig.js +71 -25
  42. package/src/environment/sensors/rayGeometry.js +23 -17
  43. package/src/environment/sensors/sensorTargets.js +67 -1
  44. package/src/environment/sensors/surfaceRays.js +130 -89
  45. package/src/environment/sensors/trackRays.js +115 -1
  46. package/src/index.d.ts +43 -0
  47. package/src/index.js +27 -2
  48. package/src/placeholder/index.d.ts +11 -0
  49. package/src/placeholder/index.js +61 -0
  50. package/src/placeholder.css +72 -0
  51. package/src/simulation/collisionGeometry.js +260 -96
  52. package/src/simulation/pit/pitFlow.js +21 -9
  53. package/src/simulation/pit/pitQueue.js +5 -3
  54. package/src/simulation/pit/pitRouteMovement.js +9 -4
  55. package/src/simulation/pit/pitRouting.js +77 -42
  56. package/src/simulation/pit/pitState.js +1 -0
  57. package/src/simulation/pit/pitTireService.js +7 -3
  58. package/src/simulation/race/raceFinish.js +25 -8
  59. package/src/simulation/race/raceOrder.js +189 -32
  60. package/src/simulation/race/raceProgress.js +129 -37
  61. package/src/simulation/race/raceSetup.js +2 -0
  62. package/src/simulation/race/raceStep.js +25 -13
  63. package/src/simulation/raceSimulation.js +20 -17
  64. package/src/simulation/rules/collisionSteward.js +40 -23
  65. package/src/simulation/rules/rulesReview.js +77 -56
  66. package/src/simulation/snapshots/raceSnapshots.js +37 -0
  67. package/src/simulation/timing/drsTiming.js +143 -30
  68. package/src/simulation/timing/gapEstimation.js +25 -3
  69. package/src/simulation/timing/lapTelemetry.js +115 -36
  70. package/src/simulation/timing/raceTiming.js +5 -1
  71. package/src/simulation/timing/sectorPerformance.js +104 -26
  72. package/src/simulation/timing/timingHistory.js +229 -14
  73. package/src/simulation/timing/timingLines.js +65 -7
  74. package/src/simulation/track/pitLaneLayout.js +23 -0
  75. package/src/simulation/track/pitLaneState.js +218 -19
  76. package/src/simulation/track/spatialQueries.js +67 -11
  77. package/src/simulation/track/trackMath.js +91 -24
  78. package/src/simulation/track/trackModel.js +2 -0
  79. package/src/simulation/track/trackQueryIndex.js +1334 -100
  80. package/src/simulation/track/trackQueryPitIndex.js +138 -14
  81. package/src/simulation/track/trackQueryScratch.js +7 -0
  82. package/src/simulation/track/trackQueryStats.js +25 -0
  83. package/src/simulation/track/trackSnapshotJson.js +139 -0
  84. package/src/simulation/track/trackStatePolicy.js +249 -1
  85. package/src/simulation/vehicle/contactResolution.js +96 -59
  86. package/src/simulation/vehicle/mainTrackWheelSurface.js +73 -34
  87. package/src/simulation/vehicle/pitWheelSurface.js +81 -28
  88. package/src/simulation/vehicle/runoffResponse.js +54 -29
  89. package/src/simulation/vehicle/vehicleGeometry.js +245 -51
  90. package/src/simulation/vehicle/vehiclePhysics.js +42 -27
  91. package/src/simulation/vehicle/vehicleSnapshots.js +23 -0
  92. package/src/simulation/vehicle/vehicleState.js +6 -0
  93. package/src/simulation/vehicle/wheelSurface.js +807 -92
  94. package/src/styles.css +497 -497
  95. package/src/ui/raceControlsTemplate.js +1 -0
  96. package/src/ui/telemetryDrawerTemplate.js +1 -0
  97. package/src/ui/telemetryTemplates.js +1 -0
@@ -0,0 +1,76 @@
1
+ # Troubleshooting
2
+
3
+ Use this when a host integration or package release check fails.
4
+
5
+ ## Raw Node Import Fails
6
+
7
+ Do not use this as the main package check:
8
+
9
+ ```bash
10
+ node -e "import('@inventure71/paddockjs')"
11
+ ```
12
+
13
+ The root package is browser-oriented and imports CSS/assets. Use the browser-free subpaths in Node:
14
+
15
+ ```js
16
+ import { createPaddockEnvironment } from '@inventure71/paddockjs/environment';
17
+ import { DriverData } from '@inventure71/paddockjs/data';
18
+ ```
19
+
20
+ ## CSS Is Missing
21
+
22
+ Import the package stylesheet from a browser entry when the host bundler does not extract it automatically:
23
+
24
+ ```js
25
+ import '@inventure71/paddockjs/styles.css';
26
+ ```
27
+
28
+ If styles are present but the simulator is squeezed or overlapping, check the host container size before changing package internals. PaddockJS supports roots at least `320px` wide and shows an `Unsupported size` placeholder below supported bounds.
29
+
30
+ ## Assets Do Not Resolve
31
+
32
+ PaddockJS owns default simulator assets. Hosts should not copy them manually. In the package repo, verify publish contents with:
33
+
34
+ ```bash
35
+ npm run pack:dry
36
+ ```
37
+
38
+ In a host app, verify through the host's browser build rather than a raw Node import.
39
+
40
+ ## Simulator Looks Frozen
41
+
42
+ Check whether browser expert mode is enabled:
43
+
44
+ ```js
45
+ expert: { enabled: true, controlledDrivers: ['budget'] }
46
+ ```
47
+
48
+ Expert mode intentionally waits for `simulator.expert.step(actions)`. Basic website mounts should omit `expert` unless they are playing back a model or debugging policy observations.
49
+
50
+ ## Policy Server Receives No Observations
51
+
52
+ Policy Runner server mode uses protocol version `2`. `/policy/decide-batch`
53
+ receives aligned compact arrays, not rich `observations`. Static specs,
54
+ configuration, `previousActionFields`, and `metricFields` are sent once to
55
+ `/policy/reset`, so servers should cache `observationSpec` and read model
56
+ inputs from `body.vectors[index]` for `body.driverIds[index]` during decisions.
57
+
58
+ ## Theme Toggle Resets The Race
59
+
60
+ Use runtime theme APIs instead of restarting:
61
+
62
+ ```js
63
+ simulator.setThemeMode('dark');
64
+ simulator.setTheme({ tokens: { primary: '#008c55' } });
65
+ ```
66
+
67
+ `restart(nextOptions)` is for race/data/seed changes, not normal light/dark presentation sync.
68
+
69
+ ## 3.x Upgrade Symptoms
70
+
71
+ - Remove host usage of `trackQueryIndex`; indexed track queries are package internals.
72
+ - Replace old strict physics usage with `physicsMode: 'advanced'`, or omit `physicsMode` for the default `arcade` mode.
73
+ - Use `@inventure71/paddockjs/environment` for headless simulation.
74
+ - Use `@inventure71/paddockjs/data` for CSS-free data/tooling imports.
75
+
76
+ See [Upgrading Hosts To PaddockJS 3.x](upgrading-hosts-to-3.md).
@@ -0,0 +1,42 @@
1
+ # Upgrading Hosts To PaddockJS 4.x
2
+
3
+ Use this checklist when moving a browser host website from 2.x-era PaddockJS usage to the 4.x package API.
4
+
5
+ 1. Install the 4.x package:
6
+
7
+ ```bash
8
+ npm install @inventure71/paddockjs@4
9
+ ```
10
+
11
+ 2. Import package CSS intentionally anywhere browser mounts are used:
12
+
13
+ ```js
14
+ import '@inventure71/paddockjs/styles.css';
15
+ ```
16
+
17
+ The stylesheet is scoped to PaddockJS roots and uses system fonts by default. Load any branded web fonts from the host app instead of relying on package-side remote font imports.
18
+
19
+ 3. Remove host usage of `trackQueryIndex`. Track query indexing is package-owned and internal in 4.x.
20
+
21
+ 4. Replace `physicsMode: 'simulator'` with `physicsMode: 'advanced'`, or omit `physicsMode` to keep the default `arcade` mode.
22
+
23
+ 5. Decide how the host should theme the simulator. Use mount-time `theme` for defaults, then use `simulator.setThemeMode(mode)` or `simulator.setTheme(theme)` for site light/dark toggles. Do not call `restart({ theme })` only to change presentation.
24
+
25
+ 6. Reuse public theme helpers instead of copying CSS variable names:
26
+
27
+ ```js
28
+ import {
29
+ PADDOCK_THEME_CSS_VARIABLES,
30
+ PADDOCK_THEME_TOKEN_KEYS,
31
+ applyPaddockTheme,
32
+ resolvePaddockTheme,
33
+ } from '@inventure71/paddockjs';
34
+ ```
35
+
36
+ 7. Decide whether the host should enable the driver camera with `ui.driverCamera: true` or `initialCameraMode: 'driver'`.
37
+
38
+ 8. Use `@inventure71/paddockjs/environment` for browser-free simulation/training imports.
39
+
40
+ 9. Use `@inventure71/paddockjs/data` for CSS-free data/helper imports such as `DriverData`, `normalizeSimulatorDrivers`, `createProceduralTrack`, and speed conversion helpers.
41
+
42
+ 10. Rebuild the host bundle and browser-smoke the host page, including theme toggles, selected-driver surfaces, and any composable mount roots.
@@ -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.1.0",
4
4
  "type": "module",
5
5
  "description": "A data-driven browser F1 simulator component for host websites.",
6
6
  "license": "Apache-2.0",
@@ -24,7 +24,8 @@
24
24
  "ui-component"
25
25
  ],
26
26
  "sideEffects": [
27
- "./src/styles.css"
27
+ "./src/styles.css",
28
+ "./src/placeholder.css"
28
29
  ],
29
30
  "publishConfig": {
30
31
  "access": "public"
@@ -45,7 +46,18 @@
45
46
  "import": "./src/environment/index.js",
46
47
  "default": "./src/environment/index.js"
47
48
  },
48
- "./styles.css": "./src/styles.css"
49
+ "./data": {
50
+ "types": "./src/data/index.d.ts",
51
+ "import": "./src/data/index.js",
52
+ "default": "./src/data/index.js"
53
+ },
54
+ "./placeholder": {
55
+ "types": "./src/placeholder/index.d.ts",
56
+ "import": "./src/placeholder/index.js",
57
+ "default": "./src/placeholder/index.js"
58
+ },
59
+ "./styles.css": "./src/styles.css",
60
+ "./placeholder.css": "./src/placeholder.css"
49
61
  },
50
62
  "files": [
51
63
  "CHANGELOG.md",
@@ -63,25 +75,29 @@
63
75
  "src/config",
64
76
  "src/data",
65
77
  "src/environment",
78
+ "src/placeholder",
66
79
  "src/rendering",
67
80
  "src/simulation",
68
81
  "src/ui",
69
82
  "src/index.d.ts",
70
83
  "src/index.js",
71
84
  "src/styles.css",
85
+ "src/placeholder.css",
72
86
  "README.md"
73
87
  ],
74
88
  "scripts": {
75
89
  "test": "node scripts/run-vitest.mjs --slow",
76
90
  "test:fast": "node scripts/run-vitest.mjs",
77
91
  "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",
92
+ "docs:check": "node scripts/docs-check.mjs",
93
+ "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",
94
+ "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
95
  "pack:dry": "npm pack --dry-run",
81
96
  "consumer:smoke": "node scripts/consumer-package-smoke.mjs",
82
97
  "browser:smoke": "node smoke/browser-smoke.mjs",
83
98
  "browser:smoke:quick": "node smoke/browser-smoke.mjs --quick",
84
99
  "browser:smoke:full": "node smoke/browser-smoke.mjs --full",
100
+ "benchmark:runtime": "node scripts/benchmark-runtime-efficiency.mjs",
85
101
  "showcase:install": "npm --prefix local-preview ci",
86
102
  "showcase:dev": "npm --prefix local-preview run dev",
87
103
  "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) {