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 = `
Then, press any button to continue.
`; 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 = `Waiting for ${name} activity.
`; const { type, index } = await this.detectActivity(); if (type === "button") { this.$wizardInstructions.innerHTML = `Release the ${name} button.
`; await this.waitButtonRelease(index); return { type, index, releasedValue: 0, pressedValue: 1 }; } this.$wizardInstructions.innerHTML = `Hold down the ${name} axis.
`; const pressedValue = await this.getAxisPush(index); this.$wizardInstructions.innerHTML = `Release the ${name} axis.
`; const releasedValue = await this.getAxisPush(index, pressedValue); return { type, index, releasedValue, pressedValue }; } /** * Calibrates the steering axis * * @returns {Promise} */ async calibrateSteering() { this.$wizardInstructions.innerHTML = `Turn the steering axis all the way to the left.
`; const { index } = await this.detectActivity("axis", 0.2); const leftValue = await this.getAxisPush(index, 0); this.$wizardInstructions.innerHTML = `Turn the steering axis all the way to the right.
`; 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("&"); } };