2025-04-23 20:21:18 -04:00

826 lines
27 KiB
JavaScript

window.gamepad.TemplateClass = class TelemetryTemplate {
/**
* Instanciates a new telemetry template
*/
constructor() {
this.AXES = ["clutch", "brake", "throttle", "steering"];
this.gamepad = window.gamepad;
this.loadSelectors();
this.loadParams();
if (!this.AXES.some((axis) => this[axis].index)) {
this.wizard();
return;
}
this.init();
}
/**
* Destroys the template
*/
destructor() {
this.running = false;
}
/**
* Converts a value to a percentage
*
* @param {number} value
* @param {number} min
* @param {number} max
* @returns {number}
*/
toPercentage(value, min, max) {
return Math.max(
0,
Math.min(100, Math.round((value - min) * (100 / (max - min)))),
);
}
/**
* Converts a value to degrees
*
* @param {number} value
* @param {number} min
* @param {number} max
* @returns {number}
*/
toDegrees(value, min, max) {
const percentage = this.toPercentage(value, min, max);
return (this.angle * (percentage - 50)) / 100;
}
/**
* Set the value of an axis
*
* @param {object} gamepad
* @param {string} axis
* @returns {number}
*/
toAxisValue(gamepad, axis) {
const { type, index, min, max } = this[axis];
if (!type || !index) return null;
const value =
type === "button"
? gamepad.buttons[index].value
: gamepad.axes[index];
return axis === "steering"
? this.toDegrees(value, min, max)
: this.toPercentage(value, min, max);
}
/**
* Loads the DOM selectors
*/
loadSelectors() {
this.$telemetry = document.querySelector("#telemetry");
this.$chart = this.$telemetry.querySelector("#chart");
this.chartContext = this.$chart.getContext("2d");
this.$meters = this.$telemetry.querySelector("#meters");
this.$clutch = this.$telemetry.querySelector("#clutch");
this.$clutchBar = this.$clutch.querySelector(".bar");
this.$clutchValue = this.$clutch.querySelector(".value");
this.$brake = this.$telemetry.querySelector("#brake");
this.$brakeBar = this.$brake.querySelector(".bar");
this.$brakeValue = this.$brake.querySelector(".value");
this.$throttle = this.$telemetry.querySelector("#throttle");
this.$throttleBar = this.$throttle.querySelector(".bar");
this.$throttleValue = this.$throttle.querySelector(".value");
this.$steering = this.$telemetry.querySelector("#steering");
this.$steeringIndicator = this.$steering.querySelector(".indicator");
this.$wizard = document.querySelector("#wizard");
this.$wizardPreview = this.$wizard.querySelector("#wizard-preview");
this.$wizardInstructions = this.$wizard.querySelector(
"#wizard-instructions",
);
}
/**
* Loads the params from the URL
*/
loadParams() {
this.withChart = gamepad.getUrlParam("chart") !== "false";
this.chartColors = {
clutch: "#2D64B9",
brake: "#A52725",
throttle: "#0CA818",
};
this.historyLength = +(gamepad.getUrlParam("history") || 5000);
this.withMeters = gamepad.getUrlParam("meters") !== "false";
this.withSteering = gamepad.getUrlParam("steeringIndex") !== null;
this.angle = gamepad.getUrlParam("angle") || 360;
this.frequency = gamepad.getUrlParam("fps") || 60;
for (const axis of this.AXES) {
this[axis] = {
type: (gamepad.getUrlParam(`${axis}Type`) || "axis").replace(
"axis",
"axe",
),
index: gamepad.getUrlParam(`${axis}Index`),
min: gamepad.getUrlParam(`${axis}Min`) || -1,
max: gamepad.getUrlParam(`${axis}Max`) || 1,
};
}
}
/**
* Sets up the template
*/
setupTemplate() {
if (!this.withChart) this.$chart.remove();
if (!this.clutch.index) this.$clutch.remove();
if (!this.brake.index) this.$brake.remove();
if (!this.throttle.index) this.$throttle.remove();
if (!this.withMeters) this.$meters.remove();
if (!this.steering.index) {
this.$steering.remove();
} else {
this.$telemetry.classList.add("with-steering");
}
}
/**
* Set the canvas size to the actual pixel size with pixel ratio * 2
*/
scaleChart() {
const { width, height } = this.$chart.getBoundingClientRect();
const pixelRatio = window.devicePixelRatio;
this.$chart.width = width * pixelRatio * 2;
this.$chart.height = height * pixelRatio * 2;
this.chartContext.scale(pixelRatio, pixelRatio);
this.chartContext.lineWidth = pixelRatio * 4;
this.$chart.style.width = `${width}px`;
this.$chart.style.height = `${height}px`;
}
/**
* Draws the live chart with the initial data and starts the draw update loop
*
* @param {boolean} [demo=false]
*/
setupChart(demo = false) {
if (!this.withChart) return;
this.scaleChart();
const now = Date.now();
this.chartData = [];
for (
let index = 0;
index < this.historyLength / this.interval;
index++
) {
const timestamp =
now - (this.historyLength - index * this.interval);
const data = demo
? this.getDemoData()
: { clutch: 0, brake: 0, throttle: 0 };
this.chartData.push({ timestamp, ...data });
}
}
/**
* Initializes the live chart
*/
init() {
this.interval = 1000 / this.frequency;
this.setupTemplate();
this.setupChart();
this.running = true;
this.update();
}
/**
* Updates the live chart and the meters
*/
update() {
if (!this.running) return;
const gamepad = this.gamepad.getActive();
const [clutch, brake, throttle, steering] = this.AXES.map((axis) =>
this.toAxisValue(gamepad, axis),
);
if (this.withChart) this.drawChart(clutch, brake, throttle);
if (this.withMeters) this.updateMeters(clutch, brake, throttle);
if (this.withSteering) this.updateSteering(steering);
window.setTimeout(() => this.update(), this.interval);
}
/**
* @todo: produce realistic data
* Generate alternative wave of demo data from 0 to 100 for throttle, brake and clutch
*
* @returns {object}
*/
getDemoData() {
const clutch = this.withClutch
? Math.max(
0,
Math.round(
Math.sin(this.demoIndex / (1000 / this.interval)) * 100,
),
)
: 0;
const brake = this.withBrake
? Math.max(
0,
Math.round(
Math.sin(this.demoIndex / (1000 / this.interval) + 1.5) *
100,
),
)
: 0;
const throttle = this.withThrottle
? Math.max(
0,
Math.round(
Math.sin(this.demoIndex / (1000 / this.interval) + 3) *
100,
),
)
: 0;
const steering = this.withSteering
? Math.round(
Math.sin(this.demoIndex / (1000 / this.interval) + 3) *
this.angle,
)
: 0;
if (this.demoIndex++ > 10000) this.demoIndex = 0;
return { clutch, brake, throttle, steering };
}
/**
* Updates the live chart and the meters for the demo mode
*/
updateDemo() {
const { clutch, brake, throttle, steering } = this.getDemoData();
if (this.withChart) this.drawChart(clutch, brake, throttle);
if (this.withMeters) this.updateMeters(clutch, brake, throttle);
if (this.withSteering) this.updateSteering(steering);
window.setTimeout(() => this.updateDemo(), this.interval);
}
/**
* Updates the data used to draw the cart
*
* @param {number} now
* @param {number} clutch
* @param {number} brake
* @param {number} throttle
*/
updateChartData(now, clutch, brake, throttle) {
const threshold = now - this.historyLength;
let remove = 0;
while (this.chartData[remove].timestamp <= threshold) {
remove++;
}
this.chartData = this.chartData.slice(remove);
this.chartData.push({ timestamp: now, clutch, brake, throttle });
}
/**
* Draws the whole chart wuth the latest data
*
* @param {number} clutch
* @param {number} brake
* @param {number} throttle
*/
drawChart(clutch, brake, throttle) {
const now = Date.now();
this.updateChartData(now, clutch, brake, throttle);
const chartContext = this.chartContext;
const { width, height } = this.$chart;
chartContext.clearRect(0, 0, width, height);
for (const axis of this.AXES) {
if (axis === "steering") continue;
chartContext.beginPath();
for (let index = 0; index < this.chartData.length; index++) {
const entry = this.chartData[index];
const x =
((entry.timestamp + this.historyLength - now) /
this.historyLength) *
width;
const y = ((101 - (entry[axis] || 0)) * height) / 100;
chartContext[index === 0 ? "moveTo" : "lineTo"](x, y);
}
chartContext.strokeStyle = this.chartColors[axis];
chartContext.stroke();
}
}
/**
* Updates the meters with the latest data
*
* @param {number} clutch
* @param {number} brake
* @param {number} throttle
* @param {number} steering
*/
updateMeters(clutch, brake, throttle) {
for (const [axis, value] of Object.entries({
clutch,
brake,
throttle,
})) {
if (value === null) continue;
this[`$${axis}Value`].innerHTML = value;
this[`$${axis}Value`].style.opacity =
`${Math.round(33 + value / 1.5)}%`;
this[`$${axis}Bar`].style.height = `${value}%`;
}
}
/**
* Updates the steering indicator with the latest data
*
* @param {number} steering
*/
updateSteering(steering) {
this.$steeringIndicator.style.transform = `rotate(${steering}deg)`;
}
/**
* Set ups the wizard with the widget demo mode
*/
setupWizard() {
this.interval = 1000 / 60;
this.$wizardInstructions
.querySelector("#wizard-preview")
.appendChild(this.$telemetry);
this.withChart = true;
this.withMeters = true;
this.withClutch = true;
this.withBrake = true;
this.withThrottle = true;
this.withSteering = true;
this.angle = 360;
this.demoIndex = 0;
this.setupChart(true);
this.updateDemo();
const $inputs = {
$clutchOption:
this.$wizardInstructions.querySelector("#clutch-option"),
$brakeOption:
this.$wizardInstructions.querySelector("#brake-option"),
$throttleOption:
this.$wizardInstructions.querySelector("#throttle-option"),
$steeringOption:
this.$wizardInstructions.querySelector("#steering-option"),
};
const $chartOption =
this.$wizardInstructions.querySelector("#chart-option");
const $historyOption =
this.$wizardInstructions.querySelector("#history-option");
const $metersOption =
this.$wizardInstructions.querySelector("#meters-option");
const $steeringAngleOption = this.$wizardInstructions.querySelector(
"#steering-angle-option",
);
const $fpsOption =
this.$wizardInstructions.querySelector("[name=fps-option]");
$inputs.$clutchOption.addEventListener("change", () => {
this.withClutch = $inputs.$clutchOption.checked;
if (this.withClutch) {
this.$clutch.style.display = "";
} else {
this.$clutch.style.display = "none";
for (const data of this.chartData) {
data.clutch = 0;
}
}
});
$inputs.$brakeOption.addEventListener("change", () => {
this.withBrake = $inputs.$brakeOption.checked;
if (this.withBrake) {
this.$brake.style.display = "";
} else {
this.$brake.style.display = "none";
for (const data of this.chartData) {
data.brake = 0;
}
}
});
$inputs.$throttleOption.addEventListener("change", () => {
this.withThrottle = $inputs.$throttleOption.checked;
if (this.withThrottle) {
this.$throttle.style.display = "";
} else {
this.$throttle.style.display = "none";
for (const data of this.chartData) {
data.throttle = 0;
}
}
});
$inputs.$steeringOption.addEventListener("change", () => {
this.withSteering = $inputs.$steeringOption.checked;
if (this.withSteering) {
this.$steering.style.display = "";
} else {
this.$steering.style.display = "none";
}
});
$chartOption.addEventListener("change", () => {
this.withChart = $chartOption.checked;
if (this.withChart) {
this.$chart.style.display = "";
} else {
this.$chart.style.display = "none";
}
this.setupChart(true);
});
$historyOption.addEventListener("change", () => {
this.historyLength = Number.parseInt($historyOption.value) * 1000;
this.setupChart(true);
});
$metersOption.addEventListener("change", () => {
this.withMeters = $metersOption.checked;
if (this.withMeters) {
this.$meters.style.display = "";
} else {
this.$meters.style.display = "none";
}
this.setupChart(true);
});
$steeringAngleOption.addEventListener("change", () => {
this.angle = $steeringAngleOption.value;
});
$fpsOption.addEventListener("change", () => {
this.interval = 1000 / Number.parseInt($fpsOption.value);
console.log(this.interval);
this.setupChart(true);
});
return {
$inputs,
$chartOption,
$historyOption,
$metersOption,
$steeringAngleOption,
$fpsOption,
};
}
/**
* Waits for one or all buttons of a gamepad to be released
*
* @param {number} [index]
* @returns {Promise}
*/
async waitButtonRelease(index = undefined) {
return new Promise((resolve) => {
const interval = window.setInterval(() => {
const gamepad = this.gamepad.getActive();
const pressedButton =
index !== undefined
? gamepad.buttons[index].pressed
: gamepad.buttons.some((button) => button.pressed);
if (pressedButton) return;
window.clearInterval(interval);
resolve();
}, 100);
});
}
/**
* Waits for a button to be pressed, then released
*
* @returns {Promise}
*/
async waitButtonClick() {
return new Promise((resolve) => {
const pressInterval = window.setInterval(() => {
const gamepad = this.gamepad.getActive();
const index = gamepad.buttons.findIndex(
(button) => button.pressed,
);
if (index === -1) return;
window.clearInterval(pressInterval);
const releaseInterval = window.setInterval(() => {
const gamepad = this.gamepad.getActive();
if (gamepad.buttons[index].pressed) return;
window.clearInterval(releaseInterval);
resolve(index);
}, 100);
}, 100);
});
}
/**
* Waits for an axis to be pushed out, and of the reference value, if any
*
* @param {number} index
* @param {number|undefined} [referenceValue = undefined]
* @param {number} [duration = 1000]
* @returns {Promise}
*/
async getAxisPush(index, referenceValue = undefined, duration = 1000) {
let start;
let value;
return new Promise((resolve) => {
const interval = window.setInterval(() => {
const gamepad = this.gamepad.getActive();
if (start === undefined) {
start = Date.now();
value = gamepad.axes[index];
return;
}
if (
referenceValue !== undefined &&
Math.abs(gamepad.axes[index] - referenceValue) < 0.5
) {
return;
}
if (Math.abs(gamepad.axes[index] - value) > 0.05) {
start = undefined;
value = undefined;
return;
}
if (Date.now() - start < duration) {
value = gamepad.axes[index];
return;
}
window.clearInterval(interval);
resolve(value);
}, 100);
});
}
/**
* Capitalizes a word
*
* @param {string} word
* @returns {string}
*/
capitalize(word) {
return `${word.charAt(0).toUpperCase()}${word.slice(1)}`;
}
/**
* Asks the user for the telemetry options
*
* @returns {Promise}
*/
async askForOptions() {
this.$wizardInstructions.innerHTML = `
<h4>Preview</h4>
<div id="wizard-preview"></div>
<h4>Inputs</h4>
<div class="wizard-options">
<div>
<input id="clutch-option" name="clutch-option" type="checkbox" checked>
<label for="clutch-option">Clutch</label>
</div>
<div>
<input id="brake-option" name="brake-option" type="checkbox" readonly checked>
<label for="brake-option">Brake</label>
</div>
<div>
<input id="throttle-option" name="throttle-option" type="checkbox" checked>
<label for="throttle-option">Throttle</label>
</div>
<div>
<input id="steering-option" name="steering-option" type="checkbox" checked>
<label for="steering-option">Steering</label>
</div>
</div>
<h4>Chart widget</h4>
<div class="wizard-options">
<div>
<input id="chart-option" name="chart-option" type="checkbox" checked>
<label for="chart-option">Enable</label>
</div>
<div>
<label for="history-option">History</label>
<input id="history-option" name="history-option" type="number" min="1" max="30" step="1" value="5">s
</div>
</div>
<h4>Meters widget</h4>
<div class="wizard-options">
<div>
<input id="meters-option" name="meters-option" type="checkbox" checked>
<label for="meters-option">Enable</label>
</div>
</div>
<h4>Steering widget</h4>
<div class="wizard-options">
<div>
<label for="steering-angle-option">Angle</label>
<input id="steering-angle-option" name="steering-angle-option" type="number" min="180" max="1080" step="10" value="360">°
</div>
</div>
<h4>Display mode</h4><div class="wizard-options">
<div>
<label for="fps-option">Mode</label>
<select id="fps-option" name="fps-option">
<option value="30">30 FPS (performance)</option>
<option value="60" selected>60 FPS (quality)</option>
</select>
</div>
</div>
<p>Then, press any button to continue.</p>
`;
const {
$inputs,
$chartOption,
$historyOption,
$metersOption,
$steeringAngleOption,
$fpsOption,
} = this.setupWizard();
await this.waitButtonRelease();
await this.waitButtonClick();
return {
...this.AXES.reduce(
(options, axis) =>
Object.assign(options, {
[`with${this.capitalize(axis)}`]:
$inputs[`$${axis}Option`].checked,
}),
{},
),
chart: $chartOption.checked,
history: Number.parseInt($historyOption.value) * 1000,
meters: $metersOption.checked,
angle: $steeringAngleOption.value,
fps: Number.parseInt($fpsOption.value),
};
}
/**
* Detects activity on any button or axis of the gamepad
*
* @param {string} [type='button'|'axis'|undefined]
* @param {number} [distance=0.5]
* @returns {Promise}
*/
async detectActivity(type = undefined, distance = 0.5) {
const before = this.gamepad.getActive();
return new Promise((resolve) => {
const interval = window.setInterval(async () => {
const gamepad = this.gamepad.getActive();
const buttonIndex = ["button", undefined].includes(type)
? gamepad.buttons.findIndex(
(button, index) =>
button.pressed &&
Math.abs(
button.value - before.buttons[index].value,
) > distance,
)
: -1;
const axisIndex = ["axis", undefined].includes(type)
? gamepad.axes.findIndex(
(axis, index) =>
Math.abs(axis - before.axes[index]) > distance,
)
: -1;
if (buttonIndex === -1 && axisIndex === -1) return;
window.clearInterval(interval);
resolve({
type: buttonIndex === -1 ? "axis" : "button",
index: buttonIndex === -1 ? axisIndex : buttonIndex,
});
}, 100);
});
}
/**
* Calibrates a pedal
*
* @param {string} name
* @returns {Promise}
*/
async calibratePedal(name) {
this.$wizardInstructions.innerHTML = `
<p>Waiting for <strong>${name}</strong> activity.</p>
`;
const { type, index } = await this.detectActivity();
if (type === "button") {
this.$wizardInstructions.innerHTML = `
<p>Release the <strong>${name}</strong> button.</p>
`;
await this.waitButtonRelease(index);
return { type, index, releasedValue: 0, pressedValue: 1 };
}
this.$wizardInstructions.innerHTML = `
<p>Hold down the <strong>${name}</strong> axis.</p>
`;
const pressedValue = await this.getAxisPush(index);
this.$wizardInstructions.innerHTML = `
<p>Release the <strong>${name}</strong> axis.</p>
`;
const releasedValue = await this.getAxisPush(index, pressedValue);
return { type, index, releasedValue, pressedValue };
}
/**
* Calibrates the steering axis
*
* @returns {Promise}
*/
async calibrateSteering() {
this.$wizardInstructions.innerHTML = `
<p>Turn the <strong>steering</strong> axis all the way to the <strong>left</strong>.</p>
`;
const { index } = await this.detectActivity("axis", 0.2);
const leftValue = await this.getAxisPush(index, 0);
this.$wizardInstructions.innerHTML = `
<p>Turn the <strong>steering</strong> axis all the way to the <strong>right</strong>.</p>
`;
const rightValue = await this.getAxisPush(index, leftValue);
return { index, leftValue, rightValue };
}
/**
* Converts an object to a query string, ignoring empty values
*
* @param {Object} options
* @returns {string}
*/
toOptionsParams(options) {
return Object.entries(options)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}=${value}`)
.join("&");
}
/**
* Generates the query string for a pedal
*
* @param {string} name
* @param {Object} data
* @returns {string}
*/
toPedalParams(name, { type, index, releasedValue, pressedValue }) {
return `${name}Type=${type}&${name}Index=${index}&${name}Min=${releasedValue}&${name}Max=${pressedValue}`;
}
/**
* Generates the query string for the steering
*
* @param {Object} data
* @returns {string}
*/
toSteeringParams({ index, leftValue, rightValue }) {
return `steeringIndex=${index}&steeringMin=${leftValue}&steeringMax=${rightValue}`;
}
/**
* Starts the wizard
*
* @returns {Promise}
*/
async wizard() {
this.$wizard.classList.add("active");
const gamepad = this.gamepad.getActive();
const {
withClutch,
withBrake,
withThrottle,
withSteering,
chart,
history,
meters,
angle,
fps,
} = await this.askForOptions();
const clutch = withClutch && (await this.calibratePedal("clutch"));
const brake = withBrake && (await this.calibratePedal("brake"));
const throttle =
withThrottle && (await this.calibratePedal("throttle"));
const steering = withSteering && (await this.calibrateSteering());
window.location.href = [
`?gamepad=${gamepad.id}&type=telemetry`,
this.toOptionsParams({ chart, history, meters, angle, fps }),
withClutch ? this.toPedalParams("clutch", clutch) : null,
withBrake ? this.toPedalParams("brake", brake) : null,
withThrottle ? this.toPedalParams("throttle", throttle) : null,
withSteering ? this.toSteeringParams(steering) : null,
]
.filter((e) => e !== null)
.join("&");
}
};