@inventure71/paddockjs 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/INSTALL_AND_UPDATE.md +10 -1
  3. package/README.md +65 -15
  4. package/docs/architecture.md +17 -13
  5. package/docs/component_inventory.md +72 -0
  6. package/docs/concepts.md +2 -2
  7. package/docs/custom_model_controller.md +2 -2
  8. package/docs/data_contract.md +96 -21
  9. package/docs/index.md +2 -1
  10. package/docs/learnings.md +6 -6
  11. package/docs/rules.md +7 -7
  12. package/docs/sense_contract.md +1 -1
  13. package/docs/system_specs.md +25 -18
  14. package/docs/training.md +13 -12
  15. package/package.json +1 -1
  16. package/src/api/PaddockSimulatorController.js +74 -4
  17. package/src/app/F1SimulatorApp.js +191 -28
  18. package/src/app/banners/raceDataBannerController.js +71 -36
  19. package/src/app/banners/stewardMessageController.js +5 -4
  20. package/src/app/camera/cameraConstants.js +2 -0
  21. package/src/app/camera/cameraController.js +86 -29
  22. package/src/app/camera/cameraGeometry.js +12 -1
  23. package/src/app/domBindings.js +89 -9
  24. package/src/app/layoutScheduler.js +27 -0
  25. package/src/app/layoutSupport.js +165 -0
  26. package/src/app/raceOverlayClearanceSupport.js +158 -0
  27. package/src/app/readouts/carOverviewRenderer.js +16 -8
  28. package/src/app/readouts/controlStateRenderer.js +1 -1
  29. package/src/app/readouts/raceStatusRenderer.js +30 -21
  30. package/src/app/readouts/readoutFormatters.js +2 -0
  31. package/src/app/readouts/telemetryRenderer.js +17 -8
  32. package/src/app/readouts/timingTowerRenderer.js +62 -10
  33. package/src/app/runtime/runtimeVisibility.js +66 -5
  34. package/src/config/cssValues.js +16 -0
  35. package/src/config/defaultOptions.js +51 -38
  36. package/src/config/themeOptions.js +825 -0
  37. package/src/config/timingGapMode.js +13 -0
  38. package/src/config/urlOptions.js +25 -0
  39. package/src/data/championship.js +14 -9
  40. package/src/environment/controllerLoop.js +17 -3
  41. package/src/environment/index.d.ts +1 -2
  42. package/src/environment/options.js +1 -1
  43. package/src/environment/runtime.js +3 -11
  44. package/src/environment/scenarios.js +1 -1
  45. package/src/environment/sensors/surfaceRays.js +41 -33
  46. package/src/environment/sensors/trackRays.js +44 -49
  47. package/src/index.d.ts +110 -9
  48. package/src/index.js +25 -1
  49. package/src/simulation/driver/driverController.js +1 -1
  50. package/src/simulation/driver/edgeRecovery.js +1 -1
  51. package/src/simulation/driver/racingControls.js +2 -2
  52. package/src/simulation/driver/rejoinControls.js +2 -2
  53. package/src/simulation/pit/pitKinematics.js +2 -2
  54. package/src/simulation/pit/pitRouting.js +2 -4
  55. package/src/simulation/race/raceSetup.js +2 -6
  56. package/src/simulation/race/raceStep.js +1 -1
  57. package/src/simulation/race/safetyCar.js +1 -1
  58. package/src/simulation/race/stalledDnf.js +1 -1
  59. package/src/simulation/timing/lapTelemetry.js +28 -9
  60. package/src/simulation/track/pitLaneLayout.js +7 -3
  61. package/src/simulation/track/spatialQueries.js +5 -28
  62. package/src/simulation/track/trackQueryIndex.js +180 -135
  63. package/src/simulation/track/trackStatePolicy.js +0 -1
  64. package/src/simulation/vehicle/contactResolution.js +3 -3
  65. package/src/simulation/vehicle/vehicleKinematics.js +2 -2
  66. package/src/simulation/vehicle/vehiclePhysics.js +3 -3
  67. package/src/simulation/vehicle/vehicleSnapshots.js +12 -1
  68. package/src/simulation/vehicle/vehicleState.js +1 -1
  69. package/src/simulation/warmup/runtimeWarmup.js +0 -1
  70. package/src/styles.css +1185 -136
  71. package/src/ui/bannerTemplates.js +38 -28
  72. package/src/ui/cameraControlsTemplate.js +16 -5
  73. package/src/ui/carOverviewTemplate.js +12 -5
  74. package/src/ui/raceCanvasTemplate.js +31 -8
  75. package/src/ui/raceControlsTemplate.js +27 -10
  76. package/src/ui/shellTemplate.js +3 -1
  77. package/src/ui/telemetryDrawerTemplate.js +23 -10
  78. package/src/ui/telemetryTemplates.js +58 -32
  79. package/src/ui/templateUtils.js +48 -0
  80. package/src/ui/timingTowerTemplate.js +29 -12
