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와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();

})();