// ==UserScript==
// @name Inara Elite Dangerous Station Popover
// @namespace moonbeeper's greasy scripts
// @match https://inara.cz/elite/*
// @grant GM_xmlhttpRequest
// @version 1.0
// @author moonbeeper
// @description Show a popover with station info when hovering over station links
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const bgColor = '#2a2a2a';
const popover = document.createElement('div');
popover.style.cssText = `
position: absolute;
background: ${bgColor};
padding: 12px;
border-radius: 3px;
box-shadow: 5px 5px 10px 0px rgba(0,0,0,.05);
max-width: 300px;
z-index: 99999999999999999999999999999999999999999999; // yup, that's enough
display: none;
pointer-events: none;
border: 1px solid #3a3a3a;
`;
const arrow = document.createElement('div');
arrow.id = 'greasty-inara-arrow';
function getArrow(pointingUp) {
let arrow = popover.querySelector('#greasty-inara-arrow');
if (!arrow) {
const arrow = document.createElement('div');
arrow.id = 'greasty-inara-arrow';
popover.appendChild(newArrow);
arrow = newArrow;
}
if (pointingUp) {
arrow.style.cssText = `
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid ${bgColor};
`;
console.log('arrow pointing up');
} else {
arrow.style.cssText = `
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid ${bgColor};
`;
console.log('arrow pointing down');
}
}
popover.appendChild(arrow);
document.body.appendChild(popover);
function getStationId(href) {
const match = href.match(/\/elite\/station(?:-market)?\/(\d+)\//);
return match ? match[1] : null;
}
async function showPopover2(stationId, link) {
popover.innerHTML = `
<div style="margin-bottom: 8px; font-weight: bold;">Fetching station data...</div>
<div style="font-size: 12px; opacity: 0.8;">Station ID: ${stationId}</div>
` + arrow.outerHTML;
requestAnimationFrame(() => positionPopover(link));
try {
const data = await fetchStationData(stationId);
if (!data) {
console.warn(`No data found for station ID ${stationId}`);
throw new Error(`No data found for station ID ${stationId}`);
}
let servicesHtml = '';
// not really possible to have no services, but just in case :)
if (data.services.length > 0) {
const displayServices = data.services.slice(0, 8); // show first 8
const remainingCount = data.services.length - 8;
servicesHtml = `
${itemPar('Station Services', '')}
<div class="tagcontainer">
${displayServices.map(service =>
`<span class="tag taginline minor nowrap">${service}</span>`
).join('')}
${remainingCount > 0 ? `<span style="font-size: 11px; color: #a0a09e;">+${remainingCount} more</span>` : ''}
</div>
`;
}
popover.innerHTML = `
<div style="margin-bottom: 8px;">
${/*itemPar('Station Name', data.name)*/ ''}
${itemPar('System', data.system)}
${itemPar('Station Type', data.stationType)}
${itemPar('Landing Pad', data.landingPad)}
${itemPar('Allegiance', data.allegiance)}
${itemPar('Minor Faction', data.faction)}
${itemPar('Last Update', data.lastUpdate)}
${servicesHtml}
</div>
<div style="font-size: 12px; opacity: 0.8;">Station ID: ${stationId}</div>
` + arrow.outerHTML;
requestAnimationFrame(() => positionPopover(link));
} catch (error) {
console.error('Error fetching station data:', error);
popover.innerHTML = `
<div style="margin-bottom: 8px; color: #f54040; font-weight: bold;">Error fetching station data</div>
<div style="font-size: 12px; opacity: 0.8;">Station ID: ${stationId}</div>
` + arrow.outerHTML;
requestAnimationFrame(() => positionPopover(link));
}
}
function itemPar(label, value) {
if (!value) {
return '';
}
return `
<div class="itempaircontainer">
<div class="itempairlabel" style="width: 150px;">${label}</div>
<div class="itempairvalue">${value}</div>
</div>
`
}
function positionPopover(link) {
const linkBoundingBox = link.getBoundingClientRect();
const popoverBoundingBox = popover.getBoundingClientRect();
let top = linkBoundingBox.top + window.scrollY - popoverBoundingBox.height - 8;
let left = linkBoundingBox.left + window.scrollX + (linkBoundingBox.width / 2) - (popoverBoundingBox.width / 2);
// if the a tag is too close to the edge, move it to the right
if (left < 10) {
left = 10;
}
// if there's not enough space above, position below
if (linkBoundingBox.top < popoverBoundingBox.height + 20) {
top = linkBoundingBox.bottom + window.scrollY + 8;
getArrow(true);
} else {
getArrow(false);
}
popover.style.left = left + 'px';
popover.style.top = top + 'px';
}
const stationFetchCache = new Map();
async function fetchStationData(stationId) {
if (stationFetchCache.has(stationId)) {
return stationFetchCache.get(stationId);
}
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://inara.cz/elite/station/${stationId}/`,
onload: function(response) {
if (response.status === 200) {
const stationData = parseStationData(response.responseText);
stationFetchCache.set(stationId, stationData);
resolve(stationData);
} else {
resolve(null);
}
},
onerror: function() {
resolve(null);
}
});
});
}
function parseStationData(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const data = {
name: '',
system: '',
stationType: '',
landingPad: '',
services: [],
allegiance: '',
faction: '',
lastUpdate: ''
};
try {
const getTextContent = (element) => element?.textContent?.trim() || '';
// get station name
const stationNameElement = doc.querySelector('h2 a.standardcolor');
data.name = getTextContent(stationNameElement);
// get system name
const systemElement = doc.querySelector('h2 a[href*="/elite/starsystem/"]');
data.system = getTextContent(systemElement);
// get basic station info from the mini grid below the station name
const itemPairs = doc.querySelectorAll('.itempaircontainer');
console.log('itemPairs', itemPairs);
const labelMap = {
'Landing pad': 'landingPad',
'Station type': 'stationType',
'Allegiance': 'allegiance',
'Minor faction': 'faction',
'Station update': 'lastUpdate'
};
itemPairs.forEach(pair => {
const label = pair.querySelector('.itempairlabel');
const value = pair.querySelector('.itempairvalue');
if (label && value) {
const labelText = getTextContent(label);
const valueText = getTextContent(value);
const dataKey = labelMap[labelText];
if (dataKey && valueText) {
if (dataKey === 'faction') {
const factionElement = value.querySelector('a[href*="/elite/minorfaction/"]');
if (factionElement) {
data[dataKey] = getTextContent(factionElement);
}
} else {
data[dataKey] = valueText;
}
}
}
});
const tagContainers = doc.querySelectorAll('.tagcontainer');
const servicesSet = new Set(); // no duplicate services
console.log('tagContainers', tagContainers);
tagContainers.forEach(container => {
const serviceTags = container.querySelectorAll('.tag');
serviceTags.forEach(tag => {
const serviceText = getTextContent(tag);
const isGrayedOut = tag.style.opacity === '0.25';
if (serviceText &&
serviceText.length > 0 &&
serviceText.length < 100 &&
!isGrayedOut) {
servicesSet.add(serviceText);
}
});
});
data.services = Array.from(servicesSet);
} catch (error) {
console.error('Error parsing station data:', error);
return {
name: 'Parse Error',
system: '',
stationType: '',
distance: '',
landingPad: '',
services: [],
economy: '',
allegiance: '',
government: '',
faction: '',
lastUpdate: ''
};
}
console.log('Parsed station data:', data);
return data;
}
document.addEventListener('mouseover', function(event) {
const link = event.target.closest('a[href*="/elite/station/"], a[href*="/elite/station-market/"]');
if (!link) return;
const stationId = getStationId(link.href);
console.log('Station ID:', stationId);
if (!stationId) return;
showPopover2(stationId, link);
popover.style.display = 'block';
requestAnimationFrame(() => positionPopover(link));
});
document.addEventListener('mouseout', function(event) {
const link = event.target.closest('a[href*="/elite/station/"], a[href*="/elite/station-market/"]');
if (!link) return;
popover.style.display = 'none';
});
document.addEventListener('scroll', function() {
popover.style.display = 'none';
});
})();