WME Speed Display

Displays road speed directly in the center of the segment (taking curves into account) in Waze Map Editor

As of 2025-02-12. See the latest version.

// ==UserScript==
// @name         WME Speed Display
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Displays road speed directly in the center of the segment (taking curves into account) in Waze Map Editor
// @author       Luan Tavares
// @license      GPLv3
// @include      https://www.waze.com/editor*
// @include      https://www.waze.com/*/editor*
// @include      https://beta.waze.com/*
// @exclude      https://www.waze.com/user/editor*
// @grant        none
// ==/UserScript==

/* 
 * @todo:
 * Adicionar um filtro de velocidades, onde o user diz de qual até qual velocidade ele quer ver
 */

/* global W */
/* global I18n */
/* global OpenLayers */

class WmeSpeedDisplay {
    constructor() {
        this.version  = 1.0;
        this.layer    = null;
        this.settings = {
            debugMode: true,
            spdEnabled: localStorage.getItem('spdEnabled') ?? true,
            general: {
                spdHideNoSpeed: false,
                spdIgnoreRoundabouts: false,
                spdMaxZoom: 18
            },
            roads: {}
        };

        this.segmentsCategories = {
            highways: [
                {
                    id: 3,
                    name: 'freeway'
                },
                {
                    id: 4,
                    name: 'ramp'
                },
                {
                    id: 6,
                    name: 'major_highway'
                },
                {
                    id: 7,
                    name: 'minor_highway'
                }
            ],
            non_drivable: [
                {
                    id: 18,
                    name: 'railroad'
                },
                {
                    id: 19,
                    name: 'runway_taxiway'
                },
                {
                    id: 5,
                    name: 'walking_trail'
                },
                {
                    id: 10,
                    name: 'pedestrian_boardwalk'
                },
                {
                    id: 16,
                    name: 'stairway'
                }
            ],
            other_drivable: [
                {
                    id: 8,
                    name: 'off_road_not_maintained'
                },
                {
                    id: 20,
                    name: 'parking_lot_road'
                },
                {
                    id: 17,
                    name: 'private_road'
                },
                {
                    id: 15,
                    name: 'ferry'
                }
            ],
            streets: [
                {
                    id: 2,
                    name: 'primary_street'
                },
                {
                    id: 1,
                    name: 'street'
                },
                {
                    id: 22,
                    name: 'narrow_street'
                }
            ]
        };

        this.debounce = {
            updateMapDisplay: this.debounce(this.updateMapDisplay.bind(this), 1000),
            saveSettings: this.debounce(this.saveSettings.bind(this))
        };

        this.checkToInit();
    }

    /**
     * Aguarda o carregamento completo do WME e inicializa o script.
     */
    checkToInit() {
        this.defineTranslations();

        if (W?.userscripts?.state?.isReady) {
            this.logDebug(I18n.translations[I18n.locale].spd.log.wmeReadyStartScript);
            this.initializePlugin();
            
        } else {
            this.logDebug('Aguardando o WME estar pronto...');

            document.addEventListener('wme-ready', this.initializePlugin.bind(this));
        }
    }

    /**
     * Inicializa o plugin e configura a camada personalizada.
     */
    async initializePlugin() {
        this.logDebug('WME está pronto. Configurando camada personalizada...');

        // Cria uma camada personalizada para os ícones
        this.layer = new OpenLayers.Layer.Vector('Speed Display Layer', {
            displayInLayerSwitcher: false
        });

        W.map.addLayer(this.layer);

        this.addLayerToggle();
        this.addSettingsTab();
        this.loadSettings();
        // this.attachSettingsListeners();

        // Escuta eventos de movimento do mapa
        this.listen();

        this.logDebug('Aguardando uns 1 segundo para tudo carregar...');

        await this.sleep(1000);

        // Atualiza os ícones inicialmente
        this.updateMapDisplay();
    }

