gamepad-visualizer.jsjavascript
/**
 * Controller visual rendering for Gamepad API consumers.
 */

const DEFAULT_ASSET_BASE_PATH = '/assets/img/gamepads/';
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
const VALID_COLOR_SCHEMES = new Set(['Black', 'White']);

function createDPadButtonVisuals(shapes) {
    return shapes.map((shape, index) => ({
        index: index + 12,
        ...shape,
    }));
}

const CONTROLLER_VISUAL_CONFIGS = {
    xbox: {
        viewBox: '1050 450 2050 1350',
        imagePaths: {
            Black: 'xbox/Controller Images/Solid/Solid Black 4k.svg',
            White: 'xbox/Controller Images/Solid/Solid White SVG.svg',
        },
        buttons: [
            { index: 0, tag: 'circle', attributes: { cx: 2491, cy: 925, r: 70 } },
            { index: 1, tag: 'circle', attributes: { cx: 2606, cy: 811, r: 70 } },
            { index: 2, tag: 'circle', attributes: { cx: 2376, cy: 810, r: 70 } },
            { index: 3, tag: 'circle', attributes: { cx: 2491, cy: 695, r: 70 } },
            { index: 4, tag: 'path', attributes: { d: 'M1443,623.26s43.32-137.59,277-140.76c0,0,17.41,2.5,22.55,6.65,0,0,30.3,30.18,49.07,33.29C1791.65,522.44,1582.23,499.75,1443,623.26Z' } },
            { index: 5, tag: 'path', attributes: { d: 'M2653.21,623.26s-43.32-137.59-277-140.76c0,0-17.4,2.5-22.55,6.65,0,0-30.29,30.18-49.07,33.29C2304.6,522.44,2514,499.75,2653.21,623.26Z' } },
            { index: 8, tag: 'circle', attributes: { cx: 1922, cy: 808, r: 52 } },
            { index: 9, tag: 'circle', attributes: { cx: 2173, cy: 808, r: 52 } },
            { index: 10, tag: 'circle', attributes: { cx: 1602, cy: 816, r: 105 } },
            { index: 11, tag: 'circle', attributes: { cx: 2276, cy: 1073, r: 105 } },
            ...createDPadButtonVisuals([
                { tag: 'rect', attributes: { x: 1771, y: 964, width: 92, height: 82, rx: 13 } },
                { tag: 'rect', attributes: { x: 1771, y: 1139, width: 92, height: 82, rx: 13 } },
                { tag: 'rect', attributes: { x: 1692, y: 1046, width: 79, height: 93, rx: 13 } },
                { tag: 'rect', attributes: { x: 1863, y: 1046, width: 82, height: 93, rx: 13 } },
            ]),
            { index: 16, tag: 'circle', attributes: { cx: 2049, cy: 637, r: 65 } },
        ],
        sticks: [
            { buttonIndex: 10, axes: [0, 1], x: 1602, y: 816, range: 80 },
            { buttonIndex: 11, axes: [2, 3], x: 2276, y: 1073, range: 80 },
        ],
        triggers: [6, 7],
    },
    playstation: {
        viewBox: '950 400 2200 1350',
        imagePaths: {
            Black: 'playstation/Controller Images/Solid/Solid Black SVG.svg',
            White: 'playstation/Controller Images/Solid/Solid White SVG.svg',
        },
        buttons: [
            { index: 0, tag: 'circle', attributes: { cx: 2668, cy: 966, r: 72 } },
            { index: 1, tag: 'circle', attributes: { cx: 2815, cy: 819, r: 72 } },
            { index: 2, tag: 'circle', attributes: { cx: 2521, cy: 819, r: 72 } },
            { index: 3, tag: 'circle', attributes: { cx: 2668, cy: 672, r: 72 } },
            { index: 4, tag: 'path', attributes: { d: 'M1306.92,530.22v-24s64.45-44.31,142-52.75,109.72,5,109.72,5a57.45,57.45,0,0,1,4.74,28.49S1402.82,497.78,1306.92,530.22Z' } },
            { index: 5, tag: 'path', attributes: { d: 'M2787.64,530.22v-24s-64.45-44.31-142-52.75-109.71,5-109.71,5a57.52,57.52,0,0,0-4.75,28.49S2691.74,497.78,2787.64,530.22Z' } },
            { index: 8, tag: 'rect', attributes: { x: 1545, y: 530, width: 80, height: 145, rx: 35, transform: 'rotate(-8 1585 602)' } },
            { index: 9, tag: 'rect', attributes: { x: 2470, y: 530, width: 80, height: 145, rx: 35, transform: 'rotate(8 2510 602)' } },
            { index: 10, tag: 'circle', attributes: { cx: 1724, cy: 1094, r: 115 } },
            { index: 11, tag: 'circle', attributes: { cx: 2370, cy: 1093, r: 115 } },
            ...createDPadButtonVisuals([
                { tag: 'path', attributes: { d: 'M1449.18,658.32s-11.48-1.24-24-1.24-24,1.24-24,1.24-26.51,1.17-28.49,23.66l1.59,48.59a24.8,24.8,0,0,0,5.44,14.73l35,38.58a6.74,6.74,0,0,0,4.16,2.45,40.6,40.6,0,0,0,6.35.53,41.32,41.32,0,0,0,7.07-.67,4.91,4.91,0,0,0,3-1.79l34.52-38a28.88,28.88,0,0,0,6.33-17.17l1.55-47.22C1475.15,659.49,1449.18,658.32,1449.18,658.32Z' } },
                { tag: 'path', attributes: { d: 'M1401.18,976.94s11.47,1.25,24,1.25,24-1.25,24-1.25,26.5-1.17,28.48-23.65l-1.59-48.6a24.79,24.79,0,0,0-5.43-14.73l-35-38.58a6.68,6.68,0,0,0-4.16-2.44,39.28,39.28,0,0,0-6.34-.53,41.49,41.49,0,0,0-7.08.66,5,5,0,0,0-3,1.79l-34.51,38a28.85,28.85,0,0,0-6.34,17.17l-1.55,47.23C1375.2,975.77,1401.18,976.94,1401.18,976.94Z' } },
                { tag: 'path', attributes: { d: 'M1265.87,793.63s-1.25,11.48-1.25,24,1.25,24,1.25,24,1.17,26.51,23.65,28.49l48.6-1.6a24.73,24.73,0,0,0,14.73-5.43l38.58-35a6.68,6.68,0,0,0,2.44-4.16,39.42,39.42,0,0,0,.53-6.35,41.46,41.46,0,0,0-.66-7.07,5.06,5.06,0,0,0-1.79-3l-38-34.51a28.87,28.87,0,0,0-17.18-6.33l-47.22-1.55C1267,767.65,1265.87,793.63,1265.87,793.63Z' } },
                { tag: 'path', attributes: { d: 'M1584.48,841.63s1.25-11.47,1.25-24-1.25-24-1.25-24-1.17-26.5-23.65-28.48l-48.6,1.59a24.79,24.79,0,0,0-14.72,5.43l-38.59,35a6.77,6.77,0,0,0-2.44,4.16,39.28,39.28,0,0,0-.53,6.34,41.21,41.21,0,0,0,.67,7.07,5,5,0,0,0,1.78,3l38,34.51a28.94,28.94,0,0,0,17.18,6.34l47.22,1.55C1583.31,867.61,1584.48,841.63,1584.48,841.63Z' } },
            ]),
            { index: 16, tag: 'circle', attributes: { cx: 2048, cy: 1094, r: 70 } },
            { index: 17, tag: 'path', attributes: { d: 'M2446.36,528.91l-.06-.3c.6-17-2.64-40.77-23.6-49.54-7.28-3.05-28.57-5.62-57.54-7.79-96.28-9.84-316.91-10.41-316.91-10.41h-.45s-220.63.57-316.91,10.41c-29,2.17-50.27,4.74-57.54,7.79-21,8.77-24.2,32.57-23.6,49.54,0,.1,0,.2-.07.3l.1.48a105.85,105.85,0,0,0,2.21,17.72l52.22,255.56q1.63,5.87,3.63,11.19A112,112,0,0,0,1718,834.93a106.93,106.93,0,0,0,26,29.1,110.63,110.63,0,0,0,34.7,18.12,119.88,119.88,0,0,0,15.93,4c2.53.46,5.11.87,7.78,1.21h491.27c2.66-.34,5.25-.75,7.78-1.21a119.88,119.88,0,0,0,15.93-4,110.63,110.63,0,0,0,34.7-18.12,106.74,106.74,0,0,0,26-29.1,111.54,111.54,0,0,0,10.17-21.07c1.32-3.55,2.54-7.28,3.63-11.19l52.22-255.56a105.85,105.85,0,0,0,2.21-17.72Z' } },
        ],
        sticks: [
            { buttonIndex: 10, axes: [0, 1], x: 1724, y: 1094, range: 85 },
            { buttonIndex: 11, axes: [2, 3], x: 2370, y: 1093, range: 85 },
        ],
        triggers: [6, 7],
    },
    switch: {
        viewBox: '1050 450 2050 1350',
        imagePaths: {
            Black: 'switch/Controller Images/Pro Controller/Solid/Pro Controller Solid Black SVG.svg',
            White: 'switch/Controller Images/Pro Controller/Solid/Pro Controller Solid White SVG.svg',
        },
        buttons: [
            { index: 0, tag: 'circle', attributes: { cx: 2501, cy: 956, r: 70 } },
            { index: 1, tag: 'circle', attributes: { cx: 2637, cy: 841, r: 70 } },
            { index: 2, tag: 'circle', attributes: { cx: 2371, cy: 841, r: 70 } },
            { index: 3, tag: 'circle', attributes: { cx: 2501, cy: 725, r: 70 } },
            { index: 4, tag: 'path', attributes: { d: 'M1404.59,610.8c43.07-43.91,147.53-67.61,260.35-80.32l5.36-.59.62-.07c39.26-4.29,79.4-7.26,118.24-9.31C1759,511,1719,487.24,1719,487.24l-20-3.16c-210.46-1.59-285.89,86-285.89,86l-28.81,52.62A132.91,132.91,0,0,0,1404.59,610.8Z' } },
            { index: 5, tag: 'path', attributes: { d: 'M2424.51,529.82l.63.07,5.36.59c112.82,12.71,217.28,36.41,260.35,80.32a135.13,135.13,0,0,0,22.22,12.74l-29.3-53.49s-75.43-87.56-285.89-86l-20,3.16s-40.23,23.87-70.39,33.34C2345.93,522.63,2385.65,525.57,2424.51,529.82Z' } },
            { index: 8, tag: 'circle', attributes: { cx: 1837, cy: 711, r: 50 } },
            { index: 9, tag: 'circle', attributes: { cx: 2260, cy: 711, r: 50 } },
            { index: 10, tag: 'circle', attributes: { cx: 1578, cy: 841, r: 105 } },
            { index: 11, tag: 'circle', attributes: { cx: 2275, cy: 1072, r: 105 } },
            ...createDPadButtonVisuals([
                { tag: 'rect', attributes: { x: 1741, y: 950, width: 87, height: 83, rx: 17 } },
                { tag: 'rect', attributes: { x: 1741, y: 1120, width: 87, height: 83, rx: 17 } },
                { tag: 'rect', attributes: { x: 1658, y: 1033, width: 83, height: 87, rx: 17 } },
                { tag: 'rect', attributes: { x: 1828, y: 1033, width: 83, height: 87, rx: 17 } },
            ]),
            { index: 16, tag: 'circle', attributes: { cx: 2172, cy: 841, r: 55 } },
            { index: 17, tag: 'rect', attributes: { x: 1876, y: 791, width: 105, height: 105, rx: 18 } },
        ],
        sticks: [
            { buttonIndex: 10, axes: [0, 1], x: 1578, y: 841, range: 80 },
            { buttonIndex: 11, axes: [2, 3], x: 2275, y: 1072, range: 80 },
        ],
        triggers: [6, 7],
    },
};

