Greasy Fork is available in English.
Enhances webpages by identifying airport and multi-airport codes, styling them, and adding interactive tooltips/hyperlinks. Dismiss reverts instances.
当前为
// ==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 = '×';
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 = '×';
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;
}
})();