    /**
     * Registra eventos de atualização do mapa para exibir as velocidades.
     */
    listen() {
        // Oficial
        W.map.events.register('moveend', null, this.onMoveEnd.bind(this));
        W.map.events.register('zoomend', null, this.onZoomEnd.bind(this));

        W.model.segments.on('objectschanged', this.onObjectsChanged.bind(this));

        // Testando
        // W.map.events.register('mouseup', null, this.onMouseUp.bind(this));
        // W.model.events.register('objectsadded', null, this.onObjectsAdded.bind(this));
        // W.model.events.register('objectsremoved', null, this.onObjectsRemoved.bind(this));
        // // W.model.events.register('mergeend', null, this.onMergeEnded.bind(this));
        // W.selectionManager.events.register('selectionchanged', null, this.onSelectionChanged.bind(this));
        // W.model.actionManager.events.register('afterundoaction', null, this.onAfterUndo.bind(this));
        // W.model.actionManager.events.register('afterredoactions', null, this.onAfterRedo.bind(this));
        // // W.model.actionManager.events.register('afterclearactions', null, this.onAfterRedo2.bind(this));
        // W.model.segments.on('objectsremoved', this.onObjectsRemoved2.bind(this));

        // this.attachSettingsListeners();
    }

    onMoveEnd(event) {
        console.log('===========> Move End:', event);

        if (this.settings.spdEnabled)
            this.debounce.updateMapDisplay();
    }

    onZoomEnd(event) {
        console.log('===========> Zoom End:', event);

        if (this.settings.spdEnabled)
            this.debounce.updateMapDisplay();
    }

    // onMouseUp(event) {
    //     console.log('===========> mouseup:', event)

    //     // this.debounce.updateMapDisplay();
    // }

    onObjectsChanged(event) {
        console.log('===========> Objetos modificados:', event);

        if (this.settings.spdEnabled)
            this.debounce.updateMapDisplay();
    }

    // onObjectsAdded(event) {
    //     console.log('===========> Objetos adicionados:', event);

    //     if (this.settings.spdEnabled)
    //         this.debounce.updateMapDisplay();
    // }

    // onObjectsRemoved(event) {
    //     console.log('===========> Objetos removidos:', event);

    //     if (this.settings.spdEnabled)
    //         this.debounce.updateMapDisplay();
    // }

    // onObjectsRemoved2(event) {
    //     console.log('===========> Objetos removidos 2:', event);

    //     if (this.settings.spdEnabled)
    //         this.debounce.updateMapDisplay();
    // }

    // onSelectionChanged(event) {
    //     console.log('===========> Seleção mudou:', event);

    //     // if (this.settings.spdEnabled)
    //     //     this.debounce.updateMapDisplay();
    // }

    // onAfterUndo(event) {
    //     console.log('===========> Depois de desfazer:', event);

    //     if (this.settings.spdEnabled)
    //         this.debounce.updateMapDisplay();
    // }

    // onAfterRedo(event) {
    //     console.log('===========> Depois de refazer:', event);

    //     if (this.settings.spdEnabled)
    //         this.debounce.updateMapDisplay();
    // }

    hasSpeedChanged(event) {
        if (!event || !event.objects) return false;

        return event.objects.some(obj => obj.model.type === 'segment' && (obj.attributes.fwdMaxSpeed || obj.attributes.revMaxSpeed));
    }