function normalizeBasePath(basePath) {
    return basePath.endsWith('/') ? basePath : `${basePath}/`;
}

function normalizeColorScheme(colorScheme) {
    if (VALID_COLOR_SCHEMES.has(colorScheme)) {
        return colorScheme;
    }

    console.warn(`Invalid colorScheme: ${colorScheme}. Using 'White' instead.`);
    return 'White';
}

function encodeAssetPath(relativePath) {
    return relativePath
        .split('/')
        .map(pathPart => encodeURIComponent(pathPart))
        .join('/');
}

function cloneVisualConfig(config) {
    if (!config) {
        return null;
    }

    return {
        viewBox: config.viewBox,
        imagePaths: { ...config.imagePaths },
        buttons: config.buttons.map(button => ({
            ...button,
            attributes: { ...button.attributes },
        })),
        sticks: config.sticks.map(stick => ({
            ...stick,
            axes: [...stick.axes],
        })),
        triggers: [...config.triggers],
    };
}

/**
 * Get a copy of the visual definition for a controller type.
 * @param {string} controllerType - Controller type reported by GamepadHelper
 * @returns {Object|null} Controller visual definition, or null when unavailable
 */
function getControllerVisualConfig(controllerType) {
    return cloneVisualConfig(CONTROLLER_VISUAL_CONFIGS[controllerType]);
}

