@inventure71/paddockjs 2.0.1 → 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 (136) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/INSTALL_AND_UPDATE.md +24 -4
  3. package/README.md +84 -512
  4. package/docs/architecture.md +38 -24
  5. package/docs/component_inventory.md +72 -0
  6. package/docs/composable-layouts.md +77 -0
  7. package/docs/concepts.md +2 -2
  8. package/docs/custom_model_controller.md +6 -6
  9. package/docs/data-helpers.md +50 -0
  10. package/docs/data_contract.md +104 -27
  11. package/docs/getting-started.md +75 -0
  12. package/docs/headless-environment.md +68 -0
  13. package/docs/index.md +18 -4
  14. package/docs/learnings.md +6 -6
  15. package/docs/rules.md +12 -8
  16. package/docs/sense_contract.md +1 -1
  17. package/docs/system_specs.md +71 -21
  18. package/docs/theming.md +64 -0
  19. package/docs/training.md +26 -19
  20. package/docs/troubleshooting.md +76 -0
  21. package/docs/upgrading-hosts-to-3.md +42 -0
  22. package/examples/python/README.md +6 -0
  23. package/examples/python/base_policy_server.py +19 -0
  24. package/package.json +10 -3
  25. package/src/api/PaddockSimulatorController.js +150 -24
  26. package/src/api/runtimeTheme.js +59 -0
  27. package/src/app/F1SimulatorApp.js +213 -29
  28. package/src/app/banners/raceDataBannerController.js +71 -36
  29. package/src/app/banners/stewardMessageController.js +5 -4
  30. package/src/app/camera/cameraConstants.js +2 -0
  31. package/src/app/camera/cameraController.js +86 -29
  32. package/src/app/camera/cameraGeometry.js +12 -1
  33. package/src/app/domBindings.js +89 -9
  34. package/src/app/layoutScheduler.js +27 -0
  35. package/src/app/layoutSupport.js +165 -0
  36. package/src/app/raceOverlayClearanceSupport.js +158 -0
  37. package/src/app/readouts/carOverviewRenderer.js +16 -8
  38. package/src/app/readouts/controlStateRenderer.js +1 -1
  39. package/src/app/readouts/raceStatusRenderer.js +30 -21
  40. package/src/app/readouts/readoutFormatters.js +2 -0
  41. package/src/app/readouts/telemetryRenderer.js +17 -8
  42. package/src/app/readouts/timingTowerRenderer.js +262 -49
  43. package/src/app/runtime/frameLoop.js +5 -1
  44. package/src/app/runtime/runtimeVisibility.js +66 -5
  45. package/src/config/cssValues.js +16 -0
  46. package/src/config/defaultOptions.js +58 -38
  47. package/src/config/themeOptions.js +830 -0
  48. package/src/config/timingGapMode.js +13 -0
  49. package/src/config/urlOptions.js +25 -0
  50. package/src/data/championship.js +14 -9
  51. package/src/data/index.d.ts +212 -0
  52. package/src/data/index.js +14 -0
  53. package/src/environment/controllerLoop.js +17 -3
  54. package/src/environment/episode.js +11 -1
  55. package/src/environment/index.d.ts +1 -2
  56. package/src/environment/metrics.js +59 -16
  57. package/src/environment/observationVector.js +50 -33
  58. package/src/environment/observations.js +89 -20
  59. package/src/environment/options.js +1 -1
  60. package/src/environment/runtime.js +44 -11
  61. package/src/environment/scenarios.js +1 -1
  62. package/src/environment/sensors/carRays.js +48 -37
  63. package/src/environment/sensors/index.js +501 -97
  64. package/src/environment/sensors/indexedRayBands.js +134 -46
  65. package/src/environment/sensors/rayBandTrace.js +1171 -0
  66. package/src/environment/sensors/rayChannels.js +108 -0
  67. package/src/environment/sensors/rayConfig.js +71 -25
  68. package/src/environment/sensors/rayGeometry.js +23 -17
  69. package/src/environment/sensors/sensorTargets.js +67 -1
  70. package/src/environment/sensors/surfaceRays.js +167 -118
  71. package/src/environment/sensors/trackRays.js +157 -48
  72. package/src/index.d.ts +153 -9
  73. package/src/index.js +52 -3
  74. package/src/simulation/collisionGeometry.js +260 -96
  75. package/src/simulation/driver/driverController.js +1 -1
  76. package/src/simulation/driver/edgeRecovery.js +1 -1
  77. package/src/simulation/driver/racingControls.js +2 -2
  78. package/src/simulation/driver/rejoinControls.js +2 -2
  79. package/src/simulation/pit/pitFlow.js +21 -9
  80. package/src/simulation/pit/pitKinematics.js +2 -2
  81. package/src/simulation/pit/pitQueue.js +5 -3
  82. package/src/simulation/pit/pitRouteMovement.js +9 -4
  83. package/src/simulation/pit/pitRouting.js +79 -46
  84. package/src/simulation/pit/pitState.js +1 -0
  85. package/src/simulation/pit/pitTireService.js +7 -3
  86. package/src/simulation/race/raceFinish.js +25 -8
  87. package/src/simulation/race/raceOrder.js +189 -32
  88. package/src/simulation/race/raceProgress.js +129 -37
  89. package/src/simulation/race/raceSetup.js +4 -6
  90. package/src/simulation/race/raceStep.js +26 -14
  91. package/src/simulation/race/safetyCar.js +1 -1
  92. package/src/simulation/race/stalledDnf.js +1 -1
  93. package/src/simulation/raceSimulation.js +20 -17
  94. package/src/simulation/rules/collisionSteward.js +40 -23
  95. package/src/simulation/rules/rulesReview.js +77 -56
  96. package/src/simulation/snapshots/raceSnapshots.js +37 -0
  97. package/src/simulation/timing/drsTiming.js +143 -30
  98. package/src/simulation/timing/gapEstimation.js +25 -3
  99. package/src/simulation/timing/lapTelemetry.js +138 -40
  100. package/src/simulation/timing/raceTiming.js +5 -1
  101. package/src/simulation/timing/sectorPerformance.js +104 -26
  102. package/src/simulation/timing/timingHistory.js +229 -14
  103. package/src/simulation/timing/timingLines.js +65 -7
  104. package/src/simulation/track/pitLaneLayout.js +30 -3
  105. package/src/simulation/track/pitLaneState.js +218 -19
  106. package/src/simulation/track/spatialQueries.js +66 -33
  107. package/src/simulation/track/trackMath.js +91 -24
  108. package/src/simulation/track/trackModel.js +2 -0
  109. package/src/simulation/track/trackQueryIndex.js +1468 -189
  110. package/src/simulation/track/trackQueryPitIndex.js +138 -14
  111. package/src/simulation/track/trackQueryScratch.js +7 -0
  112. package/src/simulation/track/trackQueryStats.js +25 -0
  113. package/src/simulation/track/trackSnapshotJson.js +139 -0
  114. package/src/simulation/track/trackStatePolicy.js +249 -2
  115. package/src/simulation/vehicle/contactResolution.js +99 -62
  116. package/src/simulation/vehicle/mainTrackWheelSurface.js +73 -34
  117. package/src/simulation/vehicle/pitWheelSurface.js +81 -28
  118. package/src/simulation/vehicle/runoffResponse.js +54 -29
  119. package/src/simulation/vehicle/vehicleGeometry.js +245 -51
  120. package/src/simulation/vehicle/vehicleKinematics.js +2 -2
  121. package/src/simulation/vehicle/vehiclePhysics.js +45 -30
  122. package/src/simulation/vehicle/vehicleSnapshots.js +35 -1
  123. package/src/simulation/vehicle/vehicleState.js +7 -1
  124. package/src/simulation/vehicle/wheelSurface.js +807 -92
  125. package/src/simulation/warmup/runtimeWarmup.js +0 -1
  126. package/src/styles.css +1568 -519
  127. package/src/ui/bannerTemplates.js +38 -28
  128. package/src/ui/cameraControlsTemplate.js +16 -5
  129. package/src/ui/carOverviewTemplate.js +12 -5
  130. package/src/ui/raceCanvasTemplate.js +31 -8
  131. package/src/ui/raceControlsTemplate.js +27 -10
  132. package/src/ui/shellTemplate.js +3 -1
  133. package/src/ui/telemetryDrawerTemplate.js +23 -10
  134. package/src/ui/telemetryTemplates.js +58 -32
  135. package/src/ui/templateUtils.js +48 -0
  136. package/src/ui/timingTowerTemplate.js +29 -12
