DofusDB Map Advanced Options UI

Menu d'options avancées pour améliorer la visibilité des ressources sur la map du site DofusDB. Disponible dans toutes les langues.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         DofusDB Map Advanced Options UI
// @name:fr      DofusDB Map Advanced Options UI
// @name:en      DofusDB Map Advanced Options UI
// @name:de      DofusDB Karten-Erweiterte Optionen UI
// @name:es      Interfaz de opciones avanzadas del mapa DofusDB
// @name:pt-BR   Interface de opções avançadas do mapa DofusDB

// @description  Menu d'options avancées pour améliorer la visibilité des ressources sur la map du site DofusDB. Disponible dans toutes les langues.
// @description:fr Menu d'options avancées pour améliorer la visibilité des ressources sur la map du site DofusDB. Disponible dans toutes les langues.
// @description:en Advanced options menu to improve resource visibility on the DofusDB map. Available in all languages.
// @description:de Erweiterte Optionen zur Verbesserung der Ressourcensichtbarkeit auf der DofusDB-Karte. In allen Sprachen verfügbar.
// @description:es Menú de opciones avanzadas para mejorar la visibilidad de los recursos en el mapa de DofusDB. Disponible en todos los idiomas.
// @description:pt-BR Menu de opções avançadas para melhorar a visibilidade dos recursos no mapa do DofusDB. Disponível em todos os idiomas.

// @namespace    http://tampermonkey.net/
// @version      1.0.2
// @author       Haorow
// @homepageURL  https://greasyfork.org/scripts/576074
// @supportURL   https://greasyfork.org/scripts/576074-dofusdb-map-advanced-options-ui/feedback
// @match        https://dofusdb.fr/*
// @grant        none
// @license      MIT
// ==/UserScript==

/* eslint-disable no-multi-spaces */