/**
 * Get the image path for a full controller visual.
 * @param {string} controllerType - Controller type reported by GamepadHelper
 * @param {string} [basePath='/assets/img/gamepads/'] - Base path containing controller assets
 * @param {string} [colorScheme='White'] - Image color scheme ('Black' or 'White')
 * @returns {string|null} Encoded controller image path, or null when unavailable
 */
function getControllerImagePath(
    controllerType,
    basePath = DEFAULT_ASSET_BASE_PATH,
    colorScheme = 'White',
) {
    const config = CONTROLLER_VISUAL_CONFIGS[controllerType];
    if (!config) {
        return null;
    }

    const resolvedColorScheme = normalizeColorScheme(colorScheme);
    return normalizeBasePath(basePath) + encodeAssetPath(config.imagePaths[resolvedColorScheme]);
}

function clamp(value, minimum, maximum) {
    return Math.max(minimum, Math.min(maximum, Number(value) || 0));
}

/**
 * Render and update a controller visual in a caller-provided DOM container.
 */
class GamepadVisualizer {
    /**
     * @param {GamepadHelper} helper - GamepadHelper instance used for mappings and paths
     * @param {Element} container - DOM element that will contain the visual
     * @param {Object} [options] - Renderer options
     * @param {string} [options.assetBasePath='/assets/img/gamepads/'] - Base asset path
     * @param {string} [options.colorScheme='White'] - Controller and button image color
     * @param {string} [options.unavailableText] - Fallback text for unsupported visuals
     */
    constructor(helper, container, options = {}) {
        if (!container || typeof container.replaceChildren !== 'function') {
            throw new TypeError('GamepadVisualizer requires a DOM container element');
        }

        this.helper = helper;
        this.container = container;
        this.assetBasePath = normalizeBasePath(options.assetBasePath || DEFAULT_ASSET_BASE_PATH);
        this.colorScheme = normalizeColorScheme(options.colorScheme || 'White');
        this.unavailableText = options.unavailableText || 'A visual is not available for this controller.';
        this.buttonElements = new Map();
        this.stickElements = [];
        this.triggerElements = new Map();
        this.gamepad = null;
        this.gamepadId = null;
        this.controllerType = null;
    }

