/* global IITC, L -- eslint */
/**
* Namespace that renders portal details in the sidebar and highlights the selected portal on the map.
*
* @memberof IITC.portal
* @namespace display
*/
/**
* Template of the "move to portal" icon shown next to the portal title
* @type {String}
* @memberof IITC.portal.display
*/
let moveToPortalIconTemplate = '<svg class="material-icons icon-button"><use xlink:href="#ic_place_24px"></use><title>Click to move to portal</title></svg>';
/**
* Template wrapping the #randdetails table (owner, range, links, fields, shielding, energy, AP, hacks)
* @type {String}
* @memberof IITC.portal.display
*/
let randDetailsTemplate = '<table id="randdetails">{rows}</table>';
/**
* Template of the artifact target portal note
* @type {String}
* @memberof IITC.portal.display
*/
let artifactTargetTemplate = '<div id="artifact_target">Target portal: {targets}</div>';
/**
* Template of the artifact shards note
* @type {String}
* @memberof IITC.portal.display
*/
let artifactFragmentsTemplate = '<div id="artifact_fragments">Shards: {name} #{fragments}</div>';
/**
* Template wrapping the portal mods block
* @type {String}
* @memberof IITC.portal.display
*/
let modsWrapperTemplate = '<div class="mods">{mods}</div>';
/**
* Template shown while full portal details are still loading
* @type {String}
* @memberof IITC.portal.display
*/
let loadingStatusTemplate = '<div id="portalStatus">Loading details...</div>';
/**
* Template of the container that holds the portal links (permalink, scanner, map links)
* @type {String}
* @memberof IITC.portal.display
*/
let linkDetailsTemplate = '<div class="linkdetails"></div>';
/**
* Template of a label that truncates with an ellipsis (e.g. "attack frequency", "force amplifier")
* @type {String}
* @memberof IITC.portal.display
*/
let ellipsisLabelTemplate = '<span title="{label}" class="text-overflow-ellipsis">{label}</span>';
/**
* Resets the scroll position of the sidebar when a new portal is selected.
*
* @memberof IITC.portal.display
*/
const resetScroll = function () {
if (window.selectedPortal !== IITC.portal.display.renderDetails.lastVisible) {
// another portal selected so scroll position become irrelevant to new portal details
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebar.scrollTop = 0; // NB: this works ONLY when #sidebar:visible
}
};
/**
* Generates and displays URLs related to the portal.
* This includes a permalink for the portal, a link for Ingress Prime, and links to alternative maps.
* Function is overwritten in `app.js`
*
* @memberof IITC.portal.display
* @param {number} lat - The latitude of the portal.
* @param {number} lng - The longitude of the portal.
* @param {string} title - The title of the portal.
* @param {string} guid - The GUID of the portal.
*/
const renderUrl = function (lat, lng, title, guid) {
const linkDetails = document.querySelector('.linkdetails');
const appendAside = (anchor) => {
const aside = document.createElement('aside');
aside.append(anchor);
linkDetails.append(aside);
};
// a permalink for the portal
const permaLink = document.createElement('a');
permaLink.setAttribute('href', IITC.portal.display.makePermalink([lat, lng]));
permaLink.setAttribute('title', 'Create a URL link to this portal');
permaLink.textContent = 'Portal link';
appendAside(permaLink);
const scannerLink = document.createElement('a');
scannerLink.setAttribute('href', IITC.portal.display.makePrimeLink(guid, lat, lng));
scannerLink.setAttribute('title', 'Copy link to this portal for Ingress Prime');
scannerLink.textContent = 'Copy scanner link';
scannerLink.addEventListener('click', function (event) {
navigator.clipboard.writeText(event.target.href).then();
event.preventDefault();
event.stopPropagation();
});
appendAside(scannerLink);
// and a map link popup dialog
const mapLink = document.createElement('a');
mapLink.setAttribute('title', 'Link to alternative maps (Google, etc)');
mapLink.textContent = 'Map links';
mapLink.addEventListener('click', () => IITC.portal.display.tools.showPosLinks(lat, lng, title));
appendAside(mapLink);
};
/**
* Selects a portal, refresh its data and renders the details of the portal in the sidebar.
*
* @memberof IITC.portal.display
* @param {string|null} guid - The globally unique identifier of the portal to display details for.
* @param {boolean} [forceSelect=false] - If true, forces the portal to be selected even if it's already the current portal.
*/
const renderDetails = function (guid, forceSelect) {
if (forceSelect || window.selectedPortal !== guid) {
IITC.portal.display.select(guid && window.portals[guid] ? guid : null, 'renderPortalDetails');
}
const sidebar = document.getElementById('sidebar');
// equivalent of jQuery ':visible'
if (sidebar && (sidebar.offsetWidth > 0 || sidebar.offsetHeight > 0 || sidebar.getClientRects().length > 0)) {
IITC.portal.display.resetScroll();
IITC.portal.display.renderDetails.lastVisible = guid;
}
if (guid && !IITC.portal.details.isFresh(guid)) {
IITC.portal.details.request(guid);
}
if (!guid || !window.portals[guid]) {
IITC.portal.selectWhenLoadedByGuid(guid);
const portalDetails = document.getElementById('portaldetails');
if (portalDetails) portalDetails.innerHTML = '';
IITC.statusbar.portal.update();
if (window.isSmartphone()) {
document.querySelectorAll('.fullimg').forEach((el) => el.remove());
}
return;
}
IITC.portal.display.renderToSidebar(window.portals[guid]);
};
/**
* Renders the details of a portal in the sidebar.
*
* @memberof IITC.portal.display
* @param {L.PortalMarker} portal - The portal marker object holding portal details.
*/
const renderToSidebar = function (portal) {
const guid = portal.options.guid;
const details = portal.getDetails();
const hasFullDetails = portal.hasFullDetails();
const historyDetails = IITC.portal.display.tools.getHistoryDetails(details);
const modDetails = hasFullDetails ? L.Util.template(IITC.portal.display.modsWrapperTemplate, { mods: IITC.portal.display.tools.getModDetails(details) }) : '';
const miscDetails = hasFullDetails ? IITC.portal.display.getMiscDetails(guid, details) : '';
const resoDetails = hasFullDetails ? IITC.portal.display.tools.getResonatorDetails(details) : '';
// TODO? other status details...
const statusDetails = hasFullDetails ? '' : IITC.portal.display.loadingStatusTemplate;
const img = IITC.portal.fixImageUrl(details.image);
const title = details.title || 'null';
const lat = details.latE6 / 1e6;
const lng = details.lngE6 / 1e6;
const imgTitle = title + '\n\nClick to show full image.';
// portal level. start with basic data - then extend with fractional info in tooltip if available
const levelInt = portal.options.level;
let levelDetails = levelInt;
if (hasFullDetails) {
const portalLevel = IITC.portal.getLevel(details); // resonator-based fractional level
levelDetails = portalLevel;
if (levelInt !== 8) {
const req_reso_levels = 8 * (levelInt + 1 - portalLevel);
levelDetails += '\n' + req_reso_levels + ' resonator level(s) needed for next portal level';
} else {
levelDetails += '\nfully upgraded';
}
}
levelDetails = 'Level ' + levelDetails;
const portalDetails = document.getElementById('portaldetails');
portalDetails.innerHTML = ''; // to ensure it's clear
portalDetails.setAttribute('class', window.TEAM_TO_CSS[window.teamStringToId(details.team)]);
// h3.title is kept for supporting plugins that reference it.
const header = document.createElement('h3');
header.setAttribute('id', 'portaltitle');
header.setAttribute('class', 'title');
header.innerHTML = IITC.portal.display.moveToPortalIconTemplate;
header.firstElementChild.addEventListener('click', function () {
IITC.portal.zoomToAndShow(guid, [details.latE6 / 1e6, details.lngE6 / 1e6]);
if (window.isSmartphone()) {
window.show('map');
}
});
const titleValue = document.createElement('span');
titleValue.setAttribute('class', 'value');
titleValue.textContent = title;
header.append(titleValue);
const closeButton = document.createElement('span');
closeButton.setAttribute('class', 'close');
closeButton.setAttribute('title', 'Close [w]');
closeButton.setAttribute('accesskey', 'w');
closeButton.textContent = 'X';
closeButton.addEventListener('click', function () {
IITC.portal.display.renderDetails(null);
});
header.append(closeButton);
// help cursor via ".imgpreview img"
const imgPreview = document.createElement('div');
imgPreview.setAttribute('class', 'imgpreview');
imgPreview.setAttribute('title', imgTitle);
imgPreview.setAttribute('style', 'background-image: url("' + img + '")');
const levelSpan = document.createElement('span');
levelSpan.setAttribute('id', 'level');
levelSpan.setAttribute('title', levelDetails);
levelSpan.textContent = levelInt;
const fullImg = document.createElement('img');
fullImg.setAttribute('class', 'hide');
fullImg.setAttribute('src', img);
imgPreview.append(levelSpan, fullImg);
portalDetails.append(header, imgPreview);
portalDetails.insertAdjacentHTML(
'beforeend',
modDetails + miscDetails + resoDetails + statusDetails + IITC.portal.display.linkDetailsTemplate + historyDetails
);
IITC.portal.display.renderUrl(lat, lng, title, guid);
// only run the hooks when we have a portalDetails object - most plugins rely on the extended data
// TODO? another hook to call always, for any plugins that can work with less data?
if (hasFullDetails) {
// compatibility
const data = IITC.portal.getSummaryData(details);
window.runHooks('portalDetailsUpdated', { guid: guid, portal: portal, portalDetails: details, portalData: data });
IITC.portal.display.setIndicators(portal);
}
};
/**
* Gets miscellaneous details for a specified portal.
*
* @memberof IITC.portal.display
* @param {string} guid - The GUID of the portal.
* @param {Object} d - The portal detail object containing various properties of the portal.
* @returns {string} HTML string representing the miscellaneous details of the portal.
*/
const getMiscDetails = function (guid, d) {
let randDetails;
if (d) {
// collect some random data that’s not worth to put in an own method
const linkInfo = IITC.portal.getLinks(guid);
const maxOutgoing = IITC.portal.getMaxOutgoingLinks(d);
const linkCount = linkInfo.in.length + linkInfo.out.length;
const links = { incoming: linkInfo.in.length, outgoing: linkInfo.out.length };
const title =
`at most ${maxOutgoing} outgoing links\n` +
`${links.outgoing} links out\n` +
`${links.incoming} links in\n` +
`(${links.outgoing + links.incoming} total)`;
const linksText = ['links', links.outgoing + ' out / ' + links.incoming + ' in', title];
const player = d.owner ? L.Util.template(IITC.portal.display.tools.nicknameTemplate, { nick: d.owner }) : '-';
const playerText = ['owner', player];
const fieldCount = IITC.portal.getFieldsCount(guid);
const fieldsText = ['fields', fieldCount];
const apGainText = IITC.portal.display.tools.getAttackApGainText(d, fieldCount, linkCount);
const attackValues = IITC.portal.getAttackValues(d);
// collect and html-ify random data
const randDetailsData = [
// these pieces of data are only relevant when the portal is captured
// maybe check if portal is captured and remove?
// But this makes the info panel look rather empty for unclaimed portals
playerText,
IITC.portal.display.tools.getRangeText(d),
linksText,
fieldsText,
IITC.portal.display.tools.getMitigationText(d, linkCount),
IITC.portal.display.tools.getEnergyText(d),
// and these have some use, even for uncaptured portals
apGainText,
IITC.portal.display.tools.getHackDetailsText(d),
];
if (attackValues.attack_frequency !== 0) {
const label = L.Util.template(IITC.portal.display.ellipsisLabelTemplate, { label: 'attack frequency' });
randDetailsData.push([label, '×' + attackValues.attack_frequency]);
}
if (attackValues.hit_bonus !== 0) {
randDetailsData.push(['hit bonus', attackValues.hit_bonus + '%']);
}
if (attackValues.force_amplifier !== 0) {
const label = L.Util.template(IITC.portal.display.ellipsisLabelTemplate, { label: 'force amplifier' });
randDetailsData.push([label, '×' + attackValues.force_amplifier]);
}
randDetails = L.Util.template(IITC.portal.display.randDetailsTemplate, { rows: window.genFourColumnTable(randDetailsData) });
// artifacts - tacked on after (but not as part of) the 'randdetails' table
// instead of using the existing columns....
if (d.artifactBrief && d.artifactBrief.target && Object.keys(d.artifactBrief.target).length > 0) {
const targets = Object.keys(d.artifactBrief.target);
// currently (2015-07-10) we no longer know the team each target portal is for - so we'll just show the artifact type(s)
randDetails += L.Util.template(IITC.portal.display.artifactTargetTemplate, {
targets: targets.map((x) => x.capitalize()).join(', '),
});
}
// shards - taken directly from the portal details
if (d.artifactDetail) {
randDetails += L.Util.template(IITC.portal.display.artifactFragmentsTemplate, {
name: d.artifactDetail.displayName,
fragments: d.artifactDetail.fragments.join(', '),
});
}
}
return randDetails;
};
/**
* The function adds circles indicating the hack range and link range of the portal.
* If the portal object are null, the indicators are removed.
*
* @memberof IITC.portal.display
* @param {Object} p - The portal object for which to set the indicators.
*/
const setIndicators = function (p) {
if (window.portalRangeIndicator) window.map.removeLayer(window.portalRangeIndicator);
window.portalRangeIndicator = null;
if (window.portalAccessIndicator) window.map.removeLayer(window.portalAccessIndicator);
window.portalAccessIndicator = null;
// if we have a portal...
if (p) {
const coord = p.getLatLng();
// range is only known for sure if we have portal details
// TODO? render a min range guess until details are loaded..?
const d = IITC.portal.details.get(p.options.guid);
if (d) {
const range = IITC.portal.getRange(d);
window.portalRangeIndicator = (
range.range > 0
? L.geodesicCircle(coord, range.range, {
fill: false,
color: window.RANGE_INDICATOR_COLOR,
weight: 3,
dashArray: range.isLinkable ? undefined : '10,10',
interactive: false,
})
: L.circle(coord, range.range, { fill: false, stroke: false, interactive: false })
).addTo(window.map);
}
window.portalAccessIndicator = L.circle(coord, window.HACK_RANGE, {
fill: false,
color: window.ACCESS_INDICATOR_COLOR,
weight: 2,
interactive: false,
}).addTo(window.map);
}
};
/**
* Highlights the selected portal on the map and clears the highlight from the previously selected portal.
*
* @memberof IITC.portal.display
* @param {string} guid - The GUID of the portal to select.
* @returns {boolean} True if the same portal is re-selected (just an update), false if a different portal is selected.
*/
const select = function (guid, event) {
const update = window.selectedPortal === guid;
const oldPortalGuid = window.selectedPortal;
window.selectedPortal = guid;
const oldPortal = window.portals[oldPortalGuid];
const newPortal = window.portals[guid];
// Restore style of unselected portal
if (!update && oldPortal) oldPortal.setSelected(false);
// Change style of selected portal
if (newPortal) newPortal.setSelected(true);
IITC.portal.display.setIndicators(newPortal);
window.runHooks('portalSelected', {
selectedPortalGuid: guid,
unselectedPortalGuid: oldPortalGuid,
event: event,
});
return update;
};
/**
* Changes the coordinates and map scale to show the range for portal links.
*
* @memberof IITC.portal.display
*/
const rangeLinkClick = function () {
if (window.portalRangeIndicator) window.map.fitBounds(window.portalRangeIndicator.getBounds());
if (window.isSmartphone()) window.show('map');
};
/**
* Creates a link to open a specific portal in Ingress Prime.
*
* It is using Firebase's Dynamic Links feature.
* https://firebase.google.com/docs/dynamic-links/create-manually
*
* @memberof IITC.portal.display
* @param {string} guid - The globally unique identifier of the portal.
* @param {number} lat - The latitude of the portal.
* @param {number} lng - The longitude of the portal.
* @returns {string} The Ingress Prime link for the portal
*/
const makePrimeLink = function (guid, lat, lng) {
const base = 'https://link.ingress.com/';
const link = {
link: `https://intel.ingress.com/portal/${guid}`,
};
const android = {
apn: 'com.nianticproject.ingress',
};
const ios = {
isi: '576505181',
ibi: 'com.google.ingress',
ifl: 'https://apps.apple.com/app/ingress/id576505181',
};
const other = {
ofl: `https://intel.ingress.com/intel?pll=${lat},${lng}`,
};
const url = new URL(base);
for (const [key, value] of Object.entries({ ...link, ...android, ...ios, ...other })) {
url.searchParams.set(key, value);
}
return url.toString();
};
/**
* Generates a permalink URL based on the specified latitude and longitude and additional options.
*
* @memberof IITC.portal.display
* @param {L.LatLng|number[]} [latlng] - The latitude and longitude for the permalink.
* Can be omitted to create mapview-only permalink.
* @param {Object} [options] - Additional options for permalink generation.
* @param {boolean} [options.includeMapView] - Include current map view in the permalink.
* @param {boolean} [options.fullURL] - Generate a fully qualified URL (default: relative link).
* @returns {string} The generated permalink URL.
*/
const makePermalink = function (latlng, options) {
options = options || {};
// ensures that lat,lng are with same precision as in stock intel permalinks
const round = (l) => Math.floor(l * 1e6) / 1e6;
const args = [];
if (!latlng || options.includeMapView) {
const c = window.map.getCenter();
args.push('ll=' + [round(c.lat), round(c.lng)].join(','), 'z=' + window.map.getZoom());
}
if (latlng) {
if ('lat' in latlng) {
latlng = [latlng.lat, latlng.lng];
}
args.push('pll=' + latlng.join(','));
}
let url = '';
if (options.fullURL) {
url += new URL(document.baseURI).origin;
}
url += document.location.pathname;
return url + '?' + args.join('&');
};
IITC.portal.display = {
resetScroll,
renderUrl,
renderDetails,
renderToSidebar,
getMiscDetails,
setIndicators,
select,
rangeLinkClick,
makePrimeLink,
makePermalink,
// overridable HTML templates
moveToPortalIconTemplate,
randDetailsTemplate,
artifactTargetTemplate,
artifactFragmentsTemplate,
modsWrapperTemplate,
loadingStatusTemplate,
linkDetailsTemplate,
ellipsisLabelTemplate,
};
// Map of legacy global names to their new names within IITC.portal.display
const legacyDisplayMappings = {
resetScrollOnNewPortal: 'resetScroll',
renderPortalUrl: 'renderUrl',
renderPortalDetails: 'renderDetails',
renderPortalToSideBar: 'renderToSidebar',
getPortalMiscDetails: 'getMiscDetails',
setPortalIndicators: 'setIndicators',
selectPortal: 'select',
rangeLinkClick: 'rangeLinkClick',
makePrimeLink: 'makePrimeLink',
makePermalink: 'makePermalink',
};
IITC.registerLegacyAliases(IITC.portal.display, legacyDisplayMappings);