diff --git a/rulesdocs/refector_rules_and_tracking.md b/rulesdocs/refector_rules_and_tracking.md index bf981fb8..c20284a2 100644 --- a/rulesdocs/refector_rules_and_tracking.md +++ b/rulesdocs/refector_rules_and_tracking.md @@ -64,7 +64,7 @@ - [x] vip audio upload card - [x] admin panel - [x] drive dock action -- [ ] gamepad mapping settings +- [x] gamepad mapping settings - [ ] mobile controls - [ ] top down map - [x] video tile @@ -77,6 +77,7 @@ - vip audio upload card - admin panel - drive dock action +- gamepad mapping settings ### LARGE CHANGES - Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved. @@ -85,6 +86,7 @@ - Split `webui/src/components/vip/VipAudioUploadCard.jsx` by extracting transport/audio helpers and UI atoms into `webui/src/components/vip/VipAudioUploadCard/` while preserving the existing `VipAudioUploadCard.jsx` import/export API. - Split `webui/src/components/AdminPanel.jsx` into `webui/src/components/AdminPanel/` and extracted monitor/health/log/LLM helper modules; updated consumers to folder entrypoint and removed external wrapper file. - Moved `DriveDockAction` to `webui/src/components/DriveDockAction/index.jsx` and updated all consumers to folder entrypoint imports. +- Split `webui/src/components/GamepadMappingSettings.jsx` into `webui/src/components/GamepadMappingSettings/` with extracted constants/helpers/SliderField modules and removed the standalone component file. ## Done criteria (per item) - [ ] Folderized structure created. diff --git a/webui/src/components/GamepadMappingSettings.jsx b/webui/src/components/GamepadMappingSettings/GamepadMappingSettingsContent.jsx similarity index 73% rename from webui/src/components/GamepadMappingSettings.jsx rename to webui/src/components/GamepadMappingSettings/GamepadMappingSettingsContent.jsx index 6218ddf2..c7702b0a 100644 --- a/webui/src/components/GamepadMappingSettings.jsx +++ b/webui/src/components/GamepadMappingSettings/GamepadMappingSettingsContent.jsx @@ -1,185 +1,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useSettingsNamespace } from '../settings/index.js'; -import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../settings/namespaces.js'; +import { useSettingsNamespace } from '../../settings/index.js'; +import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { computeGamepadOutputs, createProfileForPad, -} from '../controls/inputs/gamepadBindings.js'; -import { useGamepadHubState } from '../controls/inputs/gamepadHub.js'; - -const NUMBER_FORMAT = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }); - -const ACTIONS = [ - { - id: 'drive', - label: 'Drive stick', - kind: 'axisPair', - section: 'Driving', - invertDefaults: { invertX: false, invertY: true }, - }, - { - id: 'cameraTilt', - label: 'Camera tilt', - kind: 'axis', - section: 'Camera', - invertDefaults: { invert: true }, - }, - { - id: 'mainBrush', - label: 'Main brush', - kind: 'axis', - section: 'Brushes', - invertDefaults: { invert: false }, - }, - { - id: 'sideBrush', - label: 'Side brush', - kind: 'axis', - section: 'Brushes', - invertDefaults: { invert: false }, - }, - { id: 'vacuum', label: 'Vacuum', kind: 'button', section: 'Aux buttons' }, - { id: 'allAux', label: 'All aux', kind: 'button', section: 'Aux buttons' }, - { id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' }, - { id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' }, - { id: 'driveMacro', label: 'Drive macro', kind: 'button', section: 'Mode macros' }, - { id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' }, - { id: 'nightVisionToggle', label: 'Night vision toggle', kind: 'button', section: 'Camera' }, -]; - -const CAPTURE_AXIS_THRESHOLD = 0.45; -const CAPTURE_BUTTON_THRESHOLD = 0.6; - -function SliderField({ label, description, min, max, step, value, onChange }) { - return ( - - ); -} - -function formatSource(source) { - if (!source) return 'Unassigned'; - if (source.kind === 'axisPair') { - const invert = `${source.invertX ? 'X inv ' : ''}${source.invertY ? 'Y inv' : ''}`.trim(); - return `Axes ${source.x}/${source.y}${invert ? ` (${invert})` : ''}`; - } - if (source.kind === 'axis') { - return `Axis ${source.index}${source.invert ? ' (inv)' : ''}`; - } - if (source.kind === 'button') { - return `Button ${source.index}`; - } - if (source.kind === 'buttonAxis') { - return `Button ${source.index} (analog)`; - } - if (source.kind === 'axisButton') { - const dir = source.direction < 0 ? '<' : '>'; - return `Axis ${source.index} ${dir} ${NUMBER_FORMAT.format(source.threshold ?? 0.6)}`; - } - return 'Unassigned'; -} - -function groupActions(actions) { - return actions.reduce((acc, action) => { - const list = acc[action.section] || (acc[action.section] = []); - list.push(action); - return acc; - }, {}); -} - -function pickActivePad(pads, activeSignature) { - if (!pads || pads.length === 0) return null; - if (activeSignature) { - const match = pads.find((pad) => pad.signature === activeSignature); - if (match) return match; - } - return pads[0]; -} - -function snapshotBaseline(pad) { - return { - axes: Array.from(pad.axes ?? []), - buttons: (pad.buttons ?? []).map((btn) => ({ - pressed: Boolean(btn?.pressed), - value: typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0, - })), - }; -} - -function detectAxisCapture(pad, baseline, action) { - const deltas = (pad.axes ?? []).map((value, index) => ({ - index, - delta: Math.abs((value ?? 0) - (baseline.axes?.[index] ?? 0)), - value, - })); - deltas.sort((a, b) => b.delta - a.delta); - if (action.kind === 'axisPair') { - const top = deltas.filter((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD).slice(0, 2); - if (top.length < 2) return null; - return { - kind: 'axisPair', - x: top[0].index, - y: top[1].index, - ...(action.invertDefaults ?? {}), - }; - } - const match = deltas.find((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD); - if (!match) return null; - return { - kind: 'axis', - index: match.index, - ...(action.invertDefaults ?? {}), - }; -} - -function detectButtonCapture(pad, baseline, action) { - const buttons = pad.buttons ?? []; - for (let i = 0; i < buttons.length; i += 1) { - const btn = buttons[i]; - const value = typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0; - if (btn?.pressed || value > CAPTURE_BUTTON_THRESHOLD) { - if (action.kind === 'axis') { - return { kind: 'buttonAxis', index: i }; - } - return { kind: 'button', index: i }; - } - } - const axes = pad.axes ?? []; - for (let i = 0; i < axes.length; i += 1) { - const value = axes[i] ?? 0; - const delta = Math.abs(value - (baseline.axes?.[i] ?? 0)); - if (Math.abs(value) > 0.7 && delta > 0.5) { - if (action.kind === 'axis') { - return { kind: 'axis', index: i, ...(action.invertDefaults ?? {}) }; - } - return { kind: 'axisButton', index: i, direction: value >= 0 ? 1 : -1, threshold: 0.6 }; - } - } - return null; -} - -function buildDescriptorFromCapture(pad, baseline, action) { - if (!pad) return null; - if (action.kind === 'axis' || action.kind === 'axisPair') { - const axisDescriptor = detectAxisCapture(pad, baseline, action); - if (axisDescriptor) return axisDescriptor; - } - return detectButtonCapture(pad, baseline, action); -} +} from '../../controls/inputs/gamepadBindings.js'; +import { useGamepadHubState } from '../../controls/inputs/gamepadHub.js'; +import SliderField from './SliderField.jsx'; +import { ACTIONS, NUMBER_FORMAT } from './constants.js'; +import { + formatSource, + groupActions, + pickActivePad, + snapshotBaseline, + buildDescriptorFromCapture, +} from './helpers.js'; export default function GamepadMappingSettings() { const hubState = useGamepadHubState(); diff --git a/webui/src/components/GamepadMappingSettings/SliderField.jsx b/webui/src/components/GamepadMappingSettings/SliderField.jsx new file mode 100644 index 00000000..f479451c --- /dev/null +++ b/webui/src/components/GamepadMappingSettings/SliderField.jsx @@ -0,0 +1,23 @@ +// Reusable slider field for gamepad calibration controls. +import { NUMBER_FORMAT } from './constants.js'; + +export default function SliderField({ label, description, min, max, step, value, onChange }) { + return ( + + ); +} diff --git a/webui/src/components/GamepadMappingSettings/constants.js b/webui/src/components/GamepadMappingSettings/constants.js new file mode 100644 index 00000000..4d3c9c45 --- /dev/null +++ b/webui/src/components/GamepadMappingSettings/constants.js @@ -0,0 +1,43 @@ +// Gamepad mapping constants and action catalog. +export const NUMBER_FORMAT = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }); + +export const ACTIONS = [ + { + id: 'drive', + label: 'Drive stick', + kind: 'axisPair', + section: 'Driving', + invertDefaults: { invertX: false, invertY: true }, + }, + { + id: 'cameraTilt', + label: 'Camera tilt', + kind: 'axis', + section: 'Camera', + invertDefaults: { invert: true }, + }, + { + id: 'mainBrush', + label: 'Main brush', + kind: 'axis', + section: 'Brushes', + invertDefaults: { invert: false }, + }, + { + id: 'sideBrush', + label: 'Side brush', + kind: 'axis', + section: 'Brushes', + invertDefaults: { invert: false }, + }, + { id: 'vacuum', label: 'Vacuum', kind: 'button', section: 'Aux buttons' }, + { id: 'allAux', label: 'All aux', kind: 'button', section: 'Aux buttons' }, + { id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' }, + { id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' }, + { id: 'driveMacro', label: 'Drive macro', kind: 'button', section: 'Mode macros' }, + { id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' }, + { id: 'nightVisionToggle', label: 'Night vision toggle', kind: 'button', section: 'Camera' }, +]; + +export const CAPTURE_AXIS_THRESHOLD = 0.45; +export const CAPTURE_BUTTON_THRESHOLD = 0.6; diff --git a/webui/src/components/GamepadMappingSettings/helpers.js b/webui/src/components/GamepadMappingSettings/helpers.js new file mode 100644 index 00000000..031e5663 --- /dev/null +++ b/webui/src/components/GamepadMappingSettings/helpers.js @@ -0,0 +1,112 @@ +// Gamepad mapping helper and capture functions. +import { CAPTURE_AXIS_THRESHOLD, CAPTURE_BUTTON_THRESHOLD, NUMBER_FORMAT } from './constants.js'; + +export function formatSource(source) { + if (!source) return 'Unassigned'; + if (source.kind === 'axisPair') { + const invert = `${source.invertX ? 'X inv ' : ''}${source.invertY ? 'Y inv' : ''}`.trim(); + return `Axes ${source.x}/${source.y}${invert ? ` (${invert})` : ''}`; + } + if (source.kind === 'axis') { + return `Axis ${source.index}${source.invert ? ' (inv)' : ''}`; + } + if (source.kind === 'button') { + return `Button ${source.index}`; + } + if (source.kind === 'buttonAxis') { + return `Button ${source.index} (analog)`; + } + if (source.kind === 'axisButton') { + const dir = source.direction < 0 ? '<' : '>'; + return `Axis ${source.index} ${dir} ${NUMBER_FORMAT.format(source.threshold ?? 0.6)}`; + } + return 'Unassigned'; +} + +export function groupActions(actions) { + return actions.reduce((acc, action) => { + const list = acc[action.section] || (acc[action.section] = []); + list.push(action); + return acc; + }, {}); +} + +export function pickActivePad(pads, activeSignature) { + if (!pads || pads.length === 0) return null; + if (activeSignature) { + const match = pads.find((pad) => pad.signature === activeSignature); + if (match) return match; + } + return pads[0]; +} + +export function snapshotBaseline(pad) { + return { + axes: Array.from(pad.axes ?? []), + buttons: (pad.buttons ?? []).map((btn) => ({ + pressed: Boolean(btn?.pressed), + value: typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0, + })), + }; +} + +function detectAxisCapture(pad, baseline, action) { + const deltas = (pad.axes ?? []).map((value, index) => ({ + index, + delta: Math.abs((value ?? 0) - (baseline.axes?.[index] ?? 0)), + value, + })); + deltas.sort((a, b) => b.delta - a.delta); + if (action.kind === 'axisPair') { + const top = deltas.filter((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD).slice(0, 2); + if (top.length < 2) return null; + return { + kind: 'axisPair', + x: top[0].index, + y: top[1].index, + ...(action.invertDefaults ?? {}), + }; + } + const match = deltas.find((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD); + if (!match) return null; + return { + kind: 'axis', + index: match.index, + ...(action.invertDefaults ?? {}), + }; +} + +function detectButtonCapture(pad, baseline, action) { + const buttons = pad.buttons ?? []; + for (let i = 0; i < buttons.length; i += 1) { + const btn = buttons[i]; + const value = typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0; + if (btn?.pressed || value > CAPTURE_BUTTON_THRESHOLD) { + if (action.kind === 'axis') { + return { kind: 'buttonAxis', index: i }; + } + return { kind: 'button', index: i }; + } + } + const axes = pad.axes ?? []; + for (let i = 0; i < axes.length; i += 1) { + const value = axes[i] ?? 0; + const delta = Math.abs(value - (baseline.axes?.[i] ?? 0)); + if (Math.abs(value) > 0.7 && delta > 0.5) { + if (action.kind === 'axis') { + return { kind: 'axis', index: i, ...(action.invertDefaults ?? {}) }; + } + return { kind: 'axisButton', index: i, direction: value >= 0 ? 1 : -1, threshold: 0.6 }; + } + } + return null; +} + +export function buildDescriptorFromCapture(pad, baseline, action) { + if (!pad) return null; + if (action.kind === 'axis' || action.kind === 'axisPair') { + const axisDescriptor = detectAxisCapture(pad, baseline, action); + if (axisDescriptor) return axisDescriptor; + } + return detectButtonCapture(pad, baseline, action); +} diff --git a/webui/src/components/GamepadMappingSettings/index.jsx b/webui/src/components/GamepadMappingSettings/index.jsx new file mode 100644 index 00000000..17d67c64 --- /dev/null +++ b/webui/src/components/GamepadMappingSettings/index.jsx @@ -0,0 +1,3 @@ +import GamepadMappingSettingsContent from './GamepadMappingSettingsContent.jsx'; + +export default GamepadMappingSettingsContent; diff --git a/webui/src/components/SettingsPanel.jsx b/webui/src/components/SettingsPanel.jsx index f1eb36cf..43df8b3c 100644 --- a/webui/src/components/SettingsPanel.jsx +++ b/webui/src/components/SettingsPanel.jsx @@ -3,7 +3,7 @@ import { useControlSystem } from '../controls/index.js'; import AuthPanel from './AuthPanel.jsx'; import AdminPanel from './AdminPanel/index.jsx'; import KeymapSettings from './KeymapSettings.jsx'; -import GamepadMappingSettings from './GamepadMappingSettings.jsx'; +import GamepadMappingSettings from './GamepadMappingSettings/index.jsx'; import OvercurrentLimiterPanel from './OvercurrentLimiterPanel.jsx'; import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx'; import SessionSnapshot from './SessionSnapshot.jsx';