    createSvgElement(tag, attributes = {}) {
        const element = this.container.ownerDocument.createElementNS(SVG_NAMESPACE, tag);
        Object.entries(attributes).forEach(([name, value]) => {
            element.setAttribute(name, value);
        });
        return element;
    }

    showUnavailable() {
        const message = this.container.ownerDocument.createElement('span');
        message.className = 'gamepad-visual-unavailable';
        message.textContent = this.unavailableText;
        this.container.replaceChildren(message);
    }

    createTriggerVisual(controllerType, buttonIndex) {
        const document = this.container.ownerDocument;
        const buttonName = this.helper.getButtonName(controllerType, buttonIndex);
        const trigger = document.createElement('div');
        trigger.className = 'gamepad-trigger-visual';
        trigger.setAttribute('role', 'progressbar');
        trigger.setAttribute('aria-label', `${buttonName} trigger pressure`);
        trigger.setAttribute('aria-valuemin', '0');
        trigger.setAttribute('aria-valuemax', '1');
        trigger.setAttribute('aria-valuenow', '0');

        const fill = document.createElement('span');
        fill.className = 'gamepad-trigger-fill';
        trigger.appendChild(fill);

        const image = document.createElement('img');
        image.className = 'gamepad-trigger-image';
        image.src = this.helper.getButtonImagePath(
            controllerType,
            buttonIndex,
            this.assetBasePath,
            this.colorScheme,
        );
        image.alt = buttonName;
        trigger.appendChild(image);

        const fallback = document.createElement('span');
        fallback.className = 'gamepad-trigger-name';
        fallback.textContent = buttonName;
        trigger.appendChild(fallback);

        image.addEventListener('error', () => {
            image.classList.add('gamepad-trigger-image-unavailable');
            fallback.classList.add('visible');
        }, { once: true });

        const value = document.createElement('span');
        value.className = 'gamepad-trigger-value';
        value.textContent = '0.00';
        trigger.appendChild(value);

        this.triggerElements.set(buttonIndex, { element: trigger, value });
        return trigger;
    }