(function () {
    'use strict';

    // ========================
    // TRANSLATIONS
    // ========================
    const TRANSLATIONS = {
        fr: { options: 'Options',  enable: 'Activer les options',  theme: 'Thème',          opacity: 'Opacité de la carte', scale: 'Taille des ressources', border: 'Bord des ressources' },
        en: { options: 'Options',  enable: 'Enable options',       theme: 'Theme',          opacity: 'Map opacity',         scale: 'Resource size',         border: 'Resource border' },
        de: { options: 'Optionen', enable: 'Optionen aktivieren',  theme: 'Thema',          opacity: 'Karten-Deckkraft',    scale: 'Ressourcengröße',       border: 'Ressourcenrand' },
        es: { options: 'Opciones', enable: 'Activar opciones',     theme: 'Tema',           opacity: 'Opacidad del mapa',   scale: 'Tamaño de recursos',    border: 'Borde de recursos' },
        pt: { options: 'Opções',   enable: 'Ativar opções',        theme: 'Tema',           opacity: 'Opacidade do mapa',   scale: 'Tamanho dos recursos',  border: 'Borda dos recursos' }
    };

    function getTranslations() {
        const m = location.pathname.match(/^\/(fr|en|de|es|pt)\//);
        return TRANSLATIONS[m?.[1]] || TRANSLATIONS.fr;
    }

    // ========================
    // STATE
    // ========================
    const state = {
        enabled:     localStorage.getItem('mapEnabled') !== 'false',
        opacity:     parseFloat(localStorage.getItem('mapOpacity')) || 0.65,
        scale:       parseFloat(localStorage.getItem('mapScale'))   || 1.15,
        isLight:     localStorage.getItem('mapTheme')   !== 'dark',
        showBorders: localStorage.getItem('mapBorders') === 'true'
    };

    // ========================
    // STYLES
    // ========================
    const dynamicStyle = document.head.appendChild(document.createElement('style'));
    const staticStyle  = document.head.appendChild(document.createElement('style'));

    staticStyle.textContent = `
        .leaflet-container                { transition: background 0.3s ease; }
        .leaflet-tile-pane .leaflet-layer { transition: opacity 0.2s ease; }
        .resources-marker-wrapper         { transition: transform 0.2s ease; }

        #map-options-menu input[type="range"] {
            -webkit-appearance: none;
            width: 140px; height: 6px;
            border-radius: 999px;
            outline: none;
            background: linear-gradient(to right, #00c853 0%, #00c853 var(--value, 50%), #ddd var(--value, 50%), #ddd 100%);
        }
        #map-options-menu input[type="range"]::-webkit-slider-thumb {
            -webkit-appearance: none;
            width: 14px; height: 14px;
            border-radius: 50%;
            background: #00c853;
            cursor: pointer;
        }
        #map-options-menu input[type="range"]::-moz-range-thumb {
            width: 14px; height: 14px;
            border-radius: 50%;
            background: #00c853;
            cursor: pointer;
        }
    `;

    function applyStyle() {
        document.head.appendChild(dynamicStyle); // garantit la priorité face aux CSS chargés en lazy par DofusDB
        if (!state.enabled) {
            dynamicStyle.textContent = '';
            return;
        }
        const { isLight, opacity, scale, showBorders } = state;
        const rgb = isLight ? '0,0,0' : '255,255,255';
        dynamicStyle.textContent = `
            .leaflet-container                { background: ${isLight ? '#ffffff' : '#272727'} !important; }
            .leaflet-tile-pane .leaflet-layer { opacity: ${opacity} !important; }
            .leaflet-layer[style*="z-index: 500"] canvas { filter: ${isLight ? 'invert(1)' : 'none'}; }
            .resources-marker-wrapper {
                transform: scale(${scale});
                transform-origin: center;
                outline: ${showBorders ? `1px solid rgba(${rgb},0.8)` : 'none'};
                background-color: rgba(${rgb},0.5);
            }
        `;
    }
    applyStyle();

    // ========================
    // DOM HELPER
    // ========================
    function el(tag, styles, props) {
        const node = document.createElement(tag);
        if (styles) Object.assign(node.style, styles);
        if (props)  Object.assign(node, props);
        return node;
    }

    // ========================
    // CONTROLS
    // ========================
    const ICONS = {
        sun:  '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><circle cx="12" cy="12" r="5"/><g stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="3" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="21"/><line x1="3" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="21" y2="12"/><line x1="5.6" y1="5.6" x2="7" y2="7"/><line x1="17" y1="17" x2="18.4" y2="18.4"/><line x1="5.6" y1="18.4" x2="7" y2="17"/><line x1="17" y1="7" x2="18.4" y2="5.6"/></g></svg>',
        moon: '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z"/></svg>'
    };

    const PALETTES = {
        theme: {
            offTrack: '#272727', onTrack: '#ffffff',
            offBall:  '#ffffff', onBall:  '#272727',
            icons: { left: [ICONS.sun, '#272727'], right: [ICONS.moon, '#ffffff'] }
        },
        master: {
            offTrack: '#888',    onTrack: '#00c853',
            offBall:  '#ffffff', onBall:  '#ffffff'
        }
    };

    function createSlideToggle(initial, onChange, palette) {
        const trackOf = v => v ? palette.onTrack : palette.offTrack;
        const ballOf  = v => v ? palette.onBall  : palette.offBall;

        const container = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', width: '100%' });
        const id = 'slide-toggle-' + Math.random().toString(36).slice(2, 11);

        const checkbox = el('input', { display: 'none' }, { type: 'checkbox', id, checked: initial });

        const label = el('label', {
            width: '50px', height: '26px', borderRadius: '50px',
            background: trackOf(initial),
            position: 'relative', cursor: 'pointer',
            display: 'flex', alignItems: 'center',
            transition: '0.25s ease'
        });
        label.htmlFor = id;

        if (palette.icons) {
            const iconStyle = { flex: '1', display: 'flex', alignItems: 'center', justifyContent: 'center' };
            label.append(
                el('span', { ...iconStyle, paddingLeft: '4px',  color: palette.icons.left[1]  }, { innerHTML: palette.icons.left[0]  }),
                el('span', { ...iconStyle, paddingRight: '4px', color: palette.icons.right[1] }, { innerHTML: palette.icons.right[0] })
            );
        }

        const ball = el('span', {
            width: '20px', height: '20px', borderRadius: '50%',
            background: ballOf(initial),
            position: 'absolute', top: '3px', left: '3px',
            transition: 'transform 0.25s ease, background 0.25s ease',
            transform: initial ? 'translateX(24px)' : 'translateX(0px)'
        });

        checkbox.addEventListener('change', () => {
            const v = checkbox.checked;
            label.style.background = trackOf(v);
            ball.style.background  = ballOf(v);
            ball.style.transform   = v ? 'translateX(24px)' : 'translateX(0px)';
            onChange(v);
        });

        label.append(ball);
        container.append(checkbox, label);
        return container;
    }

    function createSlider(min, max, step, value, onChange) {
        const container = el('div', { display: 'flex', alignItems: 'center', gap: '8px' });
        const slider    = el('input', null, { type: 'range', min, max, step, value });
        const display   = el('span', { minWidth: '40px', textAlign: 'right' }, { textContent: parseFloat(value).toFixed(2) });

        const updateGradient = (v) => slider.style.setProperty('--value', ((v - min) / (max - min)) * 100 + '%');
        updateGradient(value);

        slider.addEventListener('input', (e) => {
            const v = parseFloat(e.target.value);
            display.textContent = v.toFixed(2);
            updateGradient(v);
            onChange(v);
        });

        container.append(slider, display);
        return container;
    }

    function createCheckbox(initial, onChange) {
        const box = el('div', {
            width: '22px', height: '22px', borderRadius: '6px',
            cursor: 'pointer', boxSizing: 'border-box', flexShrink: '0',
            transition: '0.2s ease'
        });

        let checked = initial;
        const render = () => {
            box.style.background = checked ? '#00c853' : '#888';
            box.style.border     = checked ? '3px solid #fff' : '11px solid #888';
        };
        render();

        box.addEventListener('click', () => {
            checked = !checked;
            render();
            onChange(checked);
        });

        return box;
    }

    // ========================
    // UI ASSEMBLY
    // ========================
    function createRow(labelText, control) {
        const row = el('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '20px' });
        row.append(el('span', { whiteSpace: 'nowrap' }, { textContent: labelText }), control);
        return row;
    }

    function bindOption(key, storageKey, transform = v => v) {
        return (v) => {
            state[key] = v;
            localStorage.setItem(storageKey, transform(v));
            applyStyle();
        };
    }

    function createUI() {
        if (document.getElementById('map-options-menu')) return true;
        const rightItem = document.querySelector('header .text-accent');
        if (!rightItem) return false;

        const t = getTranslations();

        const wrapper = el('div', {
            position: 'relative', margin: '0 20px',
            color: 'white', cursor: 'pointer',
            fontSize: '13px', userSelect: 'none'
        }, { id: 'map-options-menu' });

        const labelBar = el('div', { display: 'flex', alignItems: 'center', gap: '3px', fontWeight: '600', fontSize: '14px' });
        labelBar.append(
            el('span', null, { textContent: '⚙️' }),
            el('span', null, { textContent: t.options })
        );

        const dropdown = el('div', {
            position: 'absolute', top: 'calc(100% + 24px)', right: '0',
            background: '#333', padding: '14px', borderRadius: '10px',
            display: 'none', flexDirection: 'column', gap: '12px',
            minWidth: '320px', zIndex: '9999'
        });

        let hideTimeout;
        const show = () => { clearTimeout(hideTimeout); dropdown.style.display = 'flex'; };
        const hide = () => { hideTimeout = setTimeout(() => dropdown.style.display = 'none', 500); };
        [wrapper, dropdown].forEach(n => {
            n.addEventListener('mouseenter', show);
            n.addEventListener('mouseleave', hide);
        });

        const subControls = el('div', {
            display: 'flex', flexDirection: 'column', gap: '12px',
            transition: 'opacity 0.2s ease'
        });

        const setSubEnabled = (v) => {
            subControls.style.opacity       = v ? '1' : '0.4';
            subControls.style.pointerEvents = v ? 'auto' : 'none';
        };
        setSubEnabled(state.enabled);

        const masterToggle = createSlideToggle(state.enabled, (v) => {
            state.enabled = v;
            localStorage.setItem('mapEnabled', String(v));
            setSubEnabled(v);
            applyStyle();
        }, PALETTES.master);

        subControls.append(
            createRow(t.theme,   createSlideToggle(state.isLight, bindOption('isLight', 'mapTheme', v => v ? 'light' : 'dark'), PALETTES.theme)),
            createRow(t.opacity, createSlider(0.5, 1,   0.05, state.opacity, bindOption('opacity',     'mapOpacity'))),
            createRow(t.scale,   createSlider(1, 1.5, 0.05, state.scale,   bindOption('scale',       'mapScale'))),
            createRow(t.border,  createCheckbox(state.showBorders,            bindOption('showBorders', 'mapBorders')))
        );

        dropdown.append(
            createRow(t.enable, masterToggle),
            subControls
        );

        wrapper.append(labelBar, dropdown);
        rightItem.parentNode.insertBefore(wrapper, rightItem);
        return true;
    }

    // ========================
    // INIT — polling URL + re-montage automatique après re-render Vue
    // ========================
    const isMapPage = () => /^\/(fr|en|de|es|pt)\/tools\/map/.test(location.pathname);

    let lastPath = null;

    function ensureMenu() {
        const existing = document.getElementById('map-options-menu');
        if (!isMapPage()) existing?.remove();
        else if (!existing) createUI();
    }

    function checkRoute() {
        if (location.pathname === lastPath) return;
        lastPath = location.pathname;
        document.getElementById('map-options-menu')?.remove();
        if (isMapPage()) applyStyle(); // remet nos styles en fin de head après une nav SPA
        ensureMenu();
    }

    // Polling URL : capte les changements de route quels qu'ils soient.
    setInterval(checkRoute, 250);

    // Observer global : si Vue re-rend le header et balaie notre menu, on le remet.
    new MutationObserver(ensureMenu).observe(document.body || document.documentElement, { childList: true, subtree: true });

    checkRoute();

})();