@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.
- package/CHANGELOG.md +146 -1
- package/INSTALL_AND_UPDATE.md +15 -4
- package/README.md +84 -562
- package/docs/architecture.md +28 -18
- package/docs/composable-layouts.md +77 -0
- package/docs/custom_model_controller.md +4 -4
- package/docs/data-helpers.md +50 -0
- package/docs/data_contract.md +9 -7
- package/docs/getting-started.md +75 -0
- package/docs/headless-environment.md +68 -0
- package/docs/index.md +17 -4
- package/docs/rules.md +6 -2
- package/docs/system_specs.md +49 -6
- package/docs/theming.md +64 -0
- package/docs/training.md +20 -14
- package/docs/troubleshooting.md +76 -0
- package/docs/upgrading-hosts-to-3.md +42 -0
- package/examples/python/README.md +6 -0
- package/examples/python/base_policy_server.py +19 -0
- package/package.json +10 -3
- package/src/api/PaddockSimulatorController.js +78 -22
- package/src/api/runtimeTheme.js +59 -0
- package/src/app/F1SimulatorApp.js +24 -3
- package/src/app/readouts/timingTowerRenderer.js +203 -42
- package/src/app/runtime/frameLoop.js +5 -1
- package/src/config/defaultOptions.js +8 -1
- package/src/config/themeOptions.js +5 -0
- package/src/data/index.d.ts +212 -0
- package/src/data/index.js +14 -0
- package/src/environment/episode.js +11 -1
- package/src/environment/metrics.js +59 -16
- package/src/environment/observationVector.js +50 -33
- package/src/environment/observations.js +89 -20
- package/src/environment/runtime.js +41 -0
- package/src/environment/sensors/carRays.js +48 -37
- package/src/environment/sensors/index.js +501 -97
- package/src/environment/sensors/indexedRayBands.js +134 -46
- package/src/environment/sensors/rayBandTrace.js +1171 -0
- package/src/environment/sensors/rayChannels.js +108 -0
- package/src/environment/sensors/rayConfig.js +71 -25
- package/src/environment/sensors/rayGeometry.js +23 -17
- package/src/environment/sensors/sensorTargets.js +67 -1
- package/src/environment/sensors/surfaceRays.js +130 -89
- package/src/environment/sensors/trackRays.js +115 -1
- package/src/index.d.ts +43 -0
- package/src/index.js +27 -2
- package/src/simulation/collisionGeometry.js +260 -96
- package/src/simulation/pit/pitFlow.js +21 -9
- package/src/simulation/pit/pitQueue.js +5 -3
- package/src/simulation/pit/pitRouteMovement.js +9 -4
- package/src/simulation/pit/pitRouting.js +77 -42
- package/src/simulation/pit/pitState.js +1 -0
- package/src/simulation/pit/pitTireService.js +7 -3
- package/src/simulation/race/raceFinish.js +25 -8
- package/src/simulation/race/raceOrder.js +189 -32
- package/src/simulation/race/raceProgress.js +129 -37
- package/src/simulation/race/raceSetup.js +2 -0
- package/src/simulation/race/raceStep.js +25 -13
- package/src/simulation/raceSimulation.js +20 -17
- package/src/simulation/rules/collisionSteward.js +40 -23
- package/src/simulation/rules/rulesReview.js +77 -56
- package/src/simulation/snapshots/raceSnapshots.js +37 -0
- package/src/simulation/timing/drsTiming.js +143 -30
- package/src/simulation/timing/gapEstimation.js +25 -3
- package/src/simulation/timing/lapTelemetry.js +115 -36
- package/src/simulation/timing/raceTiming.js +5 -1
- package/src/simulation/timing/sectorPerformance.js +104 -26
- package/src/simulation/timing/timingHistory.js +229 -14
- package/src/simulation/timing/timingLines.js +65 -7
- package/src/simulation/track/pitLaneLayout.js +23 -0
- package/src/simulation/track/pitLaneState.js +218 -19
- package/src/simulation/track/spatialQueries.js +67 -11
- package/src/simulation/track/trackMath.js +91 -24
- package/src/simulation/track/trackModel.js +2 -0
- package/src/simulation/track/trackQueryIndex.js +1334 -100
- package/src/simulation/track/trackQueryPitIndex.js +138 -14
- package/src/simulation/track/trackQueryScratch.js +7 -0
- package/src/simulation/track/trackQueryStats.js +25 -0
- package/src/simulation/track/trackSnapshotJson.js +139 -0
- package/src/simulation/track/trackStatePolicy.js +249 -1
- package/src/simulation/vehicle/contactResolution.js +96 -59
- package/src/simulation/vehicle/mainTrackWheelSurface.js +73 -34
- package/src/simulation/vehicle/pitWheelSurface.js +81 -28
- package/src/simulation/vehicle/runoffResponse.js +54 -29
- package/src/simulation/vehicle/vehicleGeometry.js +245 -51
- package/src/simulation/vehicle/vehiclePhysics.js +42 -27
- package/src/simulation/vehicle/vehicleSnapshots.js +23 -0
- package/src/simulation/vehicle/vehicleState.js +6 -0
- package/src/simulation/vehicle/wheelSurface.js +807 -92
- 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
|
+
"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": "
|
|
79
|
-
"check
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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;
|