    /**
     * Mount a visual for a gamepad and apply its current input state.
     * @param {Gamepad|null} gamepad - Gamepad to render
     * @returns {GamepadVisualizer} This visualizer
     */
    mount(gamepad) {
        this.buttonElements = new Map();
        this.stickElements = [];
        this.triggerElements = new Map();
        this.gamepad = gamepad || null;
        this.gamepadId = gamepad?.id || null;
        this.controllerType = gamepad ? this.helper.detectControllerType(gamepad.id) : null;
        this.container.replaceChildren();

        const config = getControllerVisualConfig(this.controllerType);
        if (!gamepad || !config) {
            this.showUnavailable();
            return this;
        }

        const gamepadInfo = this.helper.getGamepadInfo(gamepad.id);
        this.container.appendChild(this.createTriggerVisual(this.controllerType, config.triggers[0]));

        const visual = this.createSvgElement('svg', {
            class: 'gamepad-visual-svg',
            viewBox: config.viewBox,
            role: 'img',
            'aria-label': `${gamepadInfo.name} input visual`,
        });
        const title = this.createSvgElement('title');
        title.textContent = `${gamepadInfo.name} input visual`;
        visual.appendChild(title);

        const controllerImage = this.createSvgElement('image', {
            href: getControllerImagePath(this.controllerType, this.assetBasePath, this.colorScheme),
            width: 4096,
            height: 2160,
            preserveAspectRatio: 'xMidYMid meet',
        });
        controllerImage.addEventListener('error', () => this.showUnavailable(), { once: true });
        visual.appendChild(controllerImage);

        config.buttons.forEach(button => {
            const control = this.createSvgElement(button.tag, button.attributes);
            control.classList.add('gamepad-visual-control');
            control.dataset.buttonIndex = button.index;
            visual.appendChild(control);
            this.buttonElements.set(button.index, control);
        });

        config.sticks.forEach(stick => {
            const indicator = this.createSvgElement('circle', {
                class: 'gamepad-stick-indicator',
                cx: stick.x,
                cy: stick.y,
                r: 28,
            });
            visual.appendChild(indicator);
            this.stickElements.push({
                ...stick,
                buttonElement: this.buttonElements.get(stick.buttonIndex),
                indicator,
            });
        });

        this.container.appendChild(visual);
        this.container.appendChild(this.createTriggerVisual(this.controllerType, config.triggers[1]));
        return this.update(gamepad);
    }

    /**
     * Update the mounted visual with the latest Gamepad API state.
     * @param {Gamepad} [gamepad] - Latest gamepad state; defaults to the mounted gamepad
     * @returns {GamepadVisualizer} This visualizer
     */
    update(gamepad = this.gamepad) {
        if (!gamepad) {
            return this.mount(null);
        }

        const controllerType = this.helper.detectControllerType(gamepad.id);
        if (gamepad.id !== this.gamepadId || controllerType !== this.controllerType) {
            return this.mount(gamepad);
        }

        this.gamepad = gamepad;
        gamepad.buttons.forEach((button, index) => {
            const control = this.buttonElements.get(index);
            if (!control) {
                return;
            }

            const value = clamp(button.value, 0, 1);
            const isActive = Boolean(button.pressed) || value > 0.1;
            const opacity = Math.max(0.35, value * 0.9);
            control.classList.toggle('active', isActive);
            control.style.fillOpacity = isActive ? opacity.toFixed(2) : '0';
        });

        this.triggerElements.forEach((trigger, buttonIndex) => {
            const button = gamepad.buttons[buttonIndex];
            const value = clamp(button?.value, 0, 1);
            const isActive = Boolean(button?.pressed) || value > 0.1;
            trigger.element.classList.toggle('active', isActive);
            trigger.element.style.setProperty('--gamepad-trigger-value', `${(value * 100).toFixed(0)}%`);
            trigger.element.setAttribute('aria-valuenow', value.toFixed(2));
            trigger.value.textContent = value.toFixed(2);
        });

        this.stickElements.forEach(stick => {
            const horizontalValue = clamp(gamepad.axes[stick.axes[0]], -1, 1);
            const verticalValue = clamp(gamepad.axes[stick.axes[1]], -1, 1);
            const transform = `translate(${(horizontalValue * stick.range).toFixed(1)} ${(verticalValue * stick.range).toFixed(1)})`;
            stick.buttonElement?.setAttribute('transform', transform);
            stick.indicator.setAttribute('transform', transform);
        });

        return this;
    }

    /**
     * Change image colors and remount the current gamepad.
     * @param {string} colorScheme - Image color scheme ('Black' or 'White')
     * @returns {GamepadVisualizer} This visualizer
     */
    setColorScheme(colorScheme) {
        this.colorScheme = normalizeColorScheme(colorScheme);
        return this.mount(this.gamepad);
    }

    /**
     * Remove the rendered visual and release references to the mounted gamepad.
     * @returns {GamepadVisualizer} This visualizer
     */
    destroy() {
        this.container.replaceChildren();
        this.buttonElements.clear();
        this.stickElements = [];
        this.triggerElements.clear();
        this.gamepad = null;
        this.gamepadId = null;
        this.controllerType = null;
        return this;
    }
}

module.exports = {
    GamepadVisualizer,
    getControllerImagePath,
    getControllerVisualConfig,
};