    /**
     * Atualiza a exibição das velocidades no mapa.
     */
    updateMapDisplay(updatedSegments) {
        this.logDebug('Atualizando display no mapa...');

        let zoomLevel = W.map.getZoom();

        // TODO: Isso ainda não está funcionando direito
        // Remover apenas os segmentos atualizados, se fornecidos
        if (updatedSegments) {
            let segmentIdsToRemove = Object.values(updatedSegments).map(segment => segment.attributes.id);

            // Filtrar as features na camada e remover as correspondentes aos segmentos passados
            let featuresToRemove = this.layer.features.filter(feature => {
                let segmentId = Number(feature.attributes.segmentId);

                return segmentIdsToRemove.includes(segmentId);
            });

            if (featuresToRemove.length > 0)
                this.layer.removeFeatures(featuresToRemove);
        } else {
            // Se nenhum segmento foi passado, remove tudo
            this.layer.removeAllFeatures();
        }

        if (zoomLevel < this.settings.general.maxZoom) {
            console.log('excedeu o zoom', zoomLevel)
            return;
        } else {
            console.log('tá dentro do zoom permitido', zoomLevel)
        }

        // Acessa os segmentos carregados no mapa
        let segments = updatedSegments ? updatedSegments : W.model.segments.objects || {};
        let segmentKeys = Object.keys(segments);

        if (segmentKeys.length === 0) {
            this.logDebug('Nenhum segmento encontrado. O mapa pode não estar totalmente carregado.');
            return;
        }

        this.logDebug(`Segmentos carregados: ${segmentKeys.length}`);

        // Itera sobre os segmentos e adiciona os ícones à camada
        segmentKeys.forEach(segmentId => {
            let segment = segments[segmentId];

            // Verificação de existência de atributos do segmento
            if (!segment || !segment.attributes) {
                this.logDebug(`Segmento ${segmentId} não encontrado ou atributos ausentes.`);
                return;
            }

            let attributes    = segment.attributes;
            let roadId        = this.convertStringType('snake', 'kebab', this.getRoadSettingNameById(attributes.roadType));
            let roadSettingId = `spd-show-speed-in-${roadId}`;

            // Acesso aos dados de velocidade
            let speedFwd = attributes.fwdMaxSpeed || 'N/A';
            let speedRev = attributes.revMaxSpeed || 'N/A';
            let isFwd    = attributes.fwdDirection;
            let isRev    = attributes.revDirection;

            let hideNoSpeed = this.settings.general.spdHideNoSpeed && speedFwd === 'N/A' && speedRev === 'N/A';
            let ignoreOnRoundabout = attributes.isInRoundabout && this.settings.general.spdIgnoreRoundabouts;

            if (hideNoSpeed || this.settings.roads[roadSettingId] || ignoreOnRoundabout) {
                this.logDebug('Este tipo de seguimento não é para ser carregado.');
                return; // Ignora segmentos sem velocidades definidas
            }

            this.logDebug(`Segmento ${segmentId} - Fwd: ${speedFwd} km/h | Rev: ${speedRev} km/h`);

            // Acessa a geometria do segmento e calcula o ponto médio real (considerando curvas)
            let geometry = segment.getOLGeometry();
            if (!geometry || geometry.components.length < 2) {
                this.logDebug(`Segmento ${segmentId} sem geometria válida.`);
                return;
            }

            let midpoint = this.calculateMidpoint(geometry);

            // Cria uma feature para exibir o ícone no ponto médio
            let feature = new OpenLayers.Feature.Vector(midpoint, {
                segmentId,
                speedFwd,
                speedRev
            });

            let graphicWidth;
            let graphicXOffset;

            if (isFwd && isRev && speedFwd != speedRev) {
                graphicWidth = 70;
                graphicXOffset = -30;

            } else if (isFwd && speedFwd != 'N/A' || isRev && speedRev != 'N/A') {
                graphicWidth = 30;
                graphicXOffset = -15;
            }

            // Define um estilo para o ícone
            feature.style = {
                graphic: true,
                externalGraphic: 'data:image/svg+xml;base64,' + btoa(this.getSpeedIcon(speedFwd, speedRev, isFwd, isRev)),
                graphicHeight: 30,
                graphicWidth: graphicWidth,
                graphicYOffset: -15,
                graphicXOffset: graphicXOffset
            };

            // Adiciona a feature à camada
            this.layer.addFeatures([feature]);
        });

        this.logDebug('Atualização concluída.');
    }