@@ -1,4 +1,5 @@
1
1
  import { F1SimulatorApp } from '../app/F1SimulatorApp.js';
2
+ import { installLayoutSupport } from '../app/layoutSupport.js';
2
3
  import {
3
4
  createRaceDataPanelMarkup,
4
5
  createTelemetrySectorBannerMarkup,
@@ -18,7 +19,10 @@ import {
18
19
  createTimingTowerMarkup,
19
20
  } from '../ui/componentTemplates.js';
20
21
  import { applyPaddockThemeCssVariables, resolveF1SimulatorOptions } from '../config/defaultOptions.js';
22
+ import { formatCssUrl } from '../config/cssValues.js';
21
23
  import { mergeRestartOptions } from '../config/restartOptions.js';
24
+ import { getNextTimingGapMode, normalizeTimingGapMode } from '../config/timingGapMode.js';
25
+ import { createThemeSync, resolveRuntimeThemeModeOptions, resolveRuntimeThemeOptions } from './runtimeTheme.js';
22
26
 
23
27
  function assertMountTarget(root, label) {
24
28
  if (!root || typeof root !== 'object' || !('innerHTML' in root)) {
@@ -26,10 +30,10 @@ function assertMountTarget(root, label) {
26
30
  }
27
31
  }
28
32
 
29
- function setPackageCssVariables(root, assets, theme) {
33
+ function setPackageCssVariables(root, assets, theme, context = {}) {
30
34
  root.classList?.add?.('f1-sim-component');
31
- root.style?.setProperty?.('--broadcast-panel-surface', `url('${assets.broadcastPanel}')`);
32
- applyPaddockThemeCssVariables(root, theme);
35
+ root.style?.setProperty?.('--broadcast-panel-surface', formatCssUrl(assets.broadcastPanel));
36
+ applyPaddockThemeCssVariables(root, theme, context);
33
37
  }
34
38
 
35
39
  function createCompositeRoot(getRoots, getOptions) {
@@ -38,6 +42,9 @@ function createCompositeRoot(getRoots, getOptions) {
38
42
  setProperty(name, value) {
39
43
  getRoots().forEach((root) => root.style?.setProperty?.(name, value));
40
44
  },
45
+ removeProperty(name) {
46
+ getRoots().forEach((root) => root.style?.removeProperty?.(name));
47
+ },
41
48
  },
42
49
  querySelector(selector) {
43
50
  for (const root of getRoots()) {
@@ -49,9 +56,15 @@ function createCompositeRoot(getRoots, getOptions) {
49
56
  querySelectorAll(selector) {
50
57
  return getRoots().flatMap((root) => [...(root.querySelectorAll?.(selector) ?? [])]);
51
58
  },
52
- applyCssVariables() {
59
+ setAttribute(name, value) {
60
+ getRoots().forEach((root) => root.setAttribute?.(name, value));
61
+ },
62
+ removeAttribute(name) {
63
+ getRoots().forEach((root) => root.removeAttribute?.(name));
64
+ },
65
+ applyCssVariables(context = {}) {
53
66
  const options = getOptions();
54
- getRoots().forEach((root) => setPackageCssVariables(root, options.assets, options.theme));
67
+ getRoots().forEach((root) => setPackageCssVariables(root, options.assets, options.theme, context));
55
68
  },
56
69
  };
57
70
  }
@@ -60,35 +73,41 @@ export class PaddockSimulatorController {
60
73
  constructor(options = {}) {
61
74
  this.options = resolveF1SimulatorOptions(options);
62
75
  this.roots = new Map();
76
+ this.mountRenderers = new Map();
77
+ this.layoutSupportCleanups = new Map();
63
78
  this.app = null;
64
79
  this.compositeRoot = createCompositeRoot(() => [...this.roots.values()], () => this.options);
65
80
  }
66
81
 
67
- mountComponent(root, key, markup) {
82
+ mountComponent(root, key, createMarkup) {
68
83
  assertMountTarget(root, `mount ${key}`);
69
84
  if (this.app) {
70
85
  throw new Error('Mount PaddockJS components before calling start().');
71
86
  }
72
- root.innerHTML = markup;
87
+ const render = typeof createMarkup === 'function' ? createMarkup : () => createMarkup;
88
+ root.innerHTML = render();
73
89
  setPackageCssVariables(root, this.options.assets, this.options.theme);
90
+ this.layoutSupportCleanups.get(key)?.();
91
+ this.layoutSupportCleanups.set(key, installLayoutSupport(root));
74
92
  this.roots.set(key, root);
93
+ this.mountRenderers.set(key, render);
75
94
  return root;
76
95
  }
77
96
 
78
97
  mountRaceControls(root) {
79
- return this.mountComponent(root, 'race-controls', createRaceControlsMarkup(this.options));
98
+ return this.mountComponent(root, 'race-controls', () => createRaceControlsMarkup(this.options));
80
99
  }
81
100
 
82
101
  mountCameraControls(root) {
83
- return this.mountComponent(root, 'camera-controls', createCameraControlsMarkup(this.options));
102
+ return this.mountComponent(root, 'camera-controls', () => createCameraControlsMarkup(this.options));
84
103
  }
85
104
 
86
105
  mountSafetyCarControl(root) {
87
- return this.mountComponent(root, 'safety-car-control', createSafetyCarControlMarkup(this.options));
106
+ return this.mountComponent(root, 'safety-car-control', () => createSafetyCarControlMarkup(this.options));
88
107
  }
89
108
 
90
109
  mountTimingTower(root) {
91
- return this.mountComponent(root, 'timing-tower', createTimingTowerMarkup(this.options));
110
+ return this.mountComponent(root, 'timing-tower', () => createTimingTowerMarkup(this.options));
92
111
  }
93
112
 
94
113
  mountRaceCanvas(root, {
@@ -96,50 +115,55 @@ export class PaddockSimulatorController {
96
115
  includeTimingTower = false,
97
116
  includeTelemetrySectorBanner = false,
98
117
  timingTowerVerticalFit,
118
+ responsiveNarrowLayout,
99
119
  } = {}) {
100
- return this.mountComponent(root, 'race-canvas', createRaceCanvasMarkup({
120
+ return this.mountComponent(root, 'race-canvas', () => createRaceCanvasMarkup({
101
121
  ...this.options,
102
122
  includeRaceDataPanel,
103
123
  includeTimingTower,
104
124
  includeTelemetrySectorBanner,
105
125
  timingTowerVerticalFit,
126
+ responsiveNarrowLayout,
106
127
  }));
107
128
  }
108
129
 
109
130
  mountTelemetryPanel(root, { includeOverview } = {}) {
110
- return this.mountComponent(root, 'telemetry-stack', createTelemetryPanelMarkup(this.options, { includeOverview }));
131
+ return this.mountComponent(root, 'telemetry-stack', () => createTelemetryPanelMarkup(this.options, { includeOverview }));
111
132
  }
112
133
 
113
134
  mountTelemetryCore(root) {
114
- return this.mountComponent(root, 'telemetry-core', createTelemetryCoreMarkup(this.options));
135
+ return this.mountComponent(root, 'telemetry-core', () => createTelemetryCoreMarkup(this.options));
115
136
  }
116
137
 
117
138
  mountTelemetrySectors(root) {
118
- return this.mountComponent(root, 'telemetry-sectors', createTelemetrySectorsMarkup(this.options));
139
+ return this.mountComponent(root, 'telemetry-sectors', () => createTelemetrySectorsMarkup(this.options));
119
140
  }
120
141
 
121
142
  mountTelemetryLapTimes(root) {
122
- return this.mountComponent(root, 'telemetry-lap-times', createTelemetryLapTimesMarkup(this.options));
143
+ return this.mountComponent(root, 'telemetry-lap-times', () => createTelemetryLapTimesMarkup(this.options));
123
144
  }
124
145
 
125
146
  mountTelemetrySectorTimes(root) {
126
- return this.mountComponent(root, 'telemetry-sector-times', createTelemetrySectorTimesMarkup(this.options));
147
+ return this.mountComponent(root, 'telemetry-sector-times', () => createTelemetrySectorTimesMarkup(this.options));
127
148
  }
128
149
 
129
150
  mountTelemetrySectorBanner(root) {
130
- return this.mountComponent(root, 'telemetry-sector-banner', createTelemetrySectorBannerMarkup(this.options));
151
+ return this.mountComponent(root, 'telemetry-sector-banner', () => createTelemetrySectorBannerMarkup(this.options));
131
152
  }
132
153
 
133
154
  mountRaceTelemetryDrawer(root, options = {}) {
134
- return this.mountComponent(root, 'race-telemetry-drawer', createRaceTelemetryDrawerMarkup(this.options, options));
155
+ return this.mountComponent(root, 'race-telemetry-drawer', () => createRaceTelemetryDrawerMarkup(this.options, options));
135
156
  }
136
157
 
137
158
  mountCarDriverOverview(root) {
138
- return this.mountComponent(root, 'car-driver-overview', createCarDriverOverviewMarkup(this.options));
159
+ return this.mountComponent(root, 'car-driver-overview', () => createCarDriverOverviewMarkup(this.options));
139
160
  }
140
161
 
141
162
  mountRaceDataPanel(root) {
142
- return this.mountComponent(root, 'race-data-panel', createRaceDataPanelMarkup(this.options));
163
+ return this.mountComponent(root, 'race-data-panel', () => createRaceDataPanelMarkup({
164
+ ...this.options,
165
+ standalone: true,
166
+ }));
143
167
  }
144
168
 
145
169
  querySelector(selector) {
@@ -165,17 +189,86 @@ export class PaddockSimulatorController {
165
189
  destroy() {
166
190
  this.app?.destroy();
167
191
  this.app = null;
192
+ this.layoutSupportCleanups.forEach((cleanup) => cleanup());
193
+ this.layoutSupportCleanups.clear();
168
194
  this.roots.forEach((root) => {
169
195
  root.innerHTML = '';
170
196
  });
171
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
+ });
209
+ }
210
+
211
+ syncOptionsFromRunningApp() {
212
+ const timingGapMode = this.app?.getTimingGapMode?.();
213
+ if (!timingGapMode) return this.options;
214
+ this.options = {
215
+ ...this.options,
216
+ ui: {
217
+ ...this.options.ui,
218
+ timingGapMode: normalizeTimingGapMode(timingGapMode),
219
+ },
220
+ };
221
+ return this.options;
172
222
  }
173
223
 
174
224
  restart(nextOptions = {}) {
175
- const nextResolvedOptions = resolveF1SimulatorOptions(mergeRestartOptions(this.options, nextOptions));
176
- this.app?.restart(nextResolvedOptions);
225
+ const currentOptions = this.app ? this.syncOptionsFromRunningApp() : this.options;
226
+ const nextResolvedOptions = resolveF1SimulatorOptions(mergeRestartOptions(currentOptions, nextOptions));
227
+ if (this.app) {
228
+ const previousOptions = this.options;
229
+ this.options = nextResolvedOptions;
230
+ try {
231
+ this.app.restart(nextResolvedOptions);
232
+ } catch (error) {
233
+ this.options = previousOptions;
234
+ throw error;
235
+ }
236
+ return;
237
+ }
177
238
  this.options = nextResolvedOptions;
178
- 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);
179
272
  }
180
273
 
181
274
  selectDriver(driverId) {
@@ -223,6 +316,39 @@ export class PaddockSimulatorController {
223
316
  return this.app?.simulationSpeed ?? 1;
224
317
  }
225
318
 
319
+ setTimingGapMode(mode) {
320
+ const timingGapMode = this.app
321
+ ? this.app.setTimingGapMode(mode)
322
+ : normalizeTimingGapMode(mode);
323
+ this.options = {
324
+ ...this.options,
325
+ ui: {
326
+ ...this.options.ui,
327
+ timingGapMode,
328
+ },
329
+ };
330
+ return this.options.ui.timingGapMode;
331
+ }
332
+
333
+ getTimingGapMode() {
334
+ return this.app?.getTimingGapMode?.() ?? normalizeTimingGapMode(this.options.ui?.timingGapMode);
335
+ }
336
+
337
+ toggleTimingGapMode() {
338
+ if (this.app) {
339
+ const timingGapMode = this.app.toggleTimingGapMode();
340
+ this.options = {
341
+ ...this.options,
342
+ ui: {
343
+ ...this.options.ui,
344
+ timingGapMode,
345
+ },
346
+ };
347
+ return timingGapMode;
348
+ }
349
+ return this.setTimingGapMode(getNextTimingGapMode(this.options.ui?.timingGapMode));
350
+ }
351
+
226
352
  servePenalty(penaltyId) {
227
353
  return this.app?.servePenalty(penaltyId) ?? null;
228
354
  }
@@ -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
+ }