'use strict'; import { draw_stick_dial } from '../stick-renderer.js'; import { dec2hex32, float_to_str, la } from '../utils.js'; import { auto_calibrate_stick_centers } from './calib-center-modal.js'; import { calibrate_range } from './calib-range-modal.js'; const FINETUNE_INPUT_SUFFIXES = ["LL", "LT", "RL", "RT", "LR", "LB", "RR", "RB", "LX", "LY", "RX", "RY"]; const LEFT_AND_RIGHT = ['left', 'right']; // Configuration for stick-specific operations const STICK_CONFIG = { left: { suffixes: ['LL', 'LT', 'LR', 'LB'], axisX: 'LX', axisY: 'LY', circDataName: 'll_data', canvasName: 'finetuneStickCanvasL' }, right: { suffixes: ['RL', 'RT', 'RR', 'RB'], axisX: 'RX', axisY: 'RY', circDataName: 'rr_data', canvasName: 'finetuneStickCanvasR' } }; // Event listener configurations const EVENT_CONFIGS = [ // Mode toggles { selector: '#finetuneModeCenter', event: 'change', handler: (instance, e) => e.target.checked && instance.setMode('center') }, { selector: '#finetuneModeCircularity', event: 'change', handler: (instance, e) => e.target.checked && instance.setMode('circularity') }, // General controls { selector: '#showRawNumbersCheckbox', event: 'change', handler: (instance) => instance._showRawNumbersChanged() }, { selector: '#learn-more-link', event: 'click', handler: (instance, e) => { e.preventDefault(); $('#learn-more-link').hide(); $('#learn-more-text').show(); } }, { selector: '.dropdown-item[data-step]', event: 'click', handler: (instance, e) => { e.preventDefault(); instance.stepSize = parseInt($(e.target).data('step')); } }, // Modal events { selector: '#finetuneModal', event: 'hidden.bs.modal', handler: (instance) => instance._onModalHidden() } ]; /** * DS5 Finetuning Class * Handles controller stick calibration and fine-tuning operations */ export class Finetune { constructor() { this._mode = 'center'; // 'center' or 'circularity' this.original_data = []; this.active_stick = null; // 'left', 'right', or null this._centerStepSize = 5; // Default step size for center mode this._circularityStepSize = 5; // Default step size for circularity mode this.isQuickCalibrating = false; // Prevents dialog destruction during quick calibration // Dependencies this.controller = null; this.ll_data = null; this.rr_data = null; this.clearCircularity = null; this.doneCallback = null; // Closure functions this.refresh_finetune_sticks = this._createRefreshSticksThrottled(); this.update_finetune_warning_messages = this._createUpdateWarningMessagesClosure(); this.flash_finetune_warning = this._createFlashWarningClosure(); // Continuous adjustment state this.continuous_adjustment = { initial_delay: null, repeat_delay: null, }; // Track previous slider values for incremental adjustments this._previousSliderValues = { left: 0, right: 0 }; // Store the values of the input fields when slider adjustment starts this._inputStartValuesForSlider = { left: null, right: null }; // Track slider usage state for undo functionality this._sliderUsed = { left: false, right: false }; // Track previous axis values for stopping continuous adjustment this._previousAxisValues = { left: { x: 0, y: 0 }, right: { x: 0, y: 0 } }; // Binary search state for R2/L2 circularity calibration this.binarySearch = { active: false, minValue: 0, maxValue: 65535, lastAdjustedValue: 0, inputSuffix: null, lastAxisValue: 0, targetAxisMin: 0.99, searchIterations: 0, maxIterations: 20 }; } get mode() { return this._mode; } set mode(mode) { if (mode !== 'center' && mode !== 'circularity') { throw new Error(`Invalid finetune mode: ${mode}. Must be 'center' or 'circularity'`); } this._mode = mode; this._updateUI(); } get stepSize() { return this._mode === 'center' ? this._centerStepSize : this._circularityStepSize; } set stepSize(size) { if (this._mode === 'center') { this._centerStepSize = size; } else { this._circularityStepSize = size; } this._updateStepSizeUI(); this._saveStepSizeToLocalStorage(); } async init(controllerInstance, { ll_data, rr_data, clear_circularity }, doneCallback = null) { la("finetune_modal_open"); this.controller = controllerInstance; this.ll_data = ll_data; this.rr_data = rr_data; this.clearCircularity = clear_circularity; this.doneCallback = doneCallback; this._initEventListeners(); this._restoreShowRawNumbersCheckbox(); this._restoreStepSizeFromLocalStorage(); // Lock NVS before const nv = await this.controller.queryNvStatus(); if(!nv.locked) { const res = await this.controller.nvsLock(); if(!res.ok) { return; } const nv2 = await this.controller.queryNvStatus(); if(!nv2.locked) { const errTxt = "0x" + dec2hex32(nv2.raw); throw new Error("ERROR: Cannot lock NVS (" + errTxt + ")"); } } else if(nv.status !== 'locked') { throw new Error("ERROR: Cannot read NVS status. Finetuning is not safe on this device."); } const data = await this._readFinetuneData(); const modal = new bootstrap.Modal(document.getElementById('finetuneModal'), {}) modal.show(); this._initializeFinetuneInputs(data); // Start in center mode this.setMode('center'); this.setStickToFinetune('left'); // Initialize the raw numbers display state this._showRawNumbersChanged(); this.original_data = data; // Update error slack button states this._updateErrorSlackButtonStates(); // Reset the Learn More link $('#learn-more-link').show(); $('#learn-more-text').hide(); this.refresh_finetune_sticks(); } /** * Initialize event listeners for the finetune modal */ _initEventListeners() { // Initialize finetune input listeners FINETUNE_INPUT_SUFFIXES.forEach((suffix) => { $("#finetune" + suffix).on('change', () => this._onFinetuneChange()); }); // Initialize general event listeners EVENT_CONFIGS.forEach(config => { $(config.selector).on(config.event, (e) => config.handler(this, e)); }); // Initialize stick-specific event listeners this._initStickEventListeners(); } /** * Initialize stick-specific event listeners (left and right) */ _initStickEventListeners() { LEFT_AND_RIGHT.forEach(lOrR => { $(`#${lOrR}-stick-card`).on('click', () => { this.setStickToFinetune(lOrR); }); this._initSliderListeners(lOrR); this._initButtonListeners(lOrR); this._initKeyboardListeners(lOrR); }); } /** * Initialize slider event listeners for a specific stick */ _initSliderListeners(lOrR) { const sliderId = `#${lOrR}CircularitySlider`; $(sliderId).on('input', (e) => { this._onCircularitySliderChange(lOrR, parseInt(e.target.value)); }); $(sliderId).on('mousedown touchstart', (e) => { this._onCircularitySliderStart(lOrR, parseInt(e.target.value)); }); $(sliderId).on('change', (e) => { this._onCircularitySliderRelease(lOrR); }); } /** * Initialize button event listeners for a specific stick */ _initButtonListeners(lOrR) { // Reset button $(`#${lOrR}CircularityResetBtn`).on('click', () => { this._resetCircularitySlider(lOrR); }); // Error slack button $(`#${lOrR}ErrorSlackBtn`).on('click', () => { this._onErrorSlackButtonClick(lOrR); }); // Error slack undo button $(`#${lOrR}ErrorSlackUndoBtn`).on('click', () => { this._onErrorSlackUndoButtonClick(lOrR); }); } /** * Initialize keyboard event listeners for a specific stick card */ _initKeyboardListeners(lOrR) { const stickCard = $(`#${lOrR}-stick-card`); stickCard.on('keydown', (e) => { this._onKeyboardEvent(e, true); }); stickCard.on('keyup', (e) => { this._onKeyboardEvent(e, false); }); // Make stick cards focusable stickCard.attr('tabindex', '0'); } /** * Clean up event listeners for the finetune modal */ removeEventListeners() { // Remove finetune input listeners FINETUNE_INPUT_SUFFIXES.forEach((suffix) => { $("#finetune" + suffix).off('change'); }); // Remove general event listeners EVENT_CONFIGS.forEach(config => { $(config.selector).off(config.event); }); // Remove stick-specific event listeners this._removeStickEventListeners(); } /** * Remove stick-specific event listeners */ _removeStickEventListeners() { LEFT_AND_RIGHT.forEach(lOrR => { // Remove stick card listeners $(`#${lOrR}-stick-card`).off('click keydown keyup'); // Remove slider listeners const sliderId = `#${lOrR}CircularitySlider`; $(sliderId).off('input mousedown touchstart change'); // Remove button listeners $(`#${lOrR}CircularityResetBtn`).off('click'); $(`#${lOrR}ErrorSlackBtn`).off('click'); $(`#${lOrR}ErrorSlackUndoBtn`).off('click'); }); } /** * Handle modal hidden event */ _onModalHidden() { console.log("Finetune modal hidden event triggered"); // Don't destroy the instance if quick calibration is in progress if (this.isQuickCalibrating) { console.log("Quick calibration in progress, preventing dialog destruction"); return; } // Reset circularity sliders to zero when modal closes LEFT_AND_RIGHT.forEach(lOrR => { $(`#${lOrR}CircularitySlider`).val(0); this._sliderUsed[lOrR] = false; }); destroyCurrentInstance(); } /** * Handle mode switching based on controller input */ handleModeSwitching(changes) { if (changes.l1) { this.setMode('center'); this._clearFinetuneAxisHighlights(); } else if (changes.r1) { this.setMode('circularity'); this._clearFinetuneAxisHighlights(); } } /** * Handle stick switching based on controller input */ handleStickSwitching(changes) { if (changes.sticks) { this._updateActiveStickBasedOnMovement(); } } /** * Handle D-pad adjustments for finetuning */ handleDpadAdjustment(changes) { if(!this.active_stick) return; if (this._mode === 'center') { this._handleCenterModeAdjustment(changes); } else { this._handleCircularityModeAdjustment(changes); } } /** * Handle keyboard events for arrow key adjustments * Arrow keys work like D-pad buttons for fine-tuning */ _onKeyboardEvent(event, isKeyDown) { const key = event.key; // Map arrow keys to button names (D-pad) const keyToButtonMap = { 'ArrowLeft': 'left', 'ArrowRight': 'right', 'ArrowUp': 'up', 'ArrowDown': 'down' }; const button = keyToButtonMap[key]; if (!button) return; event.preventDefault(); // Arrow keys work as D-pad buttons for adjustments if (!this.active_stick) return; const changes = {}; if (isKeyDown) { // Simulate button press by creating a change object changes[button] = true; this.handleDpadAdjustment(changes); } else { // Simulate button release changes[button] = false; this.handleDpadAdjustment(changes); } } /* Set the quick calibrating state to prevent dialog destruction * @param {boolean} isCalibrating - Whether quick calibration is in progress */ setQuickCalibrating(isCalibrating) { this.isQuickCalibrating = isCalibrating; const finetuneModal = bootstrap.Modal.getInstance('#finetuneModal'); finetuneModal.toggle(!isCalibrating); if(!isCalibrating) { this.clearCircularity(); // Refresh the finetune data after calibration this._readFinetuneData().then((data) => { this._initializeFinetuneInputs(data); this.refresh_finetune_sticks(); console.log('Finetune modal refreshed'); }); } } /** * Save finetune changes */ save() { // Unlock save button this.controller.setHasChangesToWrite(true); this._close(true); } /** * Cancel finetune changes and restore original data */ async cancel() { if(this.original_data.length == 12) await this._writeFinetuneData(this.original_data) this._close(false); } /** * Set the finetune mode */ setMode(mode) { this._mode = mode; this._updateUI(); // Reset toggle states when switching modes if (mode === 'center') { LEFT_AND_RIGHT.forEach(lOrR => { $(`#${lOrR}-stick-card`).removeClass('show-slider'); this._sliderUsed[lOrR] = false; this._showErrorSlackButton(lOrR); }); } } /** * Set which stick to finetune */ setStickToFinetune(lOrR) { if(this.active_stick === lOrR) { return; } // Stop any continuous adjustments when switching sticks this.stopContinuousDpadAdjustment(); this._clearFinetuneAxisHighlights(); // Hide slider on the previously active stick (when it becomes inactive) if (this.active_stick && this._mode === 'circularity') { const previousStickCard = $(`#${this.active_stick}-stick-card`); previousStickCard.removeClass('show-slider'); } this.active_stick = lOrR; const other_stick = lOrR === 'left' ? 'right' : 'left'; $(`#${this.active_stick}-stick-card`).addClass("stick-card-active"); $(`#${other_stick}-stick-card`).removeClass("stick-card-active"); } // Private methods /** * Restore the show raw numbers checkbox state from localStorage */ _restoreShowRawNumbersCheckbox() { const savedState = localStorage.getItem('showRawNumbersCheckbox'); if (savedState) { const isChecked = savedState === 'true'; $("#showRawNumbersCheckbox").prop('checked', isChecked); } } /** * Initialize finetune input fields with data and max values * @param {Array} data - Array of finetune values */ _initializeFinetuneInputs(data) { const maxValue = this.controller.getFinetuneMaxValue(); FINETUNE_INPUT_SUFFIXES.forEach((suffix, i) => { $("#finetune" + suffix) .attr('max', maxValue) .val(data[i]); }); } /** * Check if stick is in extreme position (close to edges) * @param {Object} stick - Stick object with x and y properties * @returns {boolean} True if stick is in extreme position */ _isStickInExtremePosition(stick) { const primeAxis = Math.max(Math.abs(stick.x), Math.abs(stick.y)); const otherAxis = Math.min(Math.abs(stick.x), Math.abs(stick.y)); return primeAxis >= 0.5 && otherAxis < 0.2; } _updateUI() { // Clear circularity data - we'll call this from core.js this.clearCircularity(); const modal = $('#finetuneModal'); if (this._mode === 'center') { $("#finetuneModeCenter").prop('checked', true); modal.removeClass('circularity-mode'); } else if (this._mode === 'circularity') { $("#finetuneModeCircularity").prop('checked', true); modal.addClass('circularity-mode'); } // Update step size UI when mode changes this._updateStepSizeUI(); // Update error slack button states when mode changes this._updateErrorSlackButtonStates(); } async _onFinetuneChange() { const out = FINETUNE_INPUT_SUFFIXES.map((suffix) => { const el = $("#finetune" + suffix); const v = parseInt(el.val()); return isNaN(v) ? 0 : v; }); await this._writeFinetuneData(out); } async _readFinetuneData() { const data = await this.controller.getInMemoryModuleData(); if(!data) { throw new Error("ERROR: Cannot read calibration data"); } return data; } async _writeFinetuneData(data) { if (data.length != 12) { return; } if (this.controller.isConnected()) { await this.controller.writeFinetuneData(data); } } _createRefreshSticksThrottled() { let timeout = null; return () => { if (timeout) return; timeout = setTimeout(() => { const sticks = this.controller.button_states.sticks; // Update both stick displays using configuration Object.entries(STICK_CONFIG).forEach(([stick, config]) => { const stickData = sticks[stick]; this._ds5FinetuneUpdate(config.canvasName, stickData.x, stickData.y); }); this.update_finetune_warning_messages(); this._highlightActiveFinetuneAxis(); this._updateErrorSlackButtonStates(); timeout = null; }, 10); }; } _createUpdateWarningMessagesClosure() { let timeout = null; // to prevent unnecessary flicker return () => { if(!this.active_stick) return; const currentStick = this.controller.button_states.sticks[this.active_stick]; if (this._mode === 'center') { const isNearCenter = Math.abs(currentStick.x) <= 0.5 && Math.abs(currentStick.y) <= 0.5; if(!isNearCenter && timeout) return; clearTimeout(timeout); timeout = setTimeout(() => { timeout = null; if(this._mode !== 'center') return; // in case it changed during timeout $('#finetuneCenterSuccess').toggle(isNearCenter); $('#finetuneCenterWarning').toggle(!isNearCenter); }, isNearCenter ? 0 : 200); } if (this._mode === 'circularity') { const isInExtremePosition = this._isStickInExtremePosition(currentStick); if(!isInExtremePosition && timeout) return; clearTimeout(timeout); timeout = setTimeout(() => { timeout = null; if(this._mode !== 'circularity') return; // in case it changed during timeout // Check if stick is in extreme position (close to edges) $('#finetuneCircularitySuccess').toggle(isInExtremePosition); $('#finetuneCircularityWarning').toggle(!isInExtremePosition); }, isInExtremePosition ? 0 : 200); } }; } _clearFinetuneAxisHighlights(to_clear = {center: true, circularity: true}) { const { center, circularity } = to_clear; if(this._mode === 'center' && center || this._mode === 'circularity' && circularity) { // Clear label highlights const labelIds = ["Lx-lbl", "Ly-lbl", "Rx-lbl", "Ry-lbl"]; labelIds.forEach(suffix => { $(`#finetuneStickCanvas${suffix}`).removeClass("text-primary"); }); } } _highlightActiveFinetuneAxis(opts = {}) { if(!this.active_stick) return; if (this._mode === 'center') { const { axis } = opts; if(!axis) return; this._clearFinetuneAxisHighlights({center: true}); const labelSuffix = `${this.active_stick === 'left' ? "L" : "R"}${axis.toLowerCase()}`; $(`#finetuneStickCanvas${labelSuffix}-lbl`).addClass("text-primary"); } else { this._clearFinetuneAxisHighlights({circularity: true}); const sticks = this.controller.button_states.sticks; const currentStick = sticks[this.active_stick]; // Only highlight if stick is moved significantly from center const deadzone = 0.5; if (Math.abs(currentStick.x) >= deadzone || Math.abs(currentStick.y) >= deadzone) { const quadrant = this._getStickQuadrant(currentStick.x, currentStick.y); const inputSuffix = this._getFinetuneInputSuffixForQuadrant(this.active_stick, quadrant); if (inputSuffix) { // Highlight the corresponding LX/LY label to observe const labelId = `finetuneStickCanvas${ this.active_stick === 'left' ? 'L' : 'R'}${ quadrant === 'left' || quadrant === 'right' ? 'x' : 'y'}-lbl`; $(`#${labelId}`).addClass("text-primary"); } } } } _ds5FinetuneUpdate(name, plx, ply) { const showRawNumbers = $("#showRawNumbersCheckbox").is(":checked"); const canvasId = `${name}${showRawNumbers ? '' : '_large'}`; const c = document.getElementById(canvasId); if (!c) { console.error(`Canvas element not found: ${canvasId}`); return; } const ctx = c.getContext("2d"); const margins = 5; const radius = c.width / 2 - margins; const sz = c.width/2 - margins; const hb = radius + margins; const yb = radius + margins; ctx.clearRect(0, 0, c.width, c.height); // Determine which stick this is using configuration const lOrR = this._getStickFromCanvasName(name); const highlight = this.active_stick === lOrR && this._isDpadAdjustmentActive(); if (this._mode === 'circularity') { // Draw stick position with circle const circularityData = lOrR === 'left' ? this.ll_data : this.rr_data; draw_stick_dial(ctx, hb, yb, sz, plx, ply, { circularity_data: circularityData, highlight }); } else { // Draw stick position with crosshair draw_stick_dial(ctx, hb, yb, sz, plx, ply, { enable_zoom_center: true, highlight }); } $("#"+ name + "x-lbl").text(float_to_str(plx, 3)); $("#"+ name + "y-lbl").text(float_to_str(ply, 3)); } /** * Get lOrR from canvas name using configuration */ _getStickFromCanvasName(canvasName) { return LEFT_AND_RIGHT.find(lOrR => STICK_CONFIG[lOrR].canvasName === canvasName ); } _showRawNumbersChanged() { const showRawNumbers = $("#showRawNumbersCheckbox").is(":checked"); const modal = $("#finetuneModal"); modal.toggleClass("hide-raw-numbers", !showRawNumbers); localStorage.setItem('showRawNumbersCheckbox', showRawNumbers); this.refresh_finetune_sticks(); } _close(success = false, message = null) { console.log("Closing finetune modal"); // Call the done callback if provided if (this.doneCallback && typeof this.doneCallback === 'function') { this.doneCallback(success, message); } $("#finetuneModal").modal("hide"); } _isStickAwayFromCenter(stick_pos, deadzone = 0.2) { return Math.abs(stick_pos.x) >= deadzone || Math.abs(stick_pos.y) >= deadzone; } _updateActiveStickBasedOnMovement() { const sticks = this.controller.button_states.sticks; const deadzone = 0.2; const left_is_away = this._isStickAwayFromCenter(sticks.left, deadzone); const right_is_away = this._isStickAwayFromCenter(sticks.right, deadzone); if (left_is_away && right_is_away) { // Both sticks are away from center - clear highlighting this._clearActiveStick(); } else if (left_is_away && !right_is_away) { // Only left stick is away from center this.setStickToFinetune('left'); } else if (right_is_away && !left_is_away) { // Only right stick is away from center this.setStickToFinetune('right'); } // If both sticks are centered, keep current active stick (no change) } _clearActiveStick() { // Remove active class from both cards $("#left-stick-card").removeClass("stick-card-active"); $("#right-stick-card").removeClass("stick-card-active"); this.active_stick = null; // Clear active stick this._clearFinetuneAxisHighlights(); } _getStickQuadrant(x, y) { // Determine which quadrant the stick is in based on x,y coordinates // x and y are normalized values between -1 and 1 if (Math.abs(x) > Math.abs(y)) { return x > 0 ? 'right' : 'left'; } else { return y > 0 ? 'down' : 'up'; } } _getFinetuneInputSuffixForQuadrant(stick, quadrant) { // This function should only be used in circularity mode // In center mode, we don't care about quadrants - use direct axis mapping instead if (this._mode === 'center') { // This function shouldn't be called in center mode console.warn('get_finetune_input_suffix_for_quadrant called in center mode - this should not happen'); return null; } // Circularity mode: map quadrants to specific calibration points using configuration const config = STICK_CONFIG[stick]; if (!config) return null; const quadrantMap = { 'left': 0, // LL, RL 'up': 1, // LT, RT 'right': 2, // LR, RR 'down': 3 // LB, RB }; const index = quadrantMap[quadrant]; return index !== undefined ? config.suffixes[index] : null; } _handleCenterModeAdjustment(changes) { const adjustmentStep = this._centerStepSize; // Use center step size for center mode // Define button mappings for center mode const buttonMappings = [ { buttons: ['left', 'square'], adjustment: adjustmentStep, axis: 'X' }, { buttons: ['right', 'circle'], adjustment: -adjustmentStep, axis: 'X' }, { buttons: ['up', 'triangle'], adjustment: adjustmentStep, axis: 'Y' }, { buttons: ['down', 'cross'], adjustment: -adjustmentStep, axis: 'Y' } ]; // Check if any relevant button was released const relevantButtons = ['left', 'right', 'square', 'circle', 'up', 'down', 'triangle', 'cross']; if (relevantButtons.some(button => changes[button] === false)) { this.stopContinuousDpadAdjustment(); return; } // Check for button presses for (const mapping of buttonMappings) { // Check if active stick is away from center (> 0.5) const sticks = this.controller.button_states.sticks; const currentStick = sticks[this.active_stick]; const stickAwayFromCenter = Math.abs(currentStick.x) > 0.5 || Math.abs(currentStick.y) > 0.5; if (stickAwayFromCenter && this._isNavigationKeyPressed()) { this.flash_finetune_warning(); return; } if (mapping.buttons.some(button => changes[button])) { this._highlightActiveFinetuneAxis({axis: mapping.axis}); this._startContinuousDpadAdjustmentCenterMode(this.active_stick, mapping.axis, mapping.adjustment); return; } } } _isNavigationKeyPressed() { const nav_buttons = ['left', 'right', 'up', 'down', 'square', 'circle', 'triangle', 'cross']; return nav_buttons.some(button => this.controller.button_states[button] === true); } _createFlashWarningClosure() { let timeout = null; return () => { function toggle() { $("#finetuneCenterWarning").toggleClass(['alert-warning', 'alert-danger']); $("#finetuneCircularityWarning").toggleClass(['alert-warning', 'alert-danger']); } if(timeout) return; toggle(); // on timeout = setTimeout(() => { toggle(); // off timeout = null; }, 300); }; } _handleCircularityModeAdjustment({sticks: _, ...changes}) { const sticks = this.controller.button_states.sticks; const currentStick = sticks[this.active_stick]; // Only adjust if stick is moved significantly from center const isInExtremePosition = this._isStickInExtremePosition(currentStick); if (!isInExtremePosition) { this.stopContinuousDpadAdjustment(); if(this._isNavigationKeyPressed()) { this.flash_finetune_warning(); } return; } const quadrant = this._getStickQuadrant(currentStick.x, currentStick.y); if (changes.r2_analog !== undefined || changes.l2_analog !== undefined) { const r2Value = this.controller.button_states.r2_analog || 0; const l2Value = this.controller.button_states.l2_analog || 0; if (r2Value === 0 && l2Value === 0) { this.binarySearch.inputSuffix = null; this.stopContinuousDpadAdjustment(); return; } if (!this.binarySearch.active && !this.binarySearch.inputSuffix && !this._isDpadAdjustmentActive()) { const inputSuffix = this._getFinetuneInputSuffixForQuadrant(this.active_stick, quadrant); if (inputSuffix) { this._startBinarySearch(inputSuffix); } } return; } // Use circularity step size for circularity mode const adjustmentStep = this._circularityStepSize; // Define button mappings for each quadrant type const horizontalButtons = ['left', 'right', 'square', 'circle']; const verticalButtons = ['up', 'down', 'triangle', 'cross']; let adjustment = 0; let relevantButtons = []; if (quadrant === 'left' || quadrant === 'right') { // Horizontal quadrants: left increases, right decreases relevantButtons = horizontalButtons; if (changes.left || changes.square) { adjustment = adjustmentStep; } else if (changes.right || changes.circle) { adjustment = -adjustmentStep; } } else if (quadrant === 'up' || quadrant === 'down') { // Vertical quadrants: up increases, down decreases relevantButtons = verticalButtons; if (changes.up || changes.triangle) { adjustment = adjustmentStep; } else if (changes.down || changes.cross) { adjustment = -adjustmentStep; } } // Check if any relevant button was released if (relevantButtons.some(button => changes[button] === false)) { this.stopContinuousDpadAdjustment(); return; } // Start continuous adjustment on button press if (adjustment !== 0) { this._startContinuousDpadAdjustment(this.active_stick, quadrant, adjustment); } } _startContinuousDpadAdjustment(stick, quadrant, adjustment) { const inputSuffix = this._getFinetuneInputSuffixForQuadrant(stick, quadrant); this._startContinuousAdjustmentWithSuffix(inputSuffix, adjustment); } _startContinuousDpadAdjustmentCenterMode(stick, targetAxis, adjustment) { // In center mode, directly map to X/Y axes using configuration const config = STICK_CONFIG[stick]; const inputSuffix = targetAxis === 'X' ? config.axisX : config.axisY; this._startContinuousAdjustmentWithSuffix(inputSuffix, adjustment); } _startContinuousAdjustmentWithSuffix(inputSuffix, adjustment) { this.stopContinuousDpadAdjustment(); const element = $(`#finetune${inputSuffix}`); if (!element.length) return; this._savePreviousStickPosition(); this._performDpadAdjustment(element, adjustment); this.clearCircularity(); // ...then prime continuous adjustment this.continuous_adjustment.initial_delay = setTimeout(() => { this.continuous_adjustment.repeat_delay = setInterval(() => { this._performDpadAdjustment(element, adjustment); this.clearCircularity(); }, 150); }, 400); // Initial delay before continuous adjustment starts (400ms) } stopContinuousDpadAdjustment() { clearInterval(this.continuous_adjustment.repeat_delay); this.continuous_adjustment.repeat_delay = null; clearTimeout(this.continuous_adjustment.initial_delay); this.continuous_adjustment.initial_delay = null; this.binarySearch.active = false; } _isDpadAdjustmentActive() { return !!this.continuous_adjustment.initial_delay; } _savePreviousStickPosition() { if (this.active_stick && this.controller.button_states.sticks) { const currentStick = this.controller.button_states.sticks[this.active_stick]; this._previousAxisValues[this.active_stick].x = currentStick.x; this._previousAxisValues[this.active_stick].y = currentStick.y; } } _startBinarySearch(inputSuffix) { const element = $(`#finetune${inputSuffix}`); if (!element.length) return; const currentValue = parseInt(element.val()) || 0; this.binarySearch = { active: true, minValue: Math.max(0, currentValue - 500), maxValue: Math.min(65535, currentValue + 500), lastAdjustedValue: currentValue, inputSuffix: inputSuffix, lastAxisValue: 0, targetAxisMin: 0.99, searchIterations: 0, maxIterations: 20 }; this._savePreviousStickPosition(); this._performBinarySearchStep(); } async _performBinarySearchStep() { if (!this.binarySearch.active || !this.binarySearch.inputSuffix) return; const element = $(`#finetune${this.binarySearch.inputSuffix}`); if (!element.length) return; const midValue = this._calculateBinarySearchMidpoint(); element.val(midValue); this.binarySearch.lastAdjustedValue = midValue; await this._onFinetuneChange(); this.clearCircularity(); await new Promise(resolve => setTimeout(resolve, 50)); const absAxis = this._calculateBinarySearchAxisValue(); this.binarySearch.lastAxisValue = absAxis; this.binarySearch.searchIterations++; if (this._isBinarySearchConverged(absAxis)) { this.binarySearch.active = false; this.stopContinuousDpadAdjustment(); return; } this._updateBinarySearchBounds(midValue, absAxis); this.continuous_adjustment.repeat_delay = setTimeout( () => this._performBinarySearchStep(), 50 ); } _calculateBinarySearchMidpoint() { const { minValue, maxValue, searchIterations, lastAdjustedValue } = this.binarySearch; return searchIterations === 0 ? lastAdjustedValue : Math.floor((minValue + maxValue) / 2); } _calculateBinarySearchAxisValue() { if (!this.binarySearch.inputSuffix || !this.active_stick) { return 0; } const currentStick = this.controller.button_states.sticks[this.active_stick]; if (!currentStick) { return 0; } const lastChar = this.binarySearch.inputSuffix.slice(-1); const axis = (['X', 'L', 'R'].includes(lastChar)) ? currentStick.x : currentStick.y; return Math.abs(axis); } _isBinarySearchConverged(absAxis) { const convergenceThreshold = 0.005; const diff = Math.abs(absAxis - this.binarySearch.targetAxisMin); const hasConverged = diff < convergenceThreshold; const maxIterationsReached = this.binarySearch.searchIterations >= this.binarySearch.maxIterations; return hasConverged || maxIterationsReached; } _updateBinarySearchBounds(midValue, absAxis) { if (!this.binarySearch.inputSuffix) return; const isInvertedDirection = this.binarySearch.inputSuffix.endsWith('R') || this.binarySearch.inputSuffix.endsWith('B'); const isAxisTooLow = absAxis < this.binarySearch.targetAxisMin; if (isAxisTooLow === isInvertedDirection) { this.binarySearch.maxValue = midValue; } else { this.binarySearch.minValue = midValue; } } async _performDpadAdjustment(element, adjustment) { const currentValue = parseInt(element.val()) || 0; const maxValue = this.controller.getFinetuneMaxValue(); const newValue = Math.max(0, Math.min(maxValue, currentValue + adjustment)); element.val(newValue); // Trigger the change event to update the finetune data await this._onFinetuneChange(); // Check if axis values have dropped from 1.00 to below 1.00 and stop adjustment if so this._checkAxisValuesForStopCondition(); } /** * Check if axis values have dropped from 1.00 to below 1.00 and stop adjustment */ _checkAxisValuesForStopCondition() { if (!this.active_stick || !this.continuous_adjustment.repeat_delay) { return; // No continuous adjustment active } const currentStick = this.controller.button_states.sticks[this.active_stick]; const previousStick = this._previousAxisValues[this.active_stick]; // Check if X axis dropped from 1.00+ to below 1.00 const xDropped = Math.abs(previousStick.x) >= 1.00 && Math.abs(currentStick.x) < 1.00; // Check if Y axis dropped from 1.00+ to below 1.00 const yDropped = Math.abs(previousStick.y) >= 1.00 && Math.abs(currentStick.y) < 1.00; if (xDropped || yDropped) { console.log(`Stopping continuous adjustment: ${this.active_stick} axis dropped below 1.00`); this.stopContinuousDpadAdjustment(); } // Update previous values for next check this._previousAxisValues[this.active_stick] = currentStick; } /** * Update the step size UI display */ _updateStepSizeUI() { const currentStepSize = this._mode === 'center' ? this._centerStepSize : this._circularityStepSize; $('#stepSizeValue').text(currentStepSize); } /** * Save step size to localStorage */ _saveStepSizeToLocalStorage() { localStorage.setItem('finetuneCenterStepSize', this._centerStepSize.toString()); localStorage.setItem('finetuneCircularityStepSize', this._circularityStepSize.toString()); } /** * Restore step size from localStorage */ _restoreStepSizeFromLocalStorage() { // Restore center step size const savedCenterStepSize = localStorage.getItem('finetuneCenterStepSize'); if (savedCenterStepSize) { this._centerStepSize = parseInt(savedCenterStepSize); } // Restore circularity step size const savedCircularityStepSize = localStorage.getItem('finetuneCircularityStepSize'); if (savedCircularityStepSize) { this._circularityStepSize = parseInt(savedCircularityStepSize); } this._updateStepSizeUI(); } /** * Handle the start of circularity slider adjustment * Store base values and reset previous slider value */ _onCircularitySliderStart(lOrR, value) { console.log(`Slider start for ${lOrR} stick, value: ${value}`); const config = STICK_CONFIG[lOrR]; const baseValues = {}; // Store the base values when slider adjustment starts config.suffixes.forEach(suffix => { const element = $(`#finetune${suffix}`); baseValues[suffix] = parseInt(element.val()) || 0; }); this._inputStartValuesForSlider[lOrR] = baseValues; this._previousSliderValues[lOrR] = value; // Store base values for circularity data arrays const circData = this[config.circDataName]; if (circData && Array.isArray(circData)) { this._inputStartValuesForSlider[lOrR][config.circDataName] = [...circData]; // Create a copy } console.log(`Base values stored for ${lOrR}:`, baseValues); } /** * Handle circularity slider changes with incremental adjustments */ _onCircularitySliderChange(lOrR, value) { // Debug: Log the data structure console.log(`Slider change for ${lOrR} stick, value: ${value}`); // If we don't have base values, treat this as the start if (!this._inputStartValuesForSlider[lOrR]) { this._onCircularitySliderStart(lOrR, value); return; } // Calculate the incremental change from the previous slider position const previousValue = this._previousSliderValues[lOrR]; const deltaValue = value - previousValue; // If no change, return early if (deltaValue === 0) { return; } // Get the start values and suffixes for the current stick const config = STICK_CONFIG[lOrR]; const startValues = this._inputStartValuesForSlider[lOrR]; // Calculate the total adjustment based on slider value from 0 // Value 0-100 maps to adjustment range (we'll use a reasonable range) const maxAdjustment = 175; // Adjust this value as needed const totalAdjustment = (value / 100) * maxAdjustment; config.suffixes.forEach(suffix => { const element = $(`#finetune${suffix}`); let newValue; if (suffix.endsWith('L') || suffix.endsWith('T')) { newValue = Math.min(65535, startValues[suffix] + totalAdjustment); } else if (suffix.endsWith('R') || suffix.endsWith('B')) { newValue = Math.max(0, startValues[suffix] - totalAdjustment); } element.val(Math.round(newValue)); }); // Update circularity data with incremental changes proportional to slider movement const adjustmentConstant = 0.00085; // Small constant for incremental adjustments const totalAdjustmentFromBase = totalAdjustment * adjustmentConstant; // Total adjustment from slider position 0 const startingData = this._inputStartValuesForSlider[lOrR][config.circDataName]; const circData = this[config.circDataName]; // Apply total adjustment from base values to maintain relative differences startingData.forEach((value, i) => circData[i] = Math.max(0, value + totalAdjustmentFromBase)); // Convert polar coordinates to cartesian, trim to square, and convert back this._trimCircularityDataToSquare(circData); // Update previous slider value this._previousSliderValues[lOrR] = value; // Refresh the stick displays to show updated circularity data this.refresh_finetune_sticks(); } /** * Handle slider release - clear circularity data * @param {string} lOrR - 'left' or 'right' */ _onCircularitySliderRelease(lOrR) { console.log(`Circularity slider released for ${lOrR} stick`); // Mark that this slider has been used this._sliderUsed[lOrR] = true; // Clear the circularity data - zero out the array while maintaining its size const config = STICK_CONFIG[lOrR]; const circData = this[config.circDataName]; circData.fill(0); // Call the clearCircularity function to update the display this.clearCircularity(lOrR); // Trigger the change event to update the finetune data once when slider is released this._onFinetuneChange(); // Toggle the slider off and change button to undo const stickCard = $(`#${lOrR}-stick-card`); stickCard.removeClass('show-slider'); this._showErrorSlackUndoButton(lOrR); // Refresh the stick displays to show cleared circularity data this.refresh_finetune_sticks(); } /** * Convert circularity data (polar radii) to cartesian coordinates, * trim to a -1,-1 to 1,1 square, then convert back to polar radii * @param {Array} data - Array of radius values representing sectors around a circle */ _trimCircularityDataToSquare(data) { const numSectors = data.length; data.forEach((radius, i) => { // Calculate angle for this sector const angle = (i * 2 * Math.PI) / numSectors; // Convert polar to cartesian coordinates const x = radius * Math.cos(angle); const y = radius * Math.sin(angle); // Trim to -1,-1 to 1,1 square const trimmedX = Math.max(-1, Math.min(1, x)); const trimmedY = Math.max(-1, Math.min(1, y)); // Convert back to polar coordinates const trimmedRadius = Math.sqrt(trimmedX * trimmedX + trimmedY * trimmedY); data[i] = trimmedRadius; }); } /** * Reset circularity slider to zero and restore input values to their base state * @param {string} lOrR - 'left' or 'right' */ _resetCircularitySlider(lOrR) { console.log(`Resetting circularity slider for ${lOrR} stick`); // If we have starting values stored, use them to reset properly // Reset the slider to zero first $(`#${lOrR}CircularitySlider`).val(0); // Trigger the slider change with value 0 to recalculate input values this._onCircularitySliderChange(lOrR, 0); // Reset the slider used state and update button back to slack this._sliderUsed[lOrR] = false; this._showErrorSlackButton(lOrR); // Clear the circularity data display this.clearCircularity(lOrR); // Trigger the change event to update the finetune data this._onFinetuneChange(); // Refresh the stick displays this.refresh_finetune_sticks(); } /** * Check if data array contains only non-zero values * @param {Array} data - The data array to check * @returns {boolean} True if all values are non-zero, false otherwise */ _hasOnlyNonZeroValues(data) { if (!data || !Array.isArray(data)) { return false; } return data.every(value => value !== 0); } /** * Update the state of error slack buttons based on data content */ _updateErrorSlackButtonStates() { Object.entries(STICK_CONFIG).forEach(([lOrR, config]) => { if (this._sliderUsed[lOrR]) { // Show undo button, hide slack button this._showErrorSlackUndoButton(lOrR); } else { // Show slack button, hide undo button this._showErrorSlackButton(lOrR); const hasData = this._hasOnlyNonZeroValues(this[config.circDataName]); const slackBtn = $(`#${lOrR}ErrorSlackBtn`); slackBtn .prop('disabled', !hasData) .toggleClass('btn-secondary', hasData) .toggleClass('btn-outline-secondary', !hasData); } }); } /** * Handle error slack button click * @param {string} lOrR - 'left' or 'right' */ _onErrorSlackButtonClick(lOrR) { console.log(`Error slack button clicked for ${lOrR} stick`); // Only allow toggle in circularity mode if (this._mode !== 'circularity') { console.log('Error slack button only works in circularity mode'); return; } // Toggle between showing LX/LY values and circularity slider const stickCard = $(`#${lOrR}-stick-card`); const isShowingSlider = stickCard.hasClass('show-slider'); stickCard.toggleClass('show-slider', !isShowingSlider); } /** * Handle error slack undo button click * @param {string} lOrR - 'left' or 'right' */ _onErrorSlackUndoButtonClick(lOrR) { console.log(`Error slack undo button clicked for ${lOrR} stick`); this._resetCircularitySlider(lOrR); } /** * Toggle button visibility between slack and undo buttons * @param {string} lOrR - 'left' or 'right' * @param {boolean} showUndo - true to show undo button, false to show slack button */ _toggleErrorSlackButtons(lOrR, showUndo) { const undoBtn = $(`#${lOrR}ErrorSlackUndoBtn`); const slackBtn = $(`#${lOrR}ErrorSlackBtn`); undoBtn.toggleClass('d-none', !showUndo); slackBtn.toggleClass('d-none', showUndo); } /** * Show undo button and hide slack button * @param {string} lOrR - 'left' or 'right' */ _showErrorSlackUndoButton(lOrR) { this._toggleErrorSlackButtons(lOrR, true); } /** * Show slack button and hide undo button * @param {string} lOrR - 'left' or 'right' */ _showErrorSlackButton(lOrR) { this._toggleErrorSlackButtons(lOrR, false); } } // Global reference to the current finetune instance let currentFinetuneInstance = null; /** * Helper function to safely clear the current finetune instance */ function destroyCurrentInstance() { if (currentFinetuneInstance) { currentFinetuneInstance.stopContinuousDpadAdjustment(); currentFinetuneInstance.removeEventListeners(); currentFinetuneInstance = null; } } // Function to create and initialize finetune instance export async function ds5_finetune(controller, dependencies, doneCallback = null) { // Create new instance currentFinetuneInstance = new Finetune(); await currentFinetuneInstance.init(controller, dependencies, doneCallback); } export function finetune_handle_controller_input(changes) { if (currentFinetuneInstance) { currentFinetuneInstance.refresh_finetune_sticks(); currentFinetuneInstance.handleModeSwitching(changes); currentFinetuneInstance.handleStickSwitching(changes); currentFinetuneInstance.handleDpadAdjustment(changes); } } function finetune_save() { console.log("Saving finetune changes"); if (currentFinetuneInstance) { currentFinetuneInstance.save(); } } async function finetune_cancel() { console.log("Cancelling finetune changes"); if (currentFinetuneInstance) { await currentFinetuneInstance.cancel(); } } export function isFinetuneVisible() { return !!currentFinetuneInstance; } // Quick calibrate functions async function finetune_quick_calibrate_center() { // Hide the finetune modal currentFinetuneInstance.setQuickCalibrating(true); const { controller } = currentFinetuneInstance; await auto_calibrate_stick_centers(controller, (success, message) => { currentFinetuneInstance.setQuickCalibrating(false); }); } async function finetune_quick_calibrate_range() { // Hide the finetune modal currentFinetuneInstance.setQuickCalibrating(true); const { controller, ll_data, rr_data } = currentFinetuneInstance; await calibrate_range(controller, { ll_data, rr_data }, (success, message) => { currentFinetuneInstance.setQuickCalibrating(false); }); } window.finetune_cancel = finetune_cancel; window.finetune_save = finetune_save; window.finetune_quick_calibrate_center = finetune_quick_calibrate_center; window.finetune_quick_calibrate_range = finetune_quick_calibrate_range;