    addLayerToggle() {
        // Aguarda até que o painel de camadas esteja disponível
        let houseNumbersSwitch = document.querySelector('#layer-switcher-item_house_numbers');

        if (!houseNumbersSwitch) {
            this.logDebug('Switch "Números das Casas" não encontrado. Tentando novamente...');
            setTimeout(() => this.addLayerToggle(), 1000);
            return;
        }

        // Criar o elemento do switch personalizado
        let layerItem = document.createElement('li');
        layerItem.innerHTML = `
            <div class="layer-selector">
                <wz-checkbox id="layer-switcher-item_speed_display">
                    <div class="layer-selector-container" title="WME Speed Display">Exibir velocidades</div>
                </wz-checkbox>
            </div>
        `;

        // Inserir abaixo do switch "Números das Casas"
        houseNumbersSwitch.closest('li').insertAdjacentElement('afterend', layerItem);

        // Obter referência ao switch
        let switchElement = document.querySelector('#layer-switcher-item_speed_display');
        if (!switchElement) return;

        // Definir estado inicial com base no localStorage
        switchElement.checked = localStorage.getItem('spdEnabled') == true;

        // Adicionar evento de clique no switch
        switchElement.addEventListener('change', (event) => {
            let enabled = event.target.checked;
            localStorage.setItem('spdEnabled', enabled);
            this.toggleLayerVisibility(enabled);
            this.debounce.updateMapDisplay();
        });

        // Definir visibilidade inicial
        this.toggleLayerVisibility(switchElement.checked);
    }

    addSettingsTab() {
        // Aguarda até que o painel de scripts esteja disponível
        let scriptTabContainer = document.querySelector('#user-info .nav-tabs');

        if (!scriptTabContainer) {
            this.logDebug('Painel de Scripts não encontrado. Tentando novamente...');
            setTimeout(() => this.addSettingsTab(), 1000);
            return;
        }

        // Verifica se a aba já existe para evitar duplicação
        if (document.querySelector('#wme-spd-tab')) {
            return;
        }

        // Criar botão da aba
        let tabButton = document.createElement('li');
        tabButton.innerHTML = `<a href="#wme-spd-settings" data-toggle="tab">Speed Display</a>`;
        scriptTabContainer.appendChild(tabButton);

        // Criar conteúdo da aba
        let tabContentContainer = document.querySelector('.tab-content');
        let userScriptsApiDocsLinkContainer = tabContentContainer.querySelector('.userscripts-api-docs-link-container');
        let tabContent = document.createElement('div');
        tabContent.id = 'wme-spd-settings';
        tabContent.classList.add('tab-pane');
        let tabContentHtml = `
            <div style="padding: 0 10px;">
                <h4>Configurações do Speed Display</h4>
                <hr>

                <wz-label html-for="">Geral</wz-label>
                <wz-checkbox checked="${this.settings.general.spdHideNoSpeed}" indeterminate="false" disabled="false" id="spd-hide-no-speed" value="true">Não exibir placa sem velocidade<input type="checkbox" value="true" style="display: none; visibility: hidden;"></wz-checkbox>
                <wz-checkbox checked="${this.settings.general.spdIgnoreRoundabouts}" indeterminate="false" disabled="false" id="spd-ignore-roundabouts" value="true">Não exibir em rotatória<input type="checkbox" value="true" style="display: none; visibility: hidden;"></wz-checkbox>
                <br>
                <wz-label html-for="" style="margin-top:10px">Renderizar até o zoom: <span id="spd-max-zoom-value">${this.settings.general.maxZoom}</span></wz-label>
                <input type="range" id="spd-max-zoom" min="12" max="22" step="1" value="${this.settings.general.maxZoom}">
                <br><br>

                <wz-label html-for="" style="margin:0">Ocultar nos seguimentos do tipo:</wz-label>`;

        Object.entries(this.segmentsCategories).forEach(segmentCategory => {
            tabContentHtml += `<wz-menu-title style="padding:0;">${I18n.translations[I18n.locale].segment.categories[segmentCategory[0]]}</wz-menu-title>`;

            Object.values(segmentCategory[1]).forEach(roadType => {
                let roadId    = this.convertStringType('snake', 'kebab', roadType.name);
                let id        = `spd-show-speed-in-${roadId}`
                let settingId = this.convertStringType('kebab', 'camel', id);
                let checked   = this.settings.roads[settingId] ?? true;

                tabContentHtml += `<wz-checkbox checked="${checked}" indeterminate="false" disabled="false" id="${id}" value="true">${I18n.translations[I18n.locale].segment.road_types[roadType.id]}<input type="checkbox" value="true" style="display: none; visibility: hidden;"></wz-checkbox>`;
            });
        });

        if (this.settings.debugMode)
            tabContentHtml += `<wz-button color="primary" id="btn-update-settings-tab" style="margin-top: 10px; width: 100%;">${I18n.translations[I18n.locale].spd.btn.updateSettingsTab}</wz-button>`;

        tabContentHtml += `</div>`;

        tabContent.innerHTML = tabContentHtml;

        tabContentContainer.insertBefore(tabContent, userScriptsApiDocsLinkContainer);

        // Configurar estado inicial
        // this.updateSettingsTabValues();
        this.loadSettings();

        // Adicionar eventos de configuração
        this.attachSettingsListeners();

        // document.querySelector('#spd-save').addEventListener('click', () => {
        //     let enabled = document.querySelector('#spd-toggle').checked;
        //     let opacity = document.querySelector('#spd-max-zoom').value;

        //     localStorage.setItem('spdEnabled', enabled);
        //     localStorage.setItem('speedDisplayMaxZoom', opacity);

        //     this.toggleLayerVisibility(enabled);
        //     this.updateIconOpacity(opacity);

        //     alert('Configurações salvas!');
        // });

        this.logDebug('Aba de configurações adicionada.');
    }

