DeepCo NekTooltip

Tooltip info display — NekStyle extension

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         DeepCo NekTooltip
// @namespace    http://tampermonkey.net/
// @version      1.5.2
// @description  Tooltip info display — NekStyle extension
// @match        https://deepco.app/*
// @license      MIT
// @grant        none
// @icon         https://www.google.com/s2/favicons?sz=64&domain=deepco.app
// ==/UserScript==

(function () {
    'use strict';

    const EXTENSION_ID = 'nek-tooltip';
    const EXTENSION_LABEL = 'NekTooltip';
    const EXTENSION_VERSION = GM?.info?.script?.version || '1';
    const BUTTON_COLOR = '#55adda';
    const STORAGE_KEY = 'NekTooltipOptions';
    const CACHE_KEY = 'NekTooltipProfileCache';
    const CACHE_TTL = 5 * 60 * 1000; // 5 min
    const NOTES_KEY = 'NekTooltipNotes';
    const HOVER_DELAY_MS = 500;
    const INJECT_TARGET = 'footer.footer>nav';

    const OPTIONS = [
        {
            id:    'tooltip-tenure',
            label: 'Show Tenure',
            alt:   'Display Tenure',
            fieldsetLabel: 'main',
            innerLabel:    'tenure',
            displayLabel:  'Tenure',
            format: (val) => val.replace('Tenure ','')
        },
        {
            id:    'tooltip-cycle-duration',
            label: 'Show Cycle Duration',
            alt:   'Display current-cycle duration',
            fieldsetLabel: 'Current Cycle',
            innerLabel:    'Cycle Duration',
            displayLabel:  'Cycle',
            format: (val) => `<span class="text-accent">${val.split(' ').slice(0,2).join(' ')}</span>`
        },
        {
            id:    'tooltip-recurse-credits',
            label: 'Show Recursive Credits',
            alt:   'Display lifetime Recursive Credits Acquired in worker tooltips',
            fieldsetLabel: 'Lifetime Output',
            innerLabel:    'Recursive Credits Acquired',
            displayLabel:  'Recurse Credits',
            format: (val) => {
                const num = parseFloat(val.replace(/,/g, ''));
                const fmt = isNaN(num)
                ? val
                : num >= 100000 ? `${Math.floor(num / 1000)}k`
                    : num >= 10000 ? `${Math.floor(num / 100)/10}k`
                    : num >= 1000 ? `${Math.floor(num)}`
                    : num >= 100 ? `${Math.floor(num*10)/10}`
                    : val;
                return `<span class="text-secondary">${fmt}</span>`;

                `<span class="text-secondary">${val}</span>`
            }
        },
        {
            id:    'tooltip-departement',
            label: 'Show Departement',
            alt:   'Display current departement',
            fieldsetLabel: 'main',
            innerLabel:    'department',
            displayLabel:  'Department',
        },
        {
            id:    'tooltip-presence-today',
            label: 'Show Presence Today',
            alt:   'Display Presence Log today duration',
            fieldsetLabel: 'Presence Log',
            innerLabel:    'Today',
            displayLabel:  'Presence Today',
            format: (val) => val.split(' ').slice(0,2).join(' ')
        },
        {
            id:    'tooltip-teamwork-processes',
            label: 'Show Teamwork Processes',
            alt:   'Display current-cycle Teamwork Processes',
            fieldsetLabel: 'Current Cycle',
            innerLabel:    'Teamwork Processes',
            displayLabel:  'Teamwork',
            format: (val) => {
                const num = parseInt(val.replace(/[\.,\s]/g,''));
                return isNaN(num)
                    ? val
                : num >= 300
                    ? `<span class="text-s">✔️</span> ${val}`
                      : `<span class="text-s">❌</span> ${val}`;
            }
        },
    ];

    const PREFERENCES = [
        { id: 'pref-enable-colors', label: 'Enable Colors', alt: 'Enable colors' },
        { id: 'pref-name-not-colored', label: 'Do not color name', alt: 'Disable player custom color' },
    ];

    function loadSettings() {
        try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
        catch { return {}; }
    }

    function saveSettings(s) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
    }

    function loadNotes() {
        try { return JSON.parse(localStorage.getItem(NOTES_KEY)) || {}; }
        catch { return {}; }
    }

    function saveNote(profileUrl, note) {
        const notes = loadNotes();
        const clean = note.trim();
        if (clean === '') delete notes[profileUrl];
        else notes[profileUrl] = clean;
        localStorage.setItem(NOTES_KEY, JSON.stringify(notes));
    }

    function getNote(profileUrl) {
        return loadNotes()[profileUrl] || '';
    }

    function loadCache() {
        try { return JSON.parse(localStorage.getItem(CACHE_KEY)) || {}; }
        catch { return {}; }
    }

    function saveCache(cache) {
        try { localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); }
        catch { /* ignore */ }
    }

    function getCachedProfile(profileUrl) {
        const cache = loadCache();
        const entry = cache[profileUrl];
        if (!entry) return null;
        if (Date.now() - entry.ts > CACHE_TTL) return null;
        return {...entry.data, ts: entry.ts};
    }

    function setCachedProfile(profileUrl, data) {
        const cache = loadCache();
        cache[profileUrl] = { ts: Date.now(), data };
        for (const [k, v] of Object.entries(cache)) {
            if (Date.now() - v.ts > CACHE_TTL) delete cache[k];
        }
        saveCache(cache);
    }

    function extractProfileData(doc) {
        const panel = doc.getElementById('main-panel')
        ?.getElementsByTagName('main')[0]
        ?.getElementsByClassName('settings-panel')[0];

        if (!panel) return null;
        const data = {};

        panel.querySelectorAll('fieldset').forEach(fieldset => {
            const legend = fieldset.querySelector('legend');
            if (!legend) return;
            const sectionLabel = legend.textContent.trim();

            fieldset.querySelectorAll('div.text-xs').forEach(labelDiv => {
                const innerLabel = labelDiv.textContent.trim();
                const container = labelDiv.closest('.rounded-box');
                const valueDiv = container?.querySelector('.text-2xl') ?? container?.querySelector('.text-lg.font-semibold');
                if (valueDiv) {
                    data[`${sectionLabel}::${innerLabel}`] = valueDiv.textContent.trim();
                }
            });
        });

        const boxes = doc.querySelectorAll('.rounded-box');
        boxes.forEach(box => {
            const label = box.querySelector('.text-xs')?.textContent.trim();
            if (!label) return;
            switch (label) {
                case 'Name': {
                    const nameSpan = box.querySelector('.text-lg span.truncate');
                    if (nameSpan) {
                        data['main::name'] = nameSpan.textContent.trim();
                        data['main::color'] = nameSpan.style.color || null;
                    }
                    const specBadge = box.querySelector('.badge-outline');
                    if (specBadge) {
                        data['main::specialisation'] = specBadge.textContent.trim();
                    }
                    break;
                }
                case 'Department': {
                    const value = box.querySelector('.text-lg span');
                    if (value) data['main::department'] = value.textContent.trim();
                    break;
                }
                case 'Status': {
                    const badge = box.querySelector('.badge');
                    if (badge) data['main::online'] = badge.textContent.trim();
                    break;
                }
                case 'Enlisted': {
                    const badge = box.querySelector('.badge');
                    if (badge) data['main::tenure'] = badge.textContent.trim();
                    break;
                }
            }
        });

        return Object.keys(data).length > 0 ? data : null;
    }

    async function fetchProfileData(profileUrl) {
        const cached = getCachedProfile(profileUrl);
        if (cached) return cached;

        const res = await fetch(profileUrl);
        const text = await res.text();
        const doc = new DOMParser().parseFromString(text, 'text/html');
        const data = extractProfileData(doc);

        if (data) setCachedProfile(profileUrl, data);
        return data ? {...data, ts: Date.now()} : null;
    }

    const tooltipEl = (() => {
        const el = document.createElement('div');
        el.classList.add("bg-base-200","card","border","border-base-300");
        el.style.cssText = [
            'display: none',
            'position: fixed',
            'z-index: 99999',
            'pointer-events: none',
            'padding: 6px 10px',
            'font-size: 11px',
            'line-height: 1.5',
            'max-width: 240px',
            'box-shadow: 0 4px 12px rgba(0,0,0,0.25)',
        ].join('; ');
        document.body.appendChild(el);
        return el;
    })();

    function hideOriginalTooltip(anchorEl){
        if(!window.NekStyle?.enabledOptions?.includes("presence-tooltip")){
            const p = anchorEl.parentElement
            if(p.classList.contains('tooltip')){
                p.classList.remove("tooltip");
                p.classList.add("nek-tooltip-removed");
            }
        }
    }

    function showOriginalTooltip(anchorEl){
        if(anchorEl && !window.NekStyle?.enabledOptions?.includes("presence-tooltip")){
            const p = anchorEl.parentElement
            if(p.classList.contains('nek-tooltip-removed')){
                p.classList.add("tooltip");
                p.classList.remove("nek-tooltip-removed");
            }
        }
    }


    function showTooltip(anchorEl, lines, datas, settings, profileUrl = null) {
        hideOriginalTooltip(anchorEl)
        const refreshTime = datas?.ts ? ( Date.now() - datas.ts ) / 60000 : 0;
        const values = lines
        .map(({ label, value }) =>
             `<div><span style="opacity:0.6;margin-right:4px;">${label}:</span>${value}</div>`
            );

        const note = profileUrl ? getNote(profileUrl) : '';
        const trucated_note = note.split("\n").slice(0,2).join("\n");
        const noteHtml = trucated_note
        ? `<div class="text-info" style="
                opacity:0.75;
                font-family: cursive;
                word-break: break-word;
              ">${trucated_note.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>')}</div>`
            : '';

        if(datas){
            const online = datas['main::online'] === 'Online';
            let spec = datas['main::specialisation'].split(" ").pop();

            tooltipEl.innerHTML = `
                <div class="flex">
                  <div class="flex-shrink-0 mr-1">${online ? '🟢':'⚫'}</div>
                  <div class="flex-1 truncate" ${settings['pref-name-not-colored']?'':'style="color:'+datas['main::color']+'"'}>${datas['main::name']}</div>
                  ${spec === 'Unspecialized' ? '' : `<div class="flex-shrink-0 ml-2">${spec}</div>`}
                </div>
                ${noteHtml}
                ${values.join('')}
                ${online ? '' : `
                  <br>
                  <div style="opacity:0.6"><span style="opacity:0.6;margin-right:4px;">Last Signal:</span>${datas["Presence Log::Last Signal"].split(' ').slice(0,2).join(' ')}</div>
                `}
            `;
        } else {
            tooltipEl.innerHTML = values.join('');
        }
        tooltipEl.style.display = 'block';
        positionTooltip(anchorEl);
    }

    function positionTooltip(anchorEl) {
        const rect = anchorEl.getBoundingClientRect();
        const gap = 8;
        let top = rect.top - tooltipEl.offsetHeight - gap;
        let left = rect.left;
        if (top < 4) top = rect.bottom + gap;
        const maxLeft = window.innerWidth - tooltipEl.offsetWidth - 4;
        if (left > maxLeft) left = maxLeft;
        tooltipEl.style.top = `${Math.max(4, top)}px`;
        tooltipEl.style.left = `${Math.max(4, left)}px`;
    }

    function hideTooltip(anchorEl) {
        tooltipEl.style.display = 'none';
        showOriginalTooltip(anchorEl);
    }

    let hoverTimer = null;
    let abortCtrl = null;
    let currentAnchor = null;

    function cancelPending() {
        clearTimeout(hoverTimer);
        hoverTimer = null;
        if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
    }

    function buildTooltipLines(data, settings) {
        return OPTIONS
            .filter(f => settings[f.id])
            .map(f => {
            const key = `${f.fieldsetLabel}::${f.innerLabel}`;
            const raw = data[key];
            let value = raw !== undefined ? raw : '—';
            if (settings['pref-enable-colors'] && f.format && raw !== undefined) {
                value = f.format(raw);
            }
            return { label: f.displayLabel ?? f.innerLabel, value };
        });
    }

    function onEnter(anchorEl, profileUrl) {
        cancelPending();
        currentAnchor = anchorEl;

        hoverTimer = setTimeout(async () => {
            const settings = loadSettings();
            if (OPTIONS.filter(f => settings[f.id]).length === 0) return;

            showTooltip(anchorEl, [{ label: '⏳', value: 'Loading…' }], null, settings);
            abortCtrl = new AbortController();
            try {
                const data = await fetchProfileData(profileUrl);
                if (!data) {
                    showTooltip(anchorEl, [{ label: '⚠', value: 'No data found' }], null, settings);
                    return;
                }
                const lines = buildTooltipLines(data, settings);
                if (lines.length === 0) hideTooltip(anchorEl);
                else showTooltip(anchorEl, lines, data, settings, profileUrl);
            } catch (e) {
                console.error(e);
                if (e.name !== 'AbortError') {
                    showTooltip(anchorEl, [{ label: '⚠', value: 'Fetch failed' }], null, settings);
                }
            }
        }, HOVER_DELAY_MS);
    }

    function showProfileNoteTooltip(anchorEl, profileUrl) {
        const settings = loadSettings();
        const data = extractProfileData(document);
        if (!data) return;
        data.ts = data.ts ?? Date.now(); // ensure ts for display
        const lines = buildTooltipLines(data, settings);
        showTooltip(anchorEl, lines, data, settings, profileUrl);
    }

    function injectNoteFieldset() {
        if (document.getElementById('nek-note-fieldset')) return;
        const nameBox = [...document.querySelectorAll('div.rounded-box')].find(box =>
                                                                               box.querySelector('.text-xs')?.textContent.trim() === 'Name'
                                                                              );
        if (!nameBox) {
            return;
        }

        const profileUrl = window.location.href;
        const currentNote = getNote(profileUrl);

        const wrapper = document.createElement('div');
        wrapper.id = 'nek-note-fieldset';
        wrapper.className = 'rounded-box border border-base-300 bg-base-100/60 p-2';

        wrapper.innerHTML = `
            <div class="text-xs uppercase tracking-widest text-base-content/60 flex" style="margin-bottom:4px;">
                <div class="flex-1">Note</div><div class="flex-shrink-0" style="opacity:0.4;font-size:9px;text-transform:none;letter-spacing:0;">NekTooltip</div>
            </div>
            <textarea
                id="nek-note-textarea"
                class="textarea textarea-bordered w-full resize-none"
                placeholder="Personal notes…"
                rows="1"
                style="font-size:12px;line-height:1.3;min-height: auto;font-family: cursive;"
            ></textarea>
        `;

        nameBox.insertAdjacentElement('afterend', wrapper);

        const textarea = wrapper.querySelector('#nek-note-textarea');
        textarea.value = currentNote;
        textarea.style.height = textarea.scrollHeight + 1 + "px";

        textarea.addEventListener('focus', () => {
            showProfileNoteTooltip(textarea, profileUrl);
        });

        textarea.addEventListener('input', () => {
            saveNote(profileUrl, textarea.value);
            showProfileNoteTooltip(textarea, profileUrl);
            textarea.style.height = "auto";
            textarea.style.height = textarea.scrollHeight + 1 + "px";
        });

        textarea.addEventListener('blur', () => {
            hideTooltip();
        });

        document.addEventListener('scroll', () => {hideTooltip()}, true);
    }

    function startProfileObserver() {
        function tryInjectNote() {
            if (document.querySelector('.settings-panel')) {
                injectNoteFieldset();
            }
        }
        tryInjectNote();
        const obs = new MutationObserver(tryInjectNote);
        obs.observe(document.documentElement, { childList: true, subtree: true });
    }

    function startLinkObserver() {
        document.addEventListener('mouseover', (e) => {
            const anchor = e.target.closest('a[href^="/workers/"]');
            if (anchor && !anchor.href.endsWith("/online") && anchor !== currentAnchor) {
                anchor.setAttribute('title', '');
                onEnter(anchor, anchor.href);
            }
        }, true);

        document.addEventListener('mouseout', (e) => {
            if (currentAnchor) {
                const anchor = e.target.closest('a[href^="/workers/"]');
                if (anchor === currentAnchor) {
                    const related = e.relatedTarget;
                    if (!related || !anchor.contains(related)) {
                        cancelPending();
                        hideTooltip(currentAnchor);
                        currentAnchor = null;
                    }
                }
            }
        }, true);

        document.addEventListener('mousemove', () => {
            if (tooltipEl.style.display !== 'none' && currentAnchor) {
                positionTooltip(currentAnchor);
            }
        }, true);
    }

    function buildOptionRow(opt, settings) {
        const row = document.createElement('label');
        row.style.cssText = 'display: flex; gap: 8px; padding: 5px 10px; cursor: pointer; align-items: center; user-select: none;';
        if (opt.alt) {
            row.setAttribute('data-tip', opt.alt);
            row.classList.add('tooltip', 'tooltip-right');
        }
        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.checked = !!settings[opt.id];
        cb.classList.add('checkbox', 'checkbox-xs');
        cb.addEventListener('change', () => {
            settings[opt.id] = cb.checked;
            saveSettings(settings);
        });
        const span = document.createElement('span');
        span.textContent = opt.label;
        row.append(cb, span);
        return row;
    }

    function createMenuContent() {
        const settings = loadSettings();
        const container = document.createElement('div');
        container.style.cssText = 'padding: 2px 0;';
        OPTIONS.forEach(opt => container.appendChild(buildOptionRow(opt, settings)));
        const hr = document.createElement('div');
        hr.style.cssText = 'height: 1px; background: #000; opacity: 0.1; margin: 4px 0;';
        container.appendChild(hr);
        PREFERENCES.forEach(opt => container.appendChild(buildOptionRow(opt, settings)));

        const versionNote = document.createElement('div');
        versionNote.textContent = `${EXTENSION_LABEL} v${EXTENSION_VERSION}`;
        versionNote.style.cssText = 'font-size: 10px; color: #777; text-align: right; padding: 4px 10px 2px; pointer-events: none;';
        container.appendChild(versionNote);
        return container;
    }

    function createStandaloneWidget() {
        const wrapper = document.createElement('div');
        wrapper.style.cssText = 'position: relative; display: inline-block;';

        const btn = document.createElement('button');
        btn.textContent = EXTENSION_LABEL;
        btn.style.color = BUTTON_COLOR;
        btn.classList.add('btn', 'btn-ghost', 'btn-sm', 'text-primary');

        const popup = document.createElement('div');
        popup.style.cssText = [
            'display: none',
            'position: absolute',
            'bottom: calc(100% + 8px)',
            'left: 0',
            'min-width: 220px',
            'z-index: 1000',
            'text-align: left',
        ].join('; ');
        popup.classList.add('card', 'bg-base-100', 'border', 'border-base-300', 'p-2', 'text-xs', 'shadow-xl');

        popup.appendChild(createMenuContent());

        btn.addEventListener('click', e => {
            e.stopPropagation();
            popup.style.display = popup.style.display === 'none' ? 'block' : 'none';
        });
        document.addEventListener('click', e => {
            if (!wrapper.contains(e.target)) popup.style.display = 'none';
        });

        wrapper.append(btn, popup);
        return wrapper;
    }

    function injectStandaloneButton() {
        const widget = createStandaloneWidget();

        function tryInject() {
            const target = document.querySelector(INJECT_TARGET);
            if (target && widget.parentElement !== target) {
                target.insertBefore(widget, target.firstChild);
            }
            if (!document.body.contains(tooltipEl)) {
                document.body.appendChild(tooltipEl);
            }
            if (currentAnchor && !document.body.contains(currentAnchor)) {
                cancelPending();
                hideTooltip(currentAnchor);
                currentAnchor = null;
            }
        }

        tryInject();
        const obs = new MutationObserver(tryInject);
        obs.observe(document.documentElement, { childList: true, subtree: true });
    }

    function registerWithNekStyle() {
        window.NekStyle.registerExtension({
            id: EXTENSION_ID,
            label: EXTENSION_LABEL,
            color: BUTTON_COLOR,
            createContent: createMenuContent,
        });
    }

    function init() {
        startLinkObserver();
        startProfileObserver();

        if (typeof window.NekStyle?.registerExtension === 'function') {
            window.NekStyle.registerExtension({
                id: EXTENSION_ID,
                label: EXTENSION_LABEL,
                color: BUTTON_COLOR,
                createContent: createMenuContent,
            });
            const obs = new MutationObserver(() => {
                if (!document.body.contains(tooltipEl)) document.body.appendChild(tooltipEl);
                if (currentAnchor && !document.body.contains(currentAnchor)) {
                    cancelPending();
                    hideTooltip(currentAnchor);
                    currentAnchor = null;
                }
            });
            obs.observe(document.documentElement, { childList: true, subtree: true });
            return;
        }

        let registered = false;
        window.__NekStyleExtensions = window.__NekStyleExtensions || [];
        window.__NekStyleExtensions.push(() => {
            registered = true;
            registerWithNekStyle();
        });

        setTimeout(() => {
            if (!registered) injectStandaloneButton();
        }, 0);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();