@@ -0,0 +1,158 @@
1
+ const CLEARANCE_CLASS = 'sim-canvas-panel--needs-banner-clearance';
2
+ import { createRafScheduler } from './layoutScheduler.js';
3
+
4
+ const CLEARANCE_VAR = '--race-overlay-banner-clearance';
5
+ const OVERLAY_GAP_PX = 12;
6
+
7
+ function parsePixelValue(value, fallback = 0) {
8
+ const parsed = Number.parseFloat(value);
9
+ return Number.isFinite(parsed) ? parsed : fallback;
10
+ }
11
+
12
+ function isVisible(element) {
13
+ if (!element || element.hidden || element.classList?.contains('is-hidden')) return false;
14
+ const rect = element.getBoundingClientRect();
15
+ const style = getComputedStyle(element);
16
+ return rect.width > 0 &&
17
+ rect.height > 0 &&
18
+ style.display !== 'none' &&
19
+ style.visibility !== 'hidden';
20
+ }
21
+
22
+ function relativeBounds(panel, element) {
23
+ const panelRect = panel.getBoundingClientRect();
24
+ const rect = element.getBoundingClientRect();
25
+ return {
26
+ left: rect.left - panelRect.left,
27
+ right: rect.right - panelRect.left,
28
+ top: rect.top - panelRect.top,
29
+ bottom: rect.bottom - panelRect.top,
30
+ width: rect.width,
31
+ height: rect.height,
32
+ };
33
+ }
34
+
35
+ function timingEntryBounds(panel, tower) {
36
+ const panelRect = panel.getBoundingClientRect();
37
+ const towerStyle = getComputedStyle(tower);
38
+ const towerRect = tower.getBoundingClientRect();
39
+ const towerOpenLeft = parsePixelValue(towerStyle.left, towerRect.left - panelRect.left);
40
+
41
+ return Array.from(tower.querySelectorAll?.('.timing-row') ?? [])
42
+ .filter(isVisible)
43
+ .map((row) => {
44
+ const rowRect = row.getBoundingClientRect();
45
+ const rowLeftInTower = rowRect.left - towerRect.left;
46
+ const left = towerOpenLeft + rowLeftInTower;
47
+ return {
48
+ left,
49
+ right: left + rowRect.width,
50
+ top: rowRect.top - panelRect.top,
51
+ bottom: rowRect.bottom - panelRect.top,
52
+ };
53
+ });
54
+ }
55
+
56
+ function rectanglesOverlap(first, second) {
57
+ return first.left < second.right &&
58
+ first.right > second.left &&
59
+ first.top < second.bottom &&
60
+ first.bottom > second.top;
61
+ }
62
+
63
+ function createPanelOverlayRecord(panel) {
64
+ return {
65
+ panel,
66
+ tower: panel.querySelector?.('[data-timing-tower]') ?? null,
67
+ banner: panel.querySelector?.('[data-race-data-panel]') ?? null,
68
+ toggle: panel.querySelector?.('[data-timing-panel-toggle]') ?? null,
69
+ };
70
+ }
71
+
72
+ function syncPanelOverlayClearance(record) {
73
+ const { panel, tower, banner, toggle } = record;
74
+ if (!panel.classList?.contains('sim-canvas-panel--responsive-narrow')) {
75
+ panel.classList?.remove(CLEARANCE_CLASS);
76
+ panel.style?.removeProperty(CLEARANCE_VAR);
77
+ return;
78
+ }
79
+
80
+ const toggleStyle = toggle ? getComputedStyle(toggle) : null;
81
+ const timingRevealMode = Boolean(toggle && toggleStyle?.display !== 'none' && toggleStyle?.visibility !== 'hidden');
82
+ if (!timingRevealMode || !tower || !isVisible(banner)) {
83
+ panel.classList?.remove(CLEARANCE_CLASS);
84
+ panel.style?.removeProperty(CLEARANCE_VAR);
85
+ return;
86
+ }
87
+
88
+ const currentClearance = parsePixelValue(getComputedStyle(panel).getPropertyValue(CLEARANCE_VAR), 0);
89
+ const bannerBounds = relativeBounds(panel, banner);
90
+ const bannerWithoutClearance = {
91
+ ...bannerBounds,
92
+ top: bannerBounds.top - currentClearance,
93
+ bottom: bannerBounds.bottom - currentClearance,
94
+ };
95
+ const needsClearance = timingEntryBounds(panel, tower)
96
+ .some((entryBounds) => rectanglesOverlap(entryBounds, bannerWithoutClearance));
97
+
98
+ panel.classList?.toggle(CLEARANCE_CLASS, needsClearance);
99
+ if (!needsClearance) {
100
+ panel.style?.removeProperty(CLEARANCE_VAR);
101
+ return;
102
+ }
103
+
104
+ const panelRect = panel.getBoundingClientRect();
105
+ const bannerRect = banner.getBoundingClientRect();
106
+ const clearance = Math.max(0, Math.ceil(panelRect.bottom - bannerRect.top + OVERLAY_GAP_PX));
107
+ panel.style?.setProperty(CLEARANCE_VAR, `${clearance}px`);
108
+ }
109
+
110
+ function raceCanvasPanels(root) {
111
+ const queryRoot = root?.querySelectorAll ? root : null;
112
+ if (!queryRoot) return [];
113
+ const panels = [];
114
+ if (root.matches?.('.sim-canvas-panel--with-timing-tower')) panels.push(root);
115
+ panels.push(...queryRoot.querySelectorAll('.sim-canvas-panel--with-timing-tower'));
116
+ return [...new Set(panels)];
117
+ }
118
+
119
+ export function installRaceOverlayClearanceSupport(root) {
120
+ const panelRecords = raceCanvasPanels(root).map(createPanelOverlayRecord);
121
+ if (panelRecords.length === 0) return () => {};
122
+
123
+ const syncPanels = () => panelRecords.forEach(syncPanelOverlayClearance);
124
+ const scheduler = createRafScheduler(syncPanels);
125
+
126
+ scheduler.runNow();
127
+
128
+ const resizeObserver = typeof ResizeObserver === 'function'
129
+ ? new ResizeObserver(scheduler.queue)
130
+ : null;
131
+ panelRecords.forEach(({ panel, tower, banner }) => {
132
+ resizeObserver?.observe(panel);
133
+ if (tower) resizeObserver?.observe(tower);
134
+ if (banner) resizeObserver?.observe(banner);
135
+ });
136
+
137
+ const mutationObserver = typeof MutationObserver === 'function'
138
+ ? new MutationObserver(scheduler.queue)
139
+ : null;
140
+ panelRecords.forEach(({ panel }) => {
141
+ mutationObserver?.observe(panel, {
142
+ attributes: true,
143
+ attributeFilter: ['aria-hidden', 'class', 'hidden', 'style'],
144
+ childList: true,
145
+ subtree: true,
146
+ });
147
+ });
148
+
149
+ if (typeof requestAnimationFrame === 'function') {
150
+ scheduler.queue();
151
+ }
152
+
153
+ return () => {
154
+ resizeObserver?.disconnect();
155
+ mutationObserver?.disconnect();
156
+ scheduler.cancel();
157
+ };
158
+ }
@@ -1,6 +1,7 @@
1
1
  import { DRIVER_STAT_DEFINITIONS, formatDriverNumber, VEHICLE_STAT_DEFINITIONS } from '../../data/championship.js';