    /**
     * Ativa ou desativa a exibição da camada de velocidade.
     * @param {Boolean} enabled Indica se a camada deve ser exibida.
     */
    toggleLayerVisibility(enabled) {
        if (this.layer) {
            this.layer.setVisibility(enabled);
            this.logDebug(`Camada de velocidade ${enabled ? 'ativada' : 'desativada'}.`);
        }
    }

    /**
     * Ajusta a opacidade dos ícones com base na configuração do usuário.
     * @param {Number} opacity Valor entre 0.1 e 1.0.
     */
    updateIconOpacity(opacity) {
        if (this.layer) {
            this.layer.styleMap.styles.default.defaultStyle.graphicOpacity = opacity;
            this.layer.redraw();
            this.logDebug(`Opacidade dos ícones ajustada para ${opacity}`);
        }
    }

    updateSettingsTab() {
        this.logDebug(I18n.translations[I18n.locale].spd.log.updatingSettingsTab);

        // Check if the tab already exists
        let linkTab    = document.querySelector('#user-info .nav-tabs li a[href="#wme-spd-settings"]');
        let tabContent = document.querySelector('#wme-spd-settings');

        if (!linkTab || !tabContent)
            return;

        this.detachSettingsListeners();
        this.defineTranslations();

        tabContent.remove();
        linkTab.closest('li').remove();
        this.addSettingsTab();
        this.loadSettings();
    }

    saveSettings() {
        // TOOD: tá muito burro isso ainda. parece que estou chamando várias vezes saporra. tá bem bagunçado essa coisa de listener ainda, save e load settings...
        Object.entries(this.settings.general).forEach(([setting, value]) => {
            let id = this.convertStringType('camel', 'kebab', setting);

            setting = document.getElementById(id).checked;
        });
        
        document.querySelectorAll('[id^="spd-show-speed-in-"]').forEach(input => {
            let settingId = this.convertStringType('kebab', 'camel', input.id);

            this.settings.roads[settingId] = input.checked;
        });

        localStorage.setItem('wmeSpeedDisplaySettings', JSON.stringify(this.settings));
    }

    loadSettings() {
        let savedSettings = JSON.parse(localStorage.getItem('wmeSpeedDisplaySettings'));

        if (savedSettings)
            this.settings = savedSettings;

        Object.entries(this.settings.general).forEach(([setting, value]) => {
            let id = this.convertStringType('camel', 'kebab', setting);

            document.getElementById(id).checked = value;

            if (setting == 'spdMaxZoom')
                document.getElementById('spd-max-zoom-value').innerText = value;
        });

        document.querySelectorAll('[id^="spd-show-speed-in-"]').forEach(input => {
            let settingId = this.convertStringType('kebab', 'camel', input.id);
            
            if (this.settings.roads.hasOwnProperty(settingId)) {
                if (input.type === 'checkbox') {
                    input.checked = this.settings.roads[settingId];

                } else {
                    input.value = this.settings.roads[settingId];
                }
            }
        });
    }

