@inventure71/paddockjs 2.0.0 → 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.
- package/CHANGELOG.md +37 -1
- package/INSTALL_AND_UPDATE.md +10 -1
- package/README.md +92 -17
- package/docs/architecture.md +26 -16
- package/docs/component_inventory.md +72 -0
- package/docs/concepts.md +7 -5
- package/docs/custom_model_controller.md +2 -2
- package/docs/data_contract.md +121 -33
- package/docs/data_model.md +1 -1
- package/docs/index.md +10 -0
- package/docs/learnings.md +6 -6
- package/docs/rules.md +39 -9
- package/docs/sense_contract.md +59 -0
- package/docs/system_specs.md +76 -20
- package/docs/training.md +27 -20
- package/package.json +1 -1
- package/src/api/PaddockSimulatorController.js +75 -36
- package/src/app/BrowserExpertAdapter.js +9 -300
- package/src/app/BrowserExpertExternalRenderer.js +303 -0
- package/src/app/F1SimulatorApp.js +190 -68
- package/src/app/banners/raceDataBannerController.js +71 -36
- package/src/app/banners/stewardMessageController.js +5 -4
- package/src/app/camera/cameraConstants.js +2 -0
- package/src/app/camera/cameraController.js +86 -29
- package/src/app/camera/cameraGeometry.js +12 -1
- package/src/app/domBindings.js +89 -9
- package/src/app/layoutScheduler.js +27 -0
- package/src/app/layoutSupport.js +165 -0
- package/src/app/raceOverlayClearanceSupport.js +158 -0
- package/src/app/readouts/carOverviewRenderer.js +16 -8
- package/src/app/readouts/controlStateRenderer.js +1 -1
- package/src/app/readouts/raceStatusRenderer.js +30 -21
- package/src/app/readouts/readoutFormatters.js +2 -0
- package/src/app/readouts/telemetryRenderer.js +17 -8
- package/src/app/readouts/timingTowerRenderer.js +62 -10
- package/src/app/runtime/runtimeVisibility.js +66 -5
- package/src/config/cssValues.js +16 -0
- package/src/config/defaultOptions.js +51 -38
- package/src/config/restartOptions.js +34 -0
- package/src/config/themeOptions.js +825 -0
- package/src/config/timingGapMode.js +13 -0
- package/src/config/urlOptions.js +25 -0
- package/src/data/championship.js +14 -9
- package/src/environment/controllerLoop.js +34 -5
- package/src/environment/episode.js +14 -2
- package/src/environment/index.d.ts +17 -4
- package/src/environment/observationVector.js +200 -0
- package/src/environment/observations.js +58 -285
- package/src/environment/options.js +1 -1
- package/src/environment/recorder.js +13 -6
- package/src/environment/rewards.js +13 -2
- package/src/environment/runtime.js +65 -25
- package/src/environment/scenarios.js +22 -3
- package/src/environment/sensors/index.js +3 -3
- package/src/environment/sensors/opponentRadar.js +13 -3
- package/src/environment/sensors/sensorTargets.js +8 -2
- package/src/environment/sensors/surfaceRays.js +84 -12
- package/src/environment/sensors/trackRays.js +173 -15
- package/src/environment/specs.js +78 -40
- package/src/index.d.ts +112 -9
- package/src/index.js +26 -33
- package/src/simulation/driver/driverController.js +1 -1
- package/src/simulation/driver/edgeRecovery.js +1 -1
- package/src/simulation/driver/racingControls.js +2 -2
- package/src/simulation/driver/rejoinControls.js +2 -2
- package/src/simulation/pit/pitFlow.js +5 -0
- package/src/simulation/pit/pitKinematics.js +24 -0
- package/src/simulation/pit/pitQueue.js +5 -0
- package/src/simulation/pit/pitRouteMovement.js +7 -0
- package/src/simulation/pit/pitRouting.js +2 -4
- package/src/simulation/pit/pitService.js +2 -0
- package/src/simulation/pit/pitTireService.js +2 -0
- package/src/simulation/race/raceLifecycle.js +2 -6
- package/src/simulation/race/raceProgress.js +6 -1
- package/src/simulation/race/raceSetup.js +2 -6
- package/src/simulation/race/raceStep.js +2 -2
- package/src/simulation/race/redFlag.js +3 -3
- package/src/simulation/race/safetyCar.js +10 -4
- package/src/simulation/race/stalledDnf.js +8 -17
- package/src/simulation/raceSimulation.js +1 -0
- package/src/simulation/rules/rulesReview.js +3 -0
- package/src/simulation/snapshots/raceSnapshots.js +1 -0
- package/src/simulation/timing/lapTelemetry.js +28 -9
- package/src/simulation/track/pitLaneLayout.js +7 -3
- package/src/simulation/track/spatialQueries.js +5 -28
- package/src/simulation/track/trackQueryGrid.js +94 -0
- package/src/simulation/track/trackQueryIndex.js +193 -434
- package/src/simulation/track/trackQueryPitIndex.js +140 -0
- package/src/simulation/track/trackQueryScratch.js +46 -0
- package/src/simulation/track/trackQueryStats.js +28 -0
- package/src/simulation/track/trackStatePolicy.js +0 -1
- package/src/simulation/vehicle/contactResolution.js +67 -2
- package/src/simulation/vehicle/runoffResponse.js +11 -12
- package/src/simulation/vehicle/vehicleKinematics.js +37 -0
- package/src/simulation/vehicle/vehiclePhysics.js +5 -4
- package/src/simulation/vehicle/vehicleSnapshots.js +22 -0
- package/src/simulation/vehicle/vehicleState.js +36 -2
- package/src/simulation/vehicle/wheelSurface.js +3 -1
- package/src/simulation/warmup/runtimeWarmup.js +0 -1
- package/src/styles.css +1185 -136
- package/src/ui/bannerTemplates.js +38 -28
- package/src/ui/cameraControlsTemplate.js +16 -5
- package/src/ui/carOverviewTemplate.js +12 -5
- package/src/ui/raceCanvasTemplate.js +31 -8
- package/src/ui/raceControlsTemplate.js +27 -10
- package/src/ui/shellTemplate.js +3 -1
- package/src/ui/telemetryDrawerTemplate.js +23 -10
- package/src/ui/telemetryTemplates.js +58 -32
- package/src/ui/templateUtils.js +48 -0
- 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
|
-
|
|
86
|
-
readouts.
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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,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 {
|
|
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 =
|
|
20
|
+
node.style.color = driverColor;
|
|
15
21
|
});
|
|
16
22
|
readouts.telemetrySectorBanners?.forEach?.((node) => {
|
|
17
|
-
node
|
|
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
|
-
|
|
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
|
-
?
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
108
|
-
|
|
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
|
|
111
|
-
button
|
|
112
|
-
button.
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
68
|
+
const ticker = app.app?.ticker;
|
|
69
|
+
if (!ticker) return;
|
|
34
70
|
|
|
35
71
|
const shouldRun = app.runtimeViewportVisible && app.runtimeDocumentVisible;
|
|
36
|
-
|
|
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
|
-
|
|
80
|
+
ticker.start?.();
|
|
42
81
|
} else {
|
|
43
|
-
|
|
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
|
+
}
|