Greasy Fork

Greasy Fork is available in English.

Nitan Airport Flair

Enhances webpages by identifying airport and multi-airport codes, styling them, and adding interactive tooltips/hyperlinks. Dismiss reverts instances.

当前为 2025-05-15 提交的版本,查看 最新版本

// ==UserScript==
// @name         Nitan Airport Flair
// @namespace    http://tampermonkey.net/
// @version      0.5
// @description  Enhances webpages by identifying airport and multi-airport codes, styling them, and adding interactive tooltips/hyperlinks. Dismiss reverts instances.
// @author       s5kf
// @license      CC BY-NC-ND 4.0; https://creativecommons.org/licenses/by-nc-nd/4.0/
// @match        *://www.uscardforum.com/*
// @resource     airportJsonData https://raw.githubusercontent.com/s5kf/airport_flair/main/airports_filtered.json
// @resource     multiAirportJsonData https://raw.githubusercontent.com/s5kf/airport_flair/main/multi_airport_data.json
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @run-at       document-idle
// @supportURL   https://github.com/s5kf/airport_flair/issues 
// ==/UserScript==

(function() {
    'use strict';

    let airportData = {}; // To store single airport data keyed by IATA code
    let multiAirportData = {}; // To store multi-airport area data
    let observer = null; // To store the MutationObserver instance

    // --- Constants (moved up for early initialization) ---
    const iataRegex = /\b([A-Za-z]{3})\b/g;
    const processedMark = 'airport-flair-processed';
    const flairTag = 'airport-flair-tag'; // Class for the flair span itself
    const potentialFlairClass = 'potential-airport-code'; // New class for potential flairs
    const multiAirportFlairClass = 'multi-airport-flair'; // New class for multi-airport flairs

    // --- Load Data from @resource ---
    function initializeData() {
        let mainDataLoaded = false;
        let multiDataLoaded = false;

        // Load Main Airport Data
        try {
            console.log("[Airport Flair] Attempting to load main airport data from @resource...");
            const airportJsonDataString = GM_getResourceText("airportJsonData");
            if (!airportJsonDataString) {
                console.error("[Airport Flair] Failed to get main airport data. GM_getResourceText returned empty.");
            } else {
                const sanitizedJsonDataString = airportJsonDataString
                    .replace(/: Infinity,/g, ": null,")
                    .replace(/: Infinity}/g, ": null}")
                    .replace(/: NaN,/g, ": null,")
                    .replace(/: NaN}/g, ": null}");
                const data = JSON.parse(sanitizedJsonDataString);
                data.forEach(airport => {
                    if (airport.iata_code) {
                        airportData[airport.iata_code.toUpperCase()] = airport;
                    }
                });
                console.log("[Airport Flair] Main airport data loaded and processed:", Object.keys(airportData).length, "entries");
                mainDataLoaded = true;
            }
        } catch (e) {
            console.error("[Airport Flair] Error loading or parsing main airport data:", e);
            // Optional: log airportJsonDataString snippet if needed
        }

        // Load Multi-Airport Data
        try {
            console.log("[Airport Flair] Attempting to load multi-airport data from @resource...");
            const multiAirportJsonDataString = GM_getResourceText("multiAirportJsonData");
            if (!multiAirportJsonDataString) {
                console.error("[Airport Flair] Failed to get multi-airport data. GM_getResourceText returned empty.");
            } else {
                multiAirportData = JSON.parse(multiAirportJsonDataString); // Assuming this JSON is clean
                console.log("[Airport Flair] Multi-airport data loaded and processed:", Object.keys(multiAirportData).length, "entries");
                multiDataLoaded = true;
            }
        } catch (e) {
            console.error("[Airport Flair] Error loading or parsing multi-airport data:", e);
            // Optional: log multiAirportJsonDataString snippet if needed
        }
        
        return mainDataLoaded; // Script functionality primarily depends on main airport data for flags etc.
                               // Multi-airport data is supplementary.
    }

    // Initialize data and then process the page
    if (initializeData()) {
        processPage();
    } else {
        console.error("[Airport Flair] Script will not run effectively due to critical data loading issues (main airport data).");
    }

    // --- CSS Styles ---
    function getDynamicStyles() {
        const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
        const htmlElement = document.documentElement;
        const bodyElement = document.body;

        let isLikelyDarkMode = prefersDark ||
                               htmlElement.classList.contains('dark') ||
                               bodyElement.classList.contains('dark') ||
                               htmlElement.classList.contains('dark-mode') || // Added common alternative
                               bodyElement.classList.contains('dark-mode');   // Added common alternative

        // Get computed background colors
        const htmlBgColor = getComputedStyle(htmlElement).backgroundColor;
        const bodyBgColor = getComputedStyle(bodyElement).backgroundColor;

        // Function to check if a CSS color string represents a dark color
        function isColorDark(colorString) {
            if (!colorString || colorString === 'transparent' || colorString === 'rgba(0, 0, 0, 0)') return false;
            if (colorString === 'rgb(0, 0, 0)' || colorString === '#000000') return true; // Pure black
            try {
                const rgbMatch = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
                if (rgbMatch) {
                    const r = parseInt(rgbMatch[1], 10);
                    const g = parseInt(rgbMatch[2], 10);
                    const b = parseInt(rgbMatch[3], 10);
                    // Heuristic: if average intensity is low, or all components are relatively low
                    if ((r + g + b) / 3 < 75 || (r < 100 && g < 100 && b < 100)) {
                        return true;
                    }
                }
            } catch (e) {
                // Ignore parsing errors, default to not dark for this check
            }
            return false;
        }

        if (!isLikelyDarkMode) {
            // If no class or media query indicated dark mode, check computed background colors
            if (isColorDark(bodyBgColor) || isColorDark(htmlBgColor)) {
                isLikelyDarkMode = true;
            }
        }

        let flairBgColor = '#eff3f4'; // Default light mode

        if (isLikelyDarkMode) {
            // Now, differentiate between dim and lights out if we've determined it's dark
            // Prioritize body background for "lights out" check, then html
            if (bodyBgColor === 'rgb(0, 0, 0)' || bodyBgColor === '#000000' ||
                htmlBgColor === 'rgb(0, 0, 0)' || htmlBgColor === '#000000') {
                flairBgColor = '#202327'; // Lights out
            } else {
                flairBgColor = '#273440'; // Dim mode (default dark)
            }
        }

        return `
            .${flairTag} {
                display: inline-flex;
                align-items: center;
                vertical-align: baseline;
                font-family: 'Fira Code', 'Roboto Mono', monospace;
                padding: 1px 4px; /* Reduced padding */
                color: #e6c07b; /* Light gold text */
                background-color: ${flairBgColor};
                border-radius: 3px;
                text-decoration: none; /* For the anchor tag */
                margin: 0 1px; /* Small margin to prevent touching adjacent text */
                position: relative; /* For absolute positioning of the dismiss button */
            }
            .${flairTag} img.country-flag {
                width: 16px;
                height: 12px;
                margin-left: 4px; /* Switched from margin-right and slightly increased for balance */
                vertical-align: middle;
            }
            .${flairTag} .dismiss-flair {
                position: absolute;
                top: -6px;
                right: -6px;
                width: 16px;
                height: 16px;
                line-height: 16px;
                text-align: center;
                font-size: 14px;
                font-weight: bold;
                background-color: rgba(74, 74, 74, 0.6); /* Translucent background */
                color: #ffffff;
                border-radius: 50%;
                cursor: pointer;
                opacity: 0;
                visibility: hidden; /* Start hidden */
                /* Only transition opacity and background-color for smoothness */
                transition: opacity 0.15s ease-in-out, background-color 0.15s ease-in-out;
                pointer-events: none; /* Prevent interaction when hidden */
                z-index: 10;
            }
            .${flairTag}:hover .dismiss-flair {
                opacity: 0.85; /* Default visible opacity */
                visibility: visible; /* Become visible */
                pointer-events: auto; /* Allow interaction when visible */
            }
            .${flairTag} .dismiss-flair:hover {
                opacity: 1; /* Full opacity on button hover */
                background-color: rgba(51, 51, 51, 0.85); /* Darker, slightly more opaque on hover */
            }
            .${potentialFlairClass} {
                text-decoration: underline dotted rgba(128, 128, 128, 0.7);
                cursor: help;
                /* Ensure it doesn't pick up parent link styles if it's inside an <a> not yet processed */
                color: inherit; 
            }
            .${potentialFlairClass}:hover {
                text-decoration-color: rgba(100, 100, 100, 1); /* Darker underline on hover */
                background-color: rgba(200, 200, 200, 0.1); /* Very subtle hover background */
            }
            /* No specific hover title CSS needed if using default browser title attribute */

            /* Styles for Multi-Airport Flairs */
            .${multiAirportFlairClass} {
                display: inline-flex;
                align-items: center;
                vertical-align: baseline;
                font-family: 'Fira Code', 'Roboto Mono', monospace;
                padding: 1px 4px;
                color: #d19a66; /* Slightly different text color, e.g., a bit more orange */
                background-color: #3a3d41; /* Different background for dark mode by default */
                border-radius: 3px;
                text-decoration: none;
                margin: 0 1px;
                position: relative;
            }
            /* Dynamic background for multi-airport flairs based on theme */
            /* Light mode for multi-airport */
            html:not(.dark) body:not(.dark) .${multiAirportFlairClass},
            body:not([style*="background-color: rgb(0, 0, 0)"]):not([style*="background-color: #000"]) .${multiAirportFlairClass} { 
                background-color: #e0e0e0; /* Lighter grey for light mode */
                color: #8c5a32; 
            }
            html.dark .${multiAirportFlairClass}, 
            body.dark .${multiAirportFlairClass},
            body[style*="background-color: rgb(0, 0, 0)"] .${multiAirportFlairClass},
            body[style*="background-color: #000"] .${multiAirportFlairClass} {
                 background-color: #273440; /* Dim mode dark */
                 color: #e6c07b; /* Revert to standard flair text color in dark mode */
            }
            /* Consider a specific lights-out color if needed, for now dim is fine */

            .${multiAirportFlairClass} img.country-flag {
                width: 16px;
                height: 12px;
                margin-left: 4px;
                vertical-align: middle;
            }
            /* No specific hover title CSS needed */

            /* The dismiss button for multi-airport should also be covered by a combined selector or specific one */
            /* Combining dismiss button hover selectors for robustness */
            .${flairTag}:hover .dismiss-flair,
            .${multiAirportFlairClass}:hover .dismiss-flair {
                opacity: 0.85;
                visibility: visible;
                pointer-events: auto;
            }
            .${flairTag} .dismiss-flair:hover,
            .${multiAirportFlairClass} .dismiss-flair:hover {
                opacity: 1;
                background-color: rgba(51, 51, 51, 0.85);
            }
        `;
    }

    function injectStyles() {
        // Remove existing styles if they exist to update dynamic ones (like dark mode)
        const existingStyleId = 'airport-flair-styles';
        let existingStyleElement = document.getElementById(existingStyleId);
        if (existingStyleElement) {
            existingStyleElement.remove();
        }
        GM_addStyle(getDynamicStyles());
        // Add an ID to the style element for easy removal/update if needed
        const styleElements = document.head.getElementsByTagName('style');
        if(styleElements.length > 0) {
             // Assume the last style element added by GM_addStyle is ours if we don't have a direct way to ID it from GM_addStyle
            styleElements[styleElements.length - 1].id = existingStyleId;
        }
    }

    // --- DOM Interaction & Manipulation ---
    function createFlairElement(code, airportInfo, originalCasing = null) {
        const anchor = document.createElement('a');
        anchor.href = `https://www.google.com/search?q=airport+${encodeURIComponent(code)}`;
        anchor.target = '_blank';
        anchor.rel = 'noopener noreferrer';
        anchor.classList.add(flairTag); // Standard flair tag
        anchor.classList.add(processedMark);

        let titleText = `${airportInfo.name} (${code})`;
        if (airportInfo.municipality) titleText += `, ${airportInfo.municipality}`;
        if (airportInfo.iso_country) titleText += `, ${airportInfo.iso_country}`;
        anchor.title = titleText;

        const codeTextNode = document.createTextNode(code);
        anchor.appendChild(codeTextNode); // Text first

        if (airportInfo.iso_country) {
            const flagImg = document.createElement('img');
            flagImg.src = `https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/4.1.5/flags/4x3/${airportInfo.iso_country.toLowerCase()}.svg`;
            flagImg.alt = `${airportInfo.iso_country} flag`;
            flagImg.classList.add('country-flag');
            anchor.appendChild(flagImg); // Flag after text
        }
        
        // Dismiss Button for single airport flairs
        const dismissBtn = document.createElement('span');
        dismissBtn.classList.add('dismiss-flair');
        dismissBtn.innerHTML = '&times;';
        dismissBtn.dataset.code = code; 
        dismissBtn.dataset.type = 'single-airport'; // Explicitly mark type

        if (originalCasing && originalCasing !== code) {
            dismissBtn.dataset.originalCasing = originalCasing;
            dismissBtn.title = `Revert to '${originalCasing}' (potential code)`;
        } else {
            dismissBtn.title = `Revert to '${code}' (potential code)`;
        }

        dismissBtn.addEventListener('click', function(event) {
            console.log("[Airport Flair] Single Airport Dismiss button clicked:", event.target.dataset.code);
            event.preventDefault();
            event.stopPropagation();

            const currentCode = event.target.dataset.code;
            const originalMixedCasing = event.target.dataset.originalCasing;
            const currentFlairElement = event.target.closest('.' + flairTag);

            const codeForPotential = originalMixedCasing || currentCode;
            const airportInfoForReversion = airportData[codeForPotential.toUpperCase()];

            if (currentFlairElement && currentFlairElement.parentNode) {
                if (airportInfoForReversion) {
                    const potentialElement = createPotentialFlairElement(codeForPotential, airportInfoForReversion, false);
                    currentFlairElement.parentNode.replaceChild(potentialElement, currentFlairElement);
                } else {
                    const revertedTextSpan = document.createElement('span');
                    revertedTextSpan.textContent = codeForPotential;
                    revertedTextSpan.classList.add(processedMark);
                    currentFlairElement.parentNode.replaceChild(revertedTextSpan, currentFlairElement);
                }
            }
        });
        anchor.appendChild(dismissBtn);
        return anchor;
    }

    // Modified createPotentialFlairElement to handle both single and multi-airport types
    function createPotentialFlairElement(originalCode, info, isMultiAirport = false) {
        const span = document.createElement('span');
        span.classList.add(potentialFlairClass);
        span.classList.add(processedMark);
        span.textContent = originalCode;
        
        let titleText = `Recognize ${originalCode.toUpperCase()} as an `;
        if (isMultiAirport && info && info.name) {
            titleText += `area? (${info.name})`;
        } else if (!isMultiAirport && info && info.name) {
            titleText += `airport? (${info.name})`;
        } else {
            titleText += `code?`; // Fallback
        }
        span.title = titleText;

        span.dataset.originalCode = originalCode;
        span.dataset.isMulti = isMultiAirport ? "true" : "false";

        const clickListener = function(event) {
            event.preventDefault();
            event.stopPropagation();

            const codeToConvert = event.target.dataset.originalCode;
            const wasMulti = event.target.dataset.isMulti === "true";
            const uppercaseCode = codeToConvert.toUpperCase();
            
            let fullFlairElement;
            if (wasMulti) {
                const maInfo = multiAirportData[uppercaseCode];
                if (maInfo) {
                    fullFlairElement = createMultiAirportFlairElement(uppercaseCode, maInfo, codeToConvert);
                }
            } else {
                const airportInfo = airportData[uppercaseCode];
                if (airportInfo) {
                    fullFlairElement = createFlairElement(uppercaseCode, airportInfo, codeToConvert);
                }
            }

            if (fullFlairElement && event.target.parentNode) {
                event.target.parentNode.replaceChild(fullFlairElement, event.target);
            }
        };
        span.addEventListener('click', clickListener, { once: true });
        return span;
    }

    function replaceTextWithFlair(textNode) {
        if (!textNode.parentNode || textNode.parentNode.classList.contains(processedMark) ||
            textNode.parentNode.closest('a, script, style, input, textarea, [contenteditable="true"]')) {
            return;
        }

        const text = textNode.nodeValue;
        let match;
        let lastIndex = 0;
        const fragment = document.createDocumentFragment();
        let foundMatch = false;

        while ((match = iataRegex.exec(text)) !== null) {
            const originalCode = match[1];
            const uppercaseCode = originalCode.toUpperCase();

            const airportInfo = airportData[uppercaseCode];
            const maInfo = multiAirportData[uppercaseCode];

            if (airportInfo || maInfo) {
                foundMatch = true;
                if (match.index > lastIndex) {
                    fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
                }

                if (originalCode === uppercaseCode) { // Already all caps
                    if (airportInfo) {
                        fragment.appendChild(createFlairElement(uppercaseCode, airportInfo));
                    } else if (maInfo) {
                        fragment.appendChild(createMultiAirportFlairElement(uppercaseCode, maInfo));
                    }
                } else { // Mixed or lowercase - create potential
                    if (airportInfo) {
                        fragment.appendChild(createPotentialFlairElement(originalCode, airportInfo, false));
                    } else if (maInfo) {
                        fragment.appendChild(createPotentialFlairElement(originalCode, maInfo, true));
                    }
                }
                lastIndex = iataRegex.lastIndex;
            }
        }

        if (foundMatch) {
            if (lastIndex < text.length) {
                fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
            }
            textNode.parentNode.replaceChild(fragment, textNode);
        }
    }

    function processNode(node) {
        if (node.nodeType === Node.TEXT_NODE) {
            replaceTextWithFlair(node);
        }
        else if (node.nodeType === Node.ELEMENT_NODE && 
                 !node.classList.contains(processedMark) &&
                 !node.closest('a, script, style, input, textarea, [contenteditable="true"], .' + flairTag + ', .' + potentialFlairClass + ', .' + multiAirportFlairClass)) {
             Array.from(node.childNodes).forEach(child => processNode(child));
        }
    }

    function processAddedNodes(mutationList) {
        for (const mutation of mutationList) {
            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach(node => {
                    // Check if the node itself has already been processed (e.g. if it's part of a flair we just added)
                    if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains(processedMark)) {
                        return;
                    }
                    processNode(node);
                });
            }
        }
        // After processing mutations, it's good to re-evaluate dynamic styles like dark mode
        injectStyles();
    }

    function observeDOMChanges() {
        if (observer) observer.disconnect(); // Disconnect previous observer if any

        observer = new MutationObserver(processAddedNodes);
        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
        console.log("MutationObserver started.");
    }

    function processPage() {
        console.log("Processing page for airport codes...");
        injectStyles();
        // Initial scan of the entire body
        processNode(document.body);
        observeDOMChanges();
    }

    // --- Cleanup ---
    window.addEventListener('unload', () => {
        if (observer) {
            observer.disconnect();
            console.log("Disconnected MutationObserver.");
        }
    });

    // --- Create Flair Elements ---
    // Function to create the actual styled flair element for MULTI-AIRPORT codes
    function createMultiAirportFlairElement(code, maInfo, originalCasing = null) {
        const anchor = document.createElement('a');
        anchor.href = `https://www.google.com/search?q=${encodeURIComponent(maInfo.name)}`; // Search for the area name
        anchor.target = '_blank';
        anchor.rel = 'noopener noreferrer';
        anchor.classList.add(multiAirportFlairClass); // Use special class
        anchor.classList.add(processedMark);

        anchor.title = `${maInfo.name} (${code})`;

        // Flag logic: use primaryAirportForFlag from maInfo to look up in airportData
        if (maInfo.primaryAirportForFlag && airportData[maInfo.primaryAirportForFlag]) {
            const primaryAirportDetails = airportData[maInfo.primaryAirportForFlag];
            if (primaryAirportDetails.iso_country) {
                const flagImg = document.createElement('img');
                flagImg.src = `https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/4.1.5/flags/4x3/${primaryAirportDetails.iso_country.toLowerCase()}.svg`;
                flagImg.alt = `${primaryAirportDetails.iso_country} flag`;
                flagImg.classList.add('country-flag');
                anchor.appendChild(flagImg); // Flag first for multi-airport to differentiate?
            }
        }

        const codeTextNode = document.createTextNode(code);
        if (anchor.firstChild && anchor.firstChild.tagName === 'IMG') { // If flag was added, append text after
            anchor.appendChild(codeTextNode);
        } else { // Otherwise, text is first
            anchor.insertBefore(codeTextNode, anchor.firstChild);
        }

        // Add Dismiss Button
        const dismissBtn = document.createElement('span');
        dismissBtn.classList.add('dismiss-flair');
        dismissBtn.innerHTML = '&times;';
        dismissBtn.dataset.code = code; // Uppercase code of the flair
        dismissBtn.dataset.type = 'multi-airport'; // Mark type for potential differentiated dismiss logic

        // Determine title and if originalCasing needs to be stored for dismiss
        if (originalCasing && originalCasing !== code) {
            dismissBtn.dataset.originalCasing = originalCasing;
            dismissBtn.title = `Revert to '${originalCasing}' (potential code)`;
        } else {
            dismissBtn.title = `Revert to '${code}' (potential code)`;
        }

        dismissBtn.addEventListener('click', function(event) {
            console.log("[Airport Flair] Multi-Airport Dismiss button clicked:", event.target.dataset.code);
            event.preventDefault();
            event.stopPropagation();

            const currentCode = event.target.dataset.code;
            const originalMixedCasing = event.target.dataset.originalCasing;
            const currentFlairElement = event.target.closest('.' + multiAirportFlairClass);
            
            const codeForPotential = originalMixedCasing || currentCode;
            const maInfoForReversion = multiAirportData[codeForPotential.toUpperCase()];
            // Also check regular airport data in case it's an ambiguous code that got here
            const airportInfoForReversion = airportData[codeForPotential.toUpperCase()];

            if (currentFlairElement && currentFlairElement.parentNode) {
                const infoForPotential = maInfoForReversion || airportInfoForReversion; // Prefer MA info if available
                if (infoForPotential) {
                    const potentialElement = createPotentialFlairElement(codeForPotential, infoForPotential, !!maInfoForReversion);
                    currentFlairElement.parentNode.replaceChild(potentialElement, currentFlairElement);
                } else {
                    // Fallback to plain text if no info found (should be rare)
                    const revertedTextSpan = document.createElement('span');
                    revertedTextSpan.textContent = codeForPotential;
                    revertedTextSpan.classList.add(processedMark);
                    currentFlairElement.parentNode.replaceChild(revertedTextSpan, currentFlairElement);
                }
            }
        });
        anchor.appendChild(dismissBtn);
        return anchor;
    }

})();