    attachSettingsListeners() {
        ['spd-hide-no-speed', 'spd-ignore-roundabouts'].forEach(id => {
            document.getElementById(id).addEventListener('change', this.onSettingsChange.bind(this));
        });

        document.getElementById('spd-max-zoom').addEventListener('input', this.onSettingZoomChanged.bind(this));

        document.querySelectorAll('[id^="spd-show-speed-in-"]').forEach(input => {
            input.addEventListener('change', this.onSettingsChange.bind(this));
        });

        if (this.settings.debugMode)
            document.getElementById('btn-update-settings-tab').addEventListener('click', this.updateSettingsTab.bind(this));
    }

    detachSettingsListeners() {
        ['spd-hide-no-speed', 'spd-ignore-roundabouts'].forEach(id => {
            document.getElementById(id).removeEventListener('change', this.onSettingsChange.bind(this));
        });

        document.getElementById('spd-max-zoom').removeEventListener('input', this.onSettingZoomChanged.bind(this));

        document.querySelectorAll('[id^="spd-show-speed-in-"]').forEach(input => {
            input.removeEventListener('change', this.onSettingsChange.bind(this));
        });

        if (this.settings.debugMode)
            document.getElementById('btn-update-settings-tab').removeEventListener('click', this.updateSettingsTab.bind(this));
    }

    onSettingsChange(event) {
        this.debounce.saveSettings();
        this.debounce.updateMapDisplay();
    }

    // Atualiza o limite de zoom de renderização ao mexer no slider
    onSettingZoomChanged(event) {
        document.querySelector('#spd-max-zoom-value').innerText = event.target.value;

        this.debounce.saveSettings();
    }

    /**
     * Calcula o ponto médio real de um segmento, considerando curvas.
     * @param {OpenLayers.Geometry.LineString} geometry Geometria do segmento.
     * @returns {OpenLayers.Geometry.Point} Ponto médio real.
     */
    calculateMidpoint(geometry) {
        let length = geometry.getLength();
        let cumulativeLength = 0;

        for (let i = 0; i < geometry.components.length - 1; i++) {
            let start = geometry.components[i];
            let end = geometry.components[i + 1];
            let segmentLength = start.distanceTo(end);

            if (cumulativeLength + segmentLength >= length / 2) {
                let ratio = (length / 2 - cumulativeLength) / segmentLength;
                return new OpenLayers.Geometry.Point(
                    start.x + ratio * (end.x - start.x),
                    start.y + ratio * (end.y - start.y)
                );
            }

            cumulativeLength += segmentLength;
        }

        return geometry.getCentroid(); // Retorno de fallback
    }

    /**
     * Gera um ícone SVG com as velocidades.
     * @param {String} speedFwd Velocidade para frente.
     * @param {String} speedRev Velocidade reversa.
     * @param {String} isFwd Sentido da via para frente.
     * @param {String} isRev Sentido da via reversa.
     * @returns {String} O SVG em formato de string.
     */
    getSpeedIcon(speedFwd, speedRev, isFwd, isRev) {
        if (isFwd && isRev && speedFwd != speedRev) {
            return `
                <svg xmlns="http://www.w3.org/2000/svg" width="120" height="50" viewBox="0 0 120 50">
                  <circle cx="25" cy="25" r="21" fill="white" stroke="red" stroke-width="5"/>
                  <text x="25" y="31" font-size="20" font-family="Arial" font-weight="bold" fill="black" text-anchor="middle">${speedFwd}</text>

                  <circle cx="80" cy="25" r="21" fill="white" stroke="red" stroke-width="5"/>
                  <text x="80" y="31" font-size="20" font-family="Arial" font-weight="bold" fill="black" text-anchor="middle">${speedRev}</text>
                </svg>
            `;
        } else {
            return `
                <svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50">
                    <circle cx="25" cy="25" r="21" fill="white" stroke="red" stroke-width="5"/>
                    <text x="25" y="31" font-size="20" font-family="Arial" font-weight="bold" fill="black" text-anchor="middle">${isFwd ? speedFwd : speedRev}</text>
                </svg>
            `;
        }
    }