2
2
  import { normalizeCustomFields } from '../../data/customFields.js';
3
- import { setText } from '../domBindings.js';
3
+ import { setStyleProperty, setText } from '../domBindings.js';
4
+ import { formatCssColor } from './readoutFormatters.js';
4
5
 
5
6
  const VEHICLE_OVERVIEW_FIELDS = [
6
7
  ['power', 'Power'],
@@ -82,8 +83,9 @@ export function renderCarDriverOverview({
82
83
  });
83
84
  if (overviewKey === lastOverviewRenderKey) return lastOverviewRenderKey;
84
85
 
85
- readouts.carOverview?.style.setProperty('--driver-color', car.color);
86
- readouts.carOverviewDiagram?.style.setProperty('--driver-color', car.color);
86
+ const driverColor = formatCssColor(car.color);
87
+ setStyleProperty(readouts.carOverview, '--driver-color', driverColor);
88
+ setStyleProperty(readouts.carOverviewDiagram, '--driver-color', driverColor);
87
89
  if (readouts.carOverviewTitle) {
88
90
  readouts.carOverviewTitle.textContent = mode === 'driver' ? 'Driver overview' : 'Car overview';
89
91
  }
@@ -96,14 +98,20 @@ export function renderCarDriverOverview({
96
98
  }
97
99
  if (readouts.carOverviewNumber) readouts.carOverviewNumber.textContent = driverNumber;
98
100
  if (readouts.carOverviewCoreStat) readouts.carOverviewCoreStat.textContent = mode === 'driver' ? 'Driver' : 'Car';
99
- readouts.carOverviewFields.forEach((fieldNode, index) => {
101
+ const fieldBindings = readouts.carOverviewFieldBindings?.length
102
+ ? readouts.carOverviewFieldBindings
103
+ : [...(readouts.carOverviewFields ?? [])].map((fieldNode) => ({
104
+ field: fieldNode,
105
+ label: fieldNode.querySelector?.('[data-overview-field-label]') ?? null,
106
+ value: fieldNode.querySelector?.('[data-overview-field-value]') ?? null,
107
+ }));
108
+ fieldBindings.forEach((fieldBinding, index) => {
100
109
  const field = displayFields[index];
110
+ const fieldNode = fieldBinding.field;
101
111
  fieldNode.hidden = !field;
102
112
  if (!field) return;
103
- const labelNode = fieldNode.querySelector('[data-overview-field-label]');
104
- const valueNode = fieldNode.querySelector('[data-overview-field-value]');
105
- setText(labelNode, field.label);
106
- setText(valueNode, field.value);
113
+ setText(fieldBinding.label, field.label);
114
+ setText(fieldBinding.value, field.value);
107
115
  });
108
116
  return overviewKey;
109
117
  }
@@ -9,7 +9,7 @@ export function updateCameraControlButtons({ camera, cameraButtons, snapshot, ha
9
9
  cameraButtons.forEach((button) => {
10
10
  const mode = button.dataset.cameraMode;
11
11
  const isAvailable = isCameraModeAvailable(mode, snapshot);
12
- if (mode === 'pit') {
12
+ if (mode === 'pit' || mode === 'driver') {
13
13
  button.hidden = !isAvailable;
14
14
  button.disabled = !isAvailable;
15
15
  button.setAttribute('aria-hidden', String(!isAvailable));
@@ -1,6 +1,12 @@
1
1
  import { getRaceControlStatusBanner } from '../../ui/raceControlStatusBanner.js';
2
- import { setText } from '../domBindings.js';
3
- import { escapeHtml } from './readoutFormatters.js';
2
+ import { resolveNodes, setStyleProperty, setText } from '../domBindings.js';
3
+ import { escapeHtml, formatCssColor } from './readoutFormatters.js';
4
+
5
+ function setTextEvery(nodes, node, value) {
6
+ resolveNodes(nodes, node).forEach((target) => {
7
+ if (target.textContent !== value) target.textContent = value;
8
+ });
9
+ }
4
10
 
5
11
  export function renderStartLights(readouts, startLightNodes, raceControl) {
6
12
  const panel = readouts.startLights;
@@ -33,7 +39,7 @@ export function renderRaceFinish({ readouts, snapshot, driverById, lastFinishCla
33
39
  const winner = snapshot.raceControl.winner;
34
40
  const winnerDriver = winner ? driverById.get(winner.id) : null;
35
41
  const winnerName = winnerDriver?.name ?? winner?.name ?? 'Winner';
36
- panel.style.setProperty('--driver-color', winner?.color ?? winnerDriver?.color ?? 'var(--red)');
42
+ setStyleProperty(panel, '--driver-color', formatCssColor(winner?.color ?? winnerDriver?.color));
37
43
  setText(readouts.finishWinner, winnerName);
38
44
 
39
45
  if (readouts.finishClassification) {
@@ -84,26 +90,29 @@ export function renderRaceStatusReadouts({
84
90
  : 'var(--green)';
85
91
  }
86
92
  if (readouts.lap) readouts.lap.textContent = `${leader?.lap ?? 1}/${snapshot.totalLaps}`;
87
- if (readouts.towerLap) readouts.towerLap.textContent = leader?.lap ?? 1;
88
- if (readouts.towerTotalLaps) readouts.towerTotalLaps.textContent = snapshot.totalLaps;
89
- if (readouts.timingTower) {
90
- readouts.timingTower.classList.toggle('is-safety-car', snapshot.raceControl.mode === 'safety-car');
91
- readouts.timingTower.classList.toggle('is-red-flag', snapshot.raceControl.mode === 'red-flag');
92
- readouts.timingTower.classList.toggle('is-pre-start', snapshot.raceControl.mode === 'pre-start');
93
- }
94
- if (readouts.towerRaceControlBanner) {
93
+ setTextEvery(readouts.towerLaps, readouts.towerLap, leader?.lap ?? 1);
94
+ setTextEvery(readouts.towerTotalLapsAll, readouts.towerTotalLaps, snapshot.totalLaps);
95
+ resolveNodes(readouts.timingTowers, readouts.timingTower).forEach((timingTower) => {
96
+ timingTower.classList.toggle('is-safety-car', snapshot.raceControl.mode === 'safety-car');
97
+ timingTower.classList.toggle('is-red-flag', snapshot.raceControl.mode === 'red-flag');
98
+ timingTower.classList.toggle('is-pre-start', snapshot.raceControl.mode === 'pre-start');
99
+ });
100
+ const raceControlBanners = resolveNodes(readouts.towerRaceControlBanners, readouts.towerRaceControlBanner);
101
+ if (raceControlBanners.length > 0) {
95
102
  const raceControlBanner = getRaceControlStatusBanner(snapshot.raceControl.mode);
96
- readouts.towerRaceControlBanner.hidden = !raceControlBanner;
97
- readouts.towerRaceControlBanner.classList?.toggle?.('is-safety-car', raceControlBanner?.status === 'safety-car');
98
- readouts.towerRaceControlBanner.classList?.toggle?.('is-red-flag', raceControlBanner?.status === 'red-flag');
99
- if (raceControlBanner && readouts.towerRaceControlBanner.dataset) {
100
- readouts.towerRaceControlBanner.dataset.raceControlStatus = raceControlBanner.status;
101
- } else if (readouts.towerRaceControlBanner.dataset) {
102
- delete readouts.towerRaceControlBanner.dataset.raceControlStatus;
103
- }
103
+ raceControlBanners.forEach((banner) => {
104
+ banner.hidden = !raceControlBanner;
105
+ banner.classList?.toggle?.('is-safety-car', raceControlBanner?.status === 'safety-car');
106
+ banner.classList?.toggle?.('is-red-flag', raceControlBanner?.status === 'red-flag');
107
+ if (raceControlBanner && banner.dataset) {
108
+ banner.dataset.raceControlStatus = raceControlBanner.status;
109
+ } else if (banner.dataset) {
110
+ delete banner.dataset.raceControlStatus;
111
+ }
112
+ });
104
113
  if (raceControlBanner) {
105
- setText(readouts.towerRaceControlKicker, raceControlBanner.kicker);
106
- setText(readouts.towerRaceControlTitle, raceControlBanner.title);
114
+ setTextEvery(readouts.towerRaceControlKickers, readouts.towerRaceControlKicker, raceControlBanner.kicker);
115
+ setTextEvery(readouts.towerRaceControlTitles, readouts.towerRaceControlTitle, raceControlBanner.title);
107
116
  }
108
117
  }
109
118
  if (readouts.drs) {
@@ -1,3 +1,5 @@
1
+ export { formatCssColor } from '../../config/cssValues.js';
2
+
1
3
  export function escapeHtml(value) {
2
4
  return String(value ?? '')
3
5
  .replaceAll('&', '&amp;')
@@ -1,20 +1,26 @@
1
- import { setTextAll, setText } from '../domBindings.js';
1
+ import { setStyleProperty, setTextAll, setText } from '../domBindings.js';
2
2
  import { clamp } from '../../simulation/simMath.js';
3
3
  import { formatDriverNumber } from '../../data/championship.js';
4
- import { formatTelemetryGap, formatTelemetryTime, setPerformanceClass } from './readoutFormatters.js';
4
+ import {
5
+ formatCssColor,
6
+ formatTelemetryGap,
7
+ formatTelemetryTime,
8
+ setPerformanceClass,
9
+ } from './readoutFormatters.js';
5
10
 
6
11
  export function renderTelemetryReadouts({ readouts, car, driverById }) {
7
12
  if (!car) return;
8
13
  const driver = driverById.get(car.id);
9
14
  const drsState = car.drsActive ? 'OPEN' : car.drsEligible ? 'READY' : 'OFF';
10
15
  const surface = (car.surface ?? 'track').toUpperCase();
16
+ const driverColor = formatCssColor(car.color);
11
17
 
12
18
  setTextAll(readouts.selectedCode, car.code);
13
19
  readouts.selectedCode?.forEach?.((node) => {
14
- node.style.color = car.color;
20
+ node.style.color = driverColor;
15
21
  });
16
22
  readouts.telemetrySectorBanners?.forEach?.((node) => {
17
- node.style.setProperty('--driver-color', car.color);
23
+ setStyleProperty(node, '--driver-color', driverColor);
18
24
  });
19
25
  setTextAll(readouts.selectedName, car.name);
20
26
  setTextAll(readouts.speed, `${Math.round(car.speedKph)} km/h`);
@@ -69,9 +75,7 @@ export function renderLapTelemetry(readouts, telemetry) {
69
75
  const fill = clamp((progress ?? 0) * 100, 0, 100);
70
76
  const sectorComplete = fill >= 99.9;
71
77
  const fillValue = `${fill.toFixed(1)}%`;
72
- if (bar.style.getPropertyValue('--sector-fill') !== fillValue) {
73
- bar.style.setProperty('--sector-fill', fillValue);
74
- }
78
+ setStyleProperty(bar, '--sector-fill', fillValue);
75
79
  bar.classList.toggle('is-active', isActive);
76
80
  bar.classList.toggle('is-complete', sectorComplete);
77
81
  setPerformanceClass(bar, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
@@ -84,11 +88,16 @@ export function renderLapTelemetry(readouts, telemetry) {
84
88
  const isActive = index === activeIndex;
85
89
  const isCompletedCurrentSector = index < activeIndex;
86
90
  const hasLiveSectors = Array.isArray(telemetry.liveSectors);
91
+ const activeSectorCompleted = isActive &&
92
+ Number.isFinite(telemetry.liveSectors?.[index]) &&
93
+ (telemetry.sectorProgress?.[index] ?? 0) >= 0.999;
87
94
  const value = index > activeIndex
88
95
  ? null
89
96
  : hasLiveSectors
90
97
  ? isActive
91
- ? telemetry.currentSectorElapsed ?? telemetry.liveSectors[index]
98
+ ? activeSectorCompleted
99
+ ? telemetry.liveSectors[index]
100
+ : telemetry.currentSectorElapsed ?? telemetry.liveSectors[index]
92
101
  : telemetry.liveSectors[index]
93
102
  : isActive && !Number.isFinite(telemetry.currentSectors?.[index])
94
103
  ? telemetry.currentSectorElapsed
@@ -1,5 +1,12 @@
1
- import { setText } from '../domBindings.js';
2
- import { escapeHtml, formatLapGap, formatRaceGap, getTireClass } from './readoutFormatters.js';
1
+ import { getNextTimingGapMode, getTimingGapModeLabel, normalizeTimingGapMode } from '../../config/timingGapMode.js';
2
+ import { setText, setTextAll } from '../domBindings.js';
3
+ import {
4
+ escapeHtml,
5
+ formatCssColor,
6
+ formatLapGap,
7
+ formatRaceGap,
8
+ getTireClass,
9
+ } from './readoutFormatters.js';
3
10
 
4
11
  function formatPenaltyHeadline(penalty) {
5
12
  const serviceType = penalty?.serviceType;
@@ -104,12 +111,17 @@ export function getTimingOrderKey(cars = [], { timingGapMode = 'interval' } = {}
104
111
  }
105
112
 
106
113
  export function syncTimingGapModeControls({ readouts, buttons, timingGapMode }) {
107
- const label = timingGapMode === 'leader' ? 'Gap' : 'Int';
108
- setText(readouts.timingGapLabel, label);
114
+ const normalizedMode = normalizeTimingGapMode(timingGapMode);
115
+ const label = getTimingGapModeLabel(normalizedMode);
116
+ const nextLabel = getTimingGapModeLabel(getNextTimingGapMode(normalizedMode));
117
+ setTextAll(readouts.timingGapLabels ?? readouts.timingGapLabel, label);
109
118
  buttons.forEach((button) => {
110
- const active = button.dataset.timingGapMode === timingGapMode;
111
- button.setAttribute('aria-pressed', String(active));
112
- button.classList?.toggle?.('is-active', active);
119
+ const leaderMode = normalizedMode === 'leader';
120
+ setText(button, label);
121
+ button.setAttribute('aria-pressed', String(leaderMode));
122
+ button.setAttribute('aria-label', `Timing gap mode ${label}; switch to ${nextLabel}`);
123
+ button.setAttribute('title', `Switch to ${nextLabel}`);
124
+ button.classList?.toggle?.('is-active', leaderMode);
113
125
  });
114
126
  }
115
127
 
@@ -147,7 +159,8 @@ export function renderTimingTower({
147
159
  const timingCode = car.timingCode ?? driver?.timingCode ?? car.code;
148
160
  const team = car.team ?? driver?.team ?? null;
149
161
  const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
150
- const iconColor = team?.color ?? car.color;
162
+ const driverColor = formatCssColor(car.color);
163
+ const iconColor = formatCssColor(team?.color ?? car.color, driverColor);
151
164
  const penalty = penaltyByDriver.get(car.id);
152
165
  const penaltyBadge = penalty
153
166
  ? `<span class="timing-penalty-badge" aria-label="${escapeHtml(formatPenaltyBadgeLabel(penalty))}" title="${escapeHtml(formatPenaltyBadgeLabel(penalty))}">!</span>`
@@ -157,7 +170,7 @@ export function renderTimingTower({
157
170
  <li>
158
171
  <button class="timing-row ${car.id === selectedId ? 'is-selected' : ''} ${dnf ? 'is-dnf' : ''}" type="button"
159
172
  data-driver-id="${escapeHtml(car.id)}" aria-label="Select ${escapeHtml(car.name)}"
160
- style="--driver-color: ${escapeHtml(car.color)}">
173
+ style="--driver-color: ${escapeHtml(driverColor)}">
161
174
  <span class="timing-position">${car.rank}</span>
162
175
  <span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
163
176
  <span class="timing-name" title="${escapeHtml(car.name)}"><span>${escapeHtml(timingCode)}</span>${penaltyBadge}</span>
@@ -167,11 +180,50 @@ export function renderTimingTower({
167
180
  </li>
168
181
  `;
169
182
  }).join('');
170
- if (timingMarkup !== lastTimingMarkup) {
183
+ if (timingMarkup !== lastTimingMarkup || timingList.innerHTML !== timingMarkup) {
171
184
  timingList.innerHTML = timingMarkup;
172
185
  return timingMarkup;
173
186
  }
174
187
  return lastTimingMarkup;
175
188
  }
176
189
 
190
+ export function renderTimingTowers({
191
+ timingLists,
192
+ timingList,
193
+ cars,
194
+ raceMode,
195
+ penalties = [],
196
+ driverById,
197
+ selectedId,
198
+ timingGapMode,
199
+ timingPenaltyBadgesEnabled,
200
+ lastTimingMarkup,
201
+ }) {
202
+ const candidateLists = typeof timingLists?.forEach === 'function'
203
+ ? [...timingLists]
204
+ : [];
205
+ const lists = candidateLists.length > 0
206
+ ? candidateLists
207
+ : timingList
208
+ ? [timingList]
209
+ : [];
210
+ if (lists.length === 0) return lastTimingMarkup;
211
+
212
+ let nextTimingMarkup = lastTimingMarkup;
213
+ lists.forEach((list) => {
214
+ nextTimingMarkup = renderTimingTower({
215
+ timingList: list,
216
+ cars,
217
+ raceMode,
218
+ penalties,
219
+ driverById,
220
+ selectedId,
221
+ timingGapMode,
222
+ timingPenaltyBadgesEnabled,
223
+ lastTimingMarkup: nextTimingMarkup,
224
+ });
225
+ });
226
+ return nextTimingMarkup;
227
+ }
228
+
177
229
  export { formatLapGap, formatRaceGap };
@@ -1,11 +1,31 @@
1
1
  export function observeRuntimeVisibility(app) {
2
2
  if (!app.canvasHost) return;
3
3
 
4
+ const syncViewportVisibility = () => {
5
+ app.runtimeViewportVisible = isRuntimeViewportVisible(app.canvasHost);
6
+ app.syncRuntimeTicker();
7
+ };
8
+ const scheduleViewportVisibilitySync = () => {
9
+ if (app.runtimeVisibilitySyncFrame) return;
10
+ if (typeof globalThis.requestAnimationFrame !== 'function') {
11
+ syncViewportVisibility();
12
+ return;
13
+ }
14
+ app.runtimeVisibilitySyncFrame = globalThis.requestAnimationFrame(() => {
15
+ app.runtimeVisibilitySyncFrame = null;
16
+ syncViewportVisibility();
17
+ });
18
+ };
19
+
4
20
  if (typeof IntersectionObserver === 'function') {
5
21
  app.visibilityObserver?.disconnect?.();
6
22
  app.visibilityObserver = new IntersectionObserver((entries) => {
7
23
  const entry = entries.find((item) => item.target === app.canvasHost) ?? entries[0];
8
- app.runtimeViewportVisible = Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0);
24
+ app.runtimeViewportVisible = Boolean(
25
+ entry?.isIntersecting ||
26
+ entry?.intersectionRatio > 0 ||
27
+ isRuntimeViewportVisible(app.canvasHost),
28
+ );
9
29
  app.syncRuntimeTicker();
10
30
  }, {
11
31
  root: null,
@@ -25,21 +45,62 @@ export function observeRuntimeVisibility(app) {
25
45
  });
26
46
  }
27
47
 
48
+ syncViewportVisibility();
49
+ globalThis.addEventListener?.('scroll', scheduleViewportVisibilitySync, {
50
+ passive: true,
51
+ signal: app.abortController.signal,
52
+ });
53
+ globalThis.addEventListener?.('resize', scheduleViewportVisibilitySync, {
54
+ passive: true,
55
+ signal: app.abortController.signal,
56
+ });
57
+ globalThis.addEventListener?.('focus', syncViewportVisibility, {
58
+ signal: app.abortController.signal,
59
+ });
60
+ globalThis.addEventListener?.('pageshow', syncViewportVisibility, {
61
+ signal: app.abortController.signal,
62
+ });
28
63
  app.syncRuntimeTicker();
29
64
  }
30
65
 
31
66
  export function syncRuntimeTicker(app) {
32
67
  if (app.expertMode) return;
33
- if (!app.app?.ticker) return;
68
+ const ticker = app.app?.ticker;
69
+ if (!ticker) return;
34
70
 
35
71
  const shouldRun = app.runtimeViewportVisible && app.runtimeDocumentVisible;
36
- if (shouldRun === app.runtimeTickerRunning) return;
72
+ const tickerRunning = typeof ticker.started === 'boolean'
73
+ ? ticker.started
74
+ : app.runtimeTickerRunning;
75
+ if (shouldRun === app.runtimeTickerRunning && shouldRun === tickerRunning) return;
37
76
 
38
77
  app.runtimeTickerRunning = shouldRun;
39
78
  if (shouldRun) {
40
79
  app.resetFrameClock();
41
- app.app.ticker.start?.();
80
+ ticker.start?.();
42
81
  } else {
43
- app.app.ticker.stop?.();
82
+ ticker.stop?.();
44
83
  }
45
84
  }
85
+
86
+ function isRuntimeViewportVisible(element) {
87
+ const rect = element?.getBoundingClientRect?.();
88
+ if (!rect) return true;
89
+
90
+ const top = Number(rect.top);
91
+ const bottom = Number(rect.bottom);
92
+ const left = Number(rect.left);
93
+ const right = Number(rect.right);
94
+ const width = Number(rect.width ?? right - left);
95
+ const height = Number(rect.height ?? bottom - top);
96
+ if (![top, bottom, left, right, width, height].every(Number.isFinite)) return true;
97
+ if (width <= 0 || height <= 0) return false;
98
+
99
+ const viewportHeight = globalThis.innerHeight || globalThis.document?.documentElement?.clientHeight || 0;
100
+ const viewportWidth = globalThis.innerWidth || globalThis.document?.documentElement?.clientWidth || 0;
101
+ const margin = 240;
102
+ return bottom >= -margin &&
103
+ top <= viewportHeight + margin &&
104
+ right >= -margin &&
105
+ left <= viewportWidth + margin;
106
+ }
@@ -0,0 +1,16 @@
1
+ import { normalizeCssColorToken } from './themeOptions.js';
2
+
3
+ export const DEFAULT_SAFE_DRIVER_COLOR = '#e10600';
4
+
5
+ export function formatCssColor(value, fallback = DEFAULT_SAFE_DRIVER_COLOR) {
6
+ return normalizeCssColorToken(value, fallback);
7
+ }
8
+
9
+ export function formatCssUrl(value) {
10
+ const raw = String(value ?? '');
11
+ const escaped = raw
12
+ .replaceAll('\\', '\\\\')
13
+ .replaceAll('"', '\\"')
14
+ .replace(/[\u0000-\u001F\u007F]/g, '');
15
+ return `url("${escaped}")`;
16
+ }