    /**
     * Makes a "dramatic" pause in the code.
     * @param {Number} ms Pause time in milliseconds
     */
    async sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    /**
     * Returns a function with delay.
     * @param {Object} func Function that will be called with delay
     * @param {(Number|null)} wait Delay time in milliseconds
     * @returns Function
     */
    debounce(func, timeout = 300) {
        let timer;

        return (...args) => {
            clearTimeout(timer);

            timer = setTimeout(() => {
                func.apply(this, args);
            }, timeout);
        }
    }

    /**
     * Função de log para debug.
     * @param {String} message
     */
    logDebug(message) {
        if (this.settings.debugMode)
            console.log(`[WME Speed Display]: ${message}`);
    }

    convertStringType(fromType, toType, string) {
        if (fromType === toType) return string; // Se os tipos forem iguais, retorna a string original

        let conversionMap = {
            camel: {
                kebab: str => str.match(/[A-Z]?[a-z]+|[0-9]+/g).join('-').toLowerCase(),
                snake: str => str.match(/[A-Z]?[a-z]+|[0-9]+/g).join('_').toLowerCase()
            },
            snake: {
                kebab: str => str.replace(/_/g, '-'),
                camel: str => str.replace(/_([a-z])/g, (_, letra) => letra.toUpperCase())
            },
            kebab: {
                camel: str => str.replace(/-([a-z])/g, (_, letra) => letra.toUpperCase()),
                snake: str => str.replace(/-/g, '_')
            }
        };

        return conversionMap[fromType]?.[toType]?.(string) || string;
    }

    getRoadSettingNameById(id) {
        for (let category of Object.values(this.segmentsCategories)) {
            let segment = category.find(item => item.id === id);
            if (segment) {
                return segment.name;
            }
        }

        // Retorna null se o ID não for encontrado
        return null;
    }

    defineTranslations() {
        switch (I18n.locale) {
            default:
                // Default language (english)
                I18n.translations[I18n.locale].spd = {
                    name: 'Speed Display',
                };

                break;

            case 'cs':
                // Czech
                I18n.translations[I18n.locale].spd = {
                    name: 'Junction Angle Info',
                };

                break;

            case 'es-419':
                // Latin-american spanish
                I18n.translations[I18n.locale].spd = {
                    name: 'Información en Ángulos de Intersección (JAI)',
                };
                
                break;

            case 'fi':
                // Finnish
                I18n.translations[I18n.locale].spd = {
                    name: 'Risteyskulmat',
                };

                break;

            case 'fr':
                // French
                I18n.translations[I18n.locale].spd = {
                    name: 'Junction Angle Info',
                };

                break;

            case 'pl':
                // Polish
                I18n.translations[I18n.locale].spd = {
                    name: 'Risteyskulmat',
                };

                break;

            case 'pt-BR':
                // Brazilian portuguese
                I18n.translations[I18n.locale].spd = {
                    name: 'Exibição de Velocidade',
                    settingTabName: 'Configurações de Exibição de Velocidade',
                    title: {
                        general: 'Geral',
                        hideOnRoadType: 'Ocultar nos seguimentos do tipo:'
                    },
                    label: {
                        hideNoSpeed: 'Não exibir em seguimento sem velocidade',
                        ignoreOnRoundabout: 'Não exibir em rotatória',
                        maxZoom: 'Renderizar até o zoom:'
                    },
                    btn: {
                        updateSettingsTab: 'Atualizar menu',
                    },
                    log: {
                        wmeReadyStartScript: 'WME carregado e pronto. Iniciando script...',
                        updatingSettingsTab: 'Atualizando aba de configurações...',
                    }
                };

                break;

            case 'ru':
                // Russian
                I18n.translations[I18n.locale].spd = {
                    name: 'Углы поворотов',
                };

                break;

            case 'sv':
                // Swedish
                I18n.translations[I18n.locale].spd = {
                    name: 'Korsningsvinklar',
                };

                break;

            case 'uk':
                // Ukrainian
                I18n.translations[I18n.locale].spd = {
                    name: 'Junction Angle Info',
                };

                break;
        };

        this.logDebug('Idiomas definidos.')
    }
}

new WmeSpeedDisplay();