Raw Source
rsenderak / AtmoBurn Services - topology helper

// ==UserScript==
// @name        AtmoBurn Services - topology helper
// @namespace   sk.seko
// @license     MIT
// @description	Display nearest fleets, colonies, rally points in various contexts
// @updateURL   https://openuserjs.org/meta/rsenderak/abs-topology.meta.js
// @downloadURL https://openuserjs.org/install/rsenderak/abs-topology.user.js
// @match       https://*.atmoburn.com/rally_points.php*
// @match       https://*.atmoburn.com/sensor_net.php*
// @match       https://*.atmoburn.com/fleet.php?*
// @match       https://*.atmoburn.com/fleet/*
// @match       https://*.atmoburn.com/overview.php?view=2
// @match       https://*.atmoburn.com/view_colony.php*
// @match       https://*.atmoburn.com/extras/fleet_refuel_info.php?*
// @match       https://*.atmoburn.com/extras/scan.php?*
// @version	    1.8
// @grant       none
// ==/UserScript==

// v1.5 - script published at openuserjs.org
// v1.5.1 - minor fixes
// v1.5.5 - various fixes in displaying "All fleets" window, like tooltips, separate ships/tonnage column etc
// v1.5.6 - all colonies scanned from fleet, other colony or fuel bunker are stored and displayed in "All Colonies Report" menu
// v1.5.7 - pop and size for scanned colonies; icons in [ABS] menu
// v1.5.8 - fix for eventual colision with "AtmoBurn Services - labels" script
// v1.5.9 - fixed filtering for "All Fleets"; added filtering for "All Colonies"
// v1.5.10 - added filtering for "Rally Points"; added "Set Reference Point" to enable input (custom) global coordinates for "distance" reporting
// v1.5.11 - esversion set to 11; small fixes
// v1.5.12 - more lenient WH parsing (for "alternative" distances); fleets missing from sensor net are removed from "All Fleets" list
// v1.5.13 - rally points stored as a map/dictionary for deduplication & easy access
// v1.6.0 - show nearest colony (distance, directions) on fleet detail screen
// v1.7 - non-existent colonies are removed when scanned from planet level
// v1.8 - better merging of "your sensor net" and "empire sensor net"

/* jshint esversion: 11 */
/* jshint node: true */

"use strict";

const WF_BASE_URL=`https://${window.location.host}`;

// local DB (browser database) keys
const KEY_RP = "abs.rp";
const KEY_RP_OLD = (empiredb) => {return `${KEY_RP}.${empiredb}`;};
const KEY_RP_TS = (empiredb) => {return `${KEY_RP}.${empiredb}.ts`;};
const KEY_SN = "abs.sn";
const KEY_SN_TS = (empiredb) => {return `${KEY_SN}.${empiredb}.ts`;};
const KEY_WH = "abs.wh";
const KEY_SYSTEM = (sid) => {return `abs.s.${sid % 10}`;};
const KEY_ALLCOLS = "abs.allcols";
const KEY_MYCOLS = "abs.mycols";
const KEY_MYCOLS_TS = `${KEY_MYCOLS}.ts`;
const KEY_MYFLEETS = "abs.myfleets";
const KEY_MYFLEETS_TS = `${KEY_MYFLEETS}.ts`;

// my colors
const MY_GREEN = "#79ab89";
const MY_GRAY = "#cccccc";
const MY_RED = "#ff3838";
const MY_ORANGE = "#fddc78";
const MY_YELLOW = "#f1ff00";

// initial reference point (center of the universe)
const REF_POINT_EMPTY = {"x": 0, "y": 0, "z": 0, "n": "CenterOfTheUniverse", "f": null};
// reference point for distance and course/elevation
const refPoint = {};
Object.assign(refPoint, REF_POINT_EMPTY);

// time constants - in milliseconds
const MINUTE_MS = 60000;
const HOUR_MS = MINUTE_MS*60;
const DAY_MS = HOUR_MS*24;

// css for created windows
const ABS_WINDOW_STYLE = `
	* {
		margin: 2;
		padding: 2;
		box-sizing: border-box;
		scrollbar-color: #383838 #292929;
	}
	body {
		font-family: 'Arial', sans-serif;
		background-color: #242424;
		color: #cccccc;
		font-size: 12px;
	}
	a { color:inherit; }

	table {
		text-indent: initial;
		line-height: normal;
		font-weight: normal;
		font-size: 12px;
		color: #cccccc;
		text-align: start;
		width: 100%;
	}
	table td:nth-child(n+3) { text-align: end; }
	tr:nth-child(even){ background-color: #2b2b2b; }
	th { background-color: #3d3d3d; color: white; }

	.yellow { color: yellow; font-weight: bold; }
	.red { color: red; }
	.green { color: green; }

	.topline { display: flex; justify-content: center; align-items: center; }
	.toplineleft { margin-right: auto; }
	.toplineright {margin-left: auto; }

	input[type="checkbox"]:not(:checked) {border-radius: 2px; }
	input[type="checkbox"].cbgreen { accent-color: ${MY_GREEN}; }
	input[type="checkbox"].cbgray { accent-color: ${MY_GRAY}; }
	input[type="checkbox"].cbred { accent-color: ${MY_RED}; }
	input[type="checkbox"].cborange { accent-color: ${MY_ORANGE}; }
	input[type="checkbox"]:not(:checked).cbgreen { appearance: none; margin-bottom: 0; width: 1em; height: 1em; background: ${MY_GREEN}; }
	input[type="checkbox"]:not(:checked).cbgray { appearance: none; margin-bottom: 0; width: 1em; height: 1em; background: ${MY_GRAY}; }
	input[type="checkbox"]:not(:checked).cbred { appearance: none; margin-bottom: 0; width: 1em; height: 1em; background: ${MY_RED}; }
	input[type="checkbox"]:not(:checked).cborange { appearance: none; margin-bottom: 0; width: 1em; height: 1em; background: ${MY_ORANGE}; }
`;

// regex pattern to match and capture x,y,z coordinates (plain text)
const XYZ_REGEX = /^\s*(\-*\d+)[,\s]+(\-*\d+)[,\s]+(\-*\d+)/;
// regex pattern to match and capture x,y,z coordinates (URL)
const XYZ_URL_REGEX = /\\?x=(-?\d+)&y=(-?\d+)&z=(-?\d+)/;
// pattern for wormhole name patter when recorded in rally points
const WH_NAME_REGEX = /[Ww][Hh]\s*#?(\d+)/;

// colony info cache
var myColonyById = null;
// fleet info cache
var myFleetById = null;

function parseXYZ(s) {
	const m = s.match(XYZ_REGEX);
	return m ? {'x': parseInt(m[1]), 'y':parseInt(m[2]), 'z': parseInt(m[3])} : null;
}

function xlog(msg) {
	console.log(`ABS-TOP: ${msg}`);
}

function xerror(msg, error) {
	console.error(`ABS-TOP: ${msg}`, error);
}

// Format Date to string, for example "2025-02-28 20:41".
function formatDateTime(dt) {
  const year = dt.getFullYear();
  const month = String(dt.getMonth() + 1).padStart(2, '0'); // Months are 0-based
  const day = String(dt.getDate()).padStart(2, '0');
  const hours = String(dt.getHours()).padStart(2, '0');
  const minutes = String(dt.getMinutes()).padStart(2, '0');
  return `${year}-${month}-${day} ${hours}:${minutes}`;
}

// Iterate over the keys of object 'obj' and delete those that satisfy the predicate 'predicate'.
function removeKeys(obj, predicate) {
	Object.keys(obj).forEach(key => {
		if (predicate(key, obj[key])) {
			delete obj[key];
		}
	});
}

// Get element by ID.
function byId(ele) {
  return document.getElementById(ele);
}

function updateCoordinates(obj, data) {
	obj.x = data.x;
	obj.y = data.y;
	obj.z = data.z;
}

function absFirstWordOf(s) {
	return s ? s.trim().split(/\s+/)[0] : s;
}

function absLastWordOf(s) {
	if (s) {
		const arr = s.trim().split(/\s+/);
		return arr[arr.length - 1];
	}
	return s;
}

function safeInteger(s) {
	return parseInt(s.replace(/,/g, ""));
}

function numberWithCommas(x) {
    return x == null ? "" : x.toString().replace(/\B(?<!\.\d*)(?=(\d{3})+(?!\d))/g, ",");
}

// compute distance in km
function absDistance(a, b) {
	const [dx, dy, dz] = [a.x - b.x, a.y - b.y, a.z - b.z];
	return Math.round(4000.0 * Math.sqrt(dx * dx + dy * dy + dz * dz));
}

// compute distance in km, use wormholes if available
function absAltDistance(a, b, d0, whMap) {
	if (!whMap) {
		return [null, null];
	}
	let d = d0;
	let wh = null;
	for (var key in whMap) {
		const [w0, w1] = whMap[key];
		const da0 = absDistance(a, w0);
		const db0 = absDistance(b, w1);
		const da1 = absDistance(a, w1);
		const db1 = absDistance(b, w0);
		if (da0 + db0 < d) {
			d = da0 + db0;
			wh = w0;
		}
		if (da1 + db1 < d) {
			d = da1 + db1;
			wh = w1;
		}
	}
	return d < d0 ? [d, wh] : [null, null];
}

// from two locations compute direction (o'clock) and elevation (degrees)
function absElevations(p1, p2) {

	function rad2deg(angle) {
		return angle * 57.29577951308232; // angle / Math.PI * 180
	}

	const dx = p2.x - p1.x;
	const dy = p2.y - p1.y;
	const dz = p2.z - p1.z;

	let horiz = null;
	if (dx == 0 && dy == 0) {
		horiz = '-';
	} else {
		horiz = Math.round((rad2deg(Math.atan2(-dy, dx)) - 90 + 180) / 360 * 12);
		if (horiz < 0) {
			horiz += 12;
		} else if (horiz < 1) {
			horiz = 12;
		}
	}

	let vert = null;
	if (dz == 0) {
		vert = 0;
	} else {
		const dxy = Math.round(Math.sqrt(dx*dx + dy*dy));
		if (dxy == 0) {
			vert = dz > 0 ? 90 : -90;
		} else {
			vert = Math.round(rad2deg(Math.atan2(dz, dxy)));
			if (vert < -90) {
				vert = -180 - vert;
			} else if (vert > 90) {
				vert = 180 - vert;
			}
		}
	}

	return [horiz, vert];
}

function getHREF(obj,fleet,col,text,title,style) {
	// https://beta5.atmoburn.com/fleet.php?x=-36113&y=127125&z=-1882&tpos=global&fleet=1177
	// https://beta5.atmoburn.com/fleet.php?fleet=1179&tcolony=258
	// https://beta5.atmoburn.com/view_colony.php?colony=258
	const tstr = title ? `title="${title}"` : "";
	const sstr = style ? `style=${style}` : "";
	if (fleet && col) {
		return `<a ${sstr} href="/fleet.php?fleet=${fleet}&tcolony=${col}" target="maingame" ${tstr}>${text}</a>`;
	}
	if (fleet && obj) {
		return `<a ${sstr} href="/fleet.php?x=${obj.x}&y=${obj.y}&z=${obj.z}&tpos=global&fleet=${fleet}" target="maingame" ${tstr}>${text}</a>`;
	}
	if (col) {
		return `<a ${sstr} href="/view_colony.php?colony=${col}" target="maingame" ${tstr}>${text}</a>`;
	}
	return `<span ${sstr}${tstr}>${text}</span>`;
}

function retrieveSystemInfo(sid) {
	const bucket = localStorage.getItem(KEY_SYSTEM(sid));
	return bucket ? JSON.parse(bucket)[sid] : null;
}

function storeSystemInfo(sid, sysinfo) {
	const key = KEY_SYSTEM(sid);
	const bucket = localStorage.getItem(key);
	const b = bucket ? JSON.parse(bucket) : {};
	b[sid] = sysinfo;
	localStorage.setItem(key, JSON.stringify(b));
}

function updateMyFleetInfo(fid, fleetInfo) {
	const bucket = localStorage.getItem(KEY_MYFLEETS);
	const b = bucket ? JSON.parse(bucket) : {};
	if (!b[fid]) {
		b[fid] = {};
	}
	Object.assign(b[fid], fleetInfo);
	b[fid].ts = Date.now();
	localStorage.setItem(KEY_MYFLEETS, JSON.stringify(b));
}

function updateMyFleets(fleets) {
	const bucket = localStorage.getItem(KEY_MYFLEETS);
	const b = bucket ? JSON.parse(bucket) : {};
	const newBucket = {};
	fleets.forEach((f) => {
		if (b[f.f]) {
			newBucket[f.f] = b[f.f];
			Object.assign(newBucket[f.f], f);
		} else {
			newBucket[f.f] = f;
		}
		if (!newBucket[f.f].ts) {
			newBucket[f.f].ts = Date.now();
		}
	});
	localStorage.setItem(KEY_MYFLEETS, JSON.stringify(newBucket));
}

function storeAllColonies(colonies, world = null) {
	const cJSON = JSON.stringify(colonies);
	xlog(`storeAllColonies: ${cJSON}`);
	// add/update colonies
	const bucket = localStorage.getItem(KEY_ALLCOLS);
	const b = bucket ? JSON.parse(bucket) : {};
	const processedColonies = [];
	colonies.forEach((c) => {
		if (b[c.c]) {
			Object.assign(b[c.c], c);
		} else {
			b[c.c] = c;
		}
		processedColonies.push(c.c);
	});
	// remove colonies no longer exist (by world ID); we assume "colonies" contains all colonies for "world"
	if (world) {
		removeKeys(b, function (key, value) {
			return value.w == world && !processedColonies.includes(parseInt(key));
		});
	}
	// store all colonies
	localStorage.setItem(KEY_ALLCOLS, JSON.stringify(b));
}

function getAllColonies() {
	return JSON.parse(localStorage.getItem(KEY_ALLCOLS));
}

async function fetchSystemInfo(sid, callback) {

	function convertToSystemInfo(d) {
		return {'i':d.ID, 'n':d.name, 'x':d.x, 'y':d.y, 'z':d.z, 'g':d.galaxy};
	}

	//xlog("start fetchSystemInfo");
	try {
		let res = retrieveSystemInfo(sid);
		if (res) {
			//xlog("fetchSystemInfo: retrieved=" + JSON.stringify(res));
		} else {
			const url = `${WF_BASE_URL}/API/?c=system&ID=${sid}`;
			const response = await fetch(url);
			//xlog("fetchSystemInfo: response=" + response);
			if (!response.ok) {
				throw new Error(`Fetchnig ${url} status: ${response.status}`);
			}
			res = await response.json();
			xlog("fetchSystemInfo: response.json=" + JSON.stringify(res));
			res = convertToSystemInfo(res);
			//xlog("fetchSystemInfo: converted=" + JSON.stringify(res));
			storeSystemInfo(sid, res);
		}
		callback(res);
	} catch (error) {
		xerror("fetchSystemInfo", error.message);
		callback(null);
	}
	//xlog("end fetchSystemInfo");
}

// objects in objectList is enriched by coordinates (o.x, o.y, o.z) derived from system ID (o.s)
function enrichAllBySystem(objectList, callback) {
	let completedRequests = 0;
	objectList.forEach((o) => {
		fetchSystemInfo(o.s, (systemInfo) => {
			if (systemInfo) {
				updateCoordinates(o, systemInfo);
			} else {
				// ignore failed requests
			}
			completedRequests++;
			if (completedRequests === objectList.length) {
				callback();
			}
		});
	});
}

function parseFleetIDFromURL() {
	let m = document.URL.match(/(?:[\?\&]fleet=|\/fleet\/)(\d+)/);
	if (m && m[1]) {
		return parseInt(m[1]);
	}
	return null;
}

function parseColonyIDFromURL() {
	let m = document.URL.match(/[\?\&]colony=(\d+)/);
	if (m && m[1]) {
		return parseInt(m[1]);
	}
	return null;
}

function parseFleetCoordinates() {
	const p = parent.document.getElementById("navData").querySelector("div#positionRight > div > a");
	return (p && p.textContent) ? parseXYZ(p.textContent) : null;
}

function parseFleetSystemAndPlanet() {
	const info = {};
	parent.document.getElementById("navData").querySelectorAll("div#positionLeft > div > a").forEach((a) => {
		let m = a.href.match(/showSystem\((\d+)/);
		if (m) {
			info.s = parseInt(m[1]);
		} else {
			m = a.href.match(/showPlanet\((\d+)/);
			if (m) {
				info.w = parseInt(m[1]);
			}
		}
	});
	return info;
}

function parseFleetScreen() {
	// update fleet ref point
	Object.assign(refPoint, REF_POINT_EMPTY);
	const xyz = parseFleetCoordinates();
	if (xyz) {
		updateCoordinates(refPoint, xyz);
		refPoint.n = byId("pageHeadLine").textContent.trim();
		// fleet ID
		refPoint.f = parseFleetIDFromURL();
		// update fleet info
		updateMyFleetInfo(refPoint.f, refPoint);
		// show nearest info
		showNearestInfo();
	} else {
		setTimeout(parseFleetScreen, 1000);
	}
}

function parseColonyScreen() {
	Object.assign(refPoint, REF_POINT_EMPTY);
	const x = byId("midcolumn").querySelector('div.subtitle > a[onclick*="showSystem"]');
	if (x) {
		const sid = parseInt(x.getAttribute("onclick").match(/showSystem\((\d+)/)[1]);
		if (sid) {
			refPoint.n = byId("midcolumn").querySelector(".pagetitle").textContent.trim();
			fetchSystemInfo(sid, (systemInfo) => {
				updateCoordinates(refPoint, systemInfo);
			});
		}
	}
}

function parseFleetFuelBunker() {
	xlog("Parsing fuel bunker");
	const colonies = [];
	let rec = null;
	document.querySelectorAll("body > div > div > div > table > tbody > tr").forEach((node) => {
		// example: <a href="/fleet.php?fleet=1521&tcolony=208" target="maingame">Pearlington</a>
		const clink = node.querySelector('a[href*="/fleet.php?fleet="][href*="&tcolony="');
		if (clink) {
			// starting row - initialize rec
			const wlink = node.querySelector('a[href*="/fleet.php"][href*="tworld="]');
			const slink = node.querySelector('a[href*="/fleet.php"][href*="tsystem="]');
			rec = {
				'ts': Date.now(),
				'c': parseInt(clink.href.match(/tcolony=(\d+)/)[1]),
				'n': clink.textContent.trim(),
				'w': parseInt(wlink.href.match(/tworld=(\d+)/)[1]),
				's': parseInt(slink.href.match(/tsystem=(\d+)/)[1]),
			};
		} else if (rec) {
			// second row - append info to record
			// example: <a href="/message.php?player=20" target="maingame">Civil Goverment</a>
			const plink = node.querySelector('a[href*="/message.php?player="]');
			if (plink) {
				rec.p = plink.textContent.trim(); // add player info
				colonies.push(rec); // add colony to list
				rec = null;
			}
		}
	});

	// enrich by system coordinates
	enrichAllBySystem(colonies, function() {
		storeAllColonies(colonies); // enrichement finished, store results
	});
}

function parseScan() {
	xlog("Parsing scan");

	let col = null;
	const cid = parseColonyIDFromURL();
	if (cid) { // scan from colony
		col = getMyColony(cid);
	} else { // scan from fleet
		col = parseFleetCoordinates();
		const info = parseFleetSystemAndPlanet();
		col.s = info.s;
		col.w = info.w;
	}
	if (!col) {
		xerror("parseScan", "Position coordinates can't be determined");
		return;
	}

	const coloniesTable = document.querySelectorAll("body > div > div > table")[1];
	if (!coloniesTable) {
		return; // no colonies to scan
	}

	const colonies = [];
	let rec = null;
	coloniesTable.querySelectorAll('tbody > tr').forEach((node) => {
		const clink = node.querySelector('a[href*="/fleet.php"][href*="tcolony="');
		if (clink) {
			rec = {
				'ts': Date.now(),
				'c': parseInt(clink.href.match(/tcolony=(\d+)/)[1]),
				'n': clink.textContent?.trim()
			};
			const row = clink.parentElement.parentElement;
			rec.p = row.querySelector("td:nth-child(2)").textContent?.trim(); // player name
			rec.fc = row.querySelector("td:nth-child(3)").textContent?.trim(); // player faction
			rec.r = row.querySelector("td:nth-child(4)").textContent?.trim()[0].toUpperCase(); // player relation (F or E or N)
			rec.cp = safeInteger(row.querySelector("td:nth-child(5)").textContent?.trim()); // colony population
			rec.cs = safeInteger(row.querySelector("td:nth-child(6)").textContent?.trim()); // colony size
			// copy system ID, planet ID and coordinates from scanning colony
			rec.s = col.s;
			rec.w = col.w;
			updateCoordinates(rec, col);
			// parsed record to colony list
			colonies.push(rec);
		}
	});
	storeAllColonies(colonies, col.w);
}

function isWormhole(r) {
	return r.t[1] === 'W';
}

function updateWHList(rpMap) {
	// map wormhole numbers to pairs of wormholes
	const whMap = {};
	Object.values(rpMap).forEach((r) => {
		if (isWormhole(r)) {
			const m = r.n.match(WH_NAME_REGEX);
			if (!m) {
				xerror("Wormhole has an unsupported name", r.n);
				return;
			}
			// create simplified record
			const wh = {'n':r.n};
			updateCoordinates(wh, r);
			// add to wh pair map
			const whNumber = m[1];
			if (whMap[whNumber]) {
				whMap[whNumber].push(wh);
			} else {
				whMap[whNumber] = [wh];
			}
		}
	});
	// delete unpaired wormholes
	removeKeys(whMap, function (key, value) {
		return !value || value.length !== 2;
	});
	const whJSON = JSON.stringify(whMap);
	localStorage.setItem(KEY_WH, whJSON);
}

function checkUpToDate(tsKey, now, miliseconds) {
	const ts = localStorage.getItem(tsKey);
	return ts && ts > now - miliseconds;
}

function prepareData(data) {
	if (!data) {
		return [];
	}
	const whMap = JSON.parse(localStorage.getItem(KEY_WH));
	data.forEach((d) => {
		const [horiz, vert] = absElevations(refPoint, d);
		d.horiz = horiz;
		d.vert = vert;
		const d0 = absDistance(refPoint, d);
		d.dist = Math.round(d0 / 10000) / 100;
		const [altdist, wh] = absAltDistance(refPoint, d, d0, whMap);
		if (wh) {
			d.altdist = Math.round(altdist / 10000) / 100;
			d.wh = wh;
		}
	});
	data.sort((d1, d2) => {
		if (isNaN(d1.dist)) {
			return 1; // Push NaN to the back
		}
		if (isNaN(d2.dist)) {
			return -1;
		}
		return d1.dist - d2.dist;
	});
	return data;
}

function parseMyFleets() {
	const now = Date.now();

	// check cached value if usable
	if (checkUpToDate(KEY_MYFLEETS_TS, now, 3*MINUTE_MS)) {
		xlog("MyFleets: up-to-date");
		return;
	}

	// parse fleets (fleet ID and fleet name)
	const fleets = [];
    byId("fleetlist").querySelectorAll('a[href*="/fleet.php?fleet="]').forEach((node) => {
		const f = {};
        f.f = parseInt(node.href.match(/fleet=(\d+)/)[1]);
        f.n = node.text.trim();
		fleets.push(f);
    });

	// store it
	updateMyFleets(fleets);
	localStorage.setItem(KEY_MYFLEETS_TS, now);
	xlog(`Stored my fleets`);
}

function parseMyFleetsOverview() {

	function parseLocation(f, locLink) {
		// global coordinates
		const locURL = locLink.href;
		let m = locURL.match(XYZ_URL_REGEX);
		if (m) {
			f.x = parseInt(m[1]);
			f.y = parseInt(m[2]);
			f.z = parseInt(m[3]);
			return;
		}
		// colony
		m = locURL.match(/view_colony\.php\?colony=(\d+)/);
		if (m) {
			f.c = parseInt(m[1]);
			f.ln = locLink.textContent?.trim();
			return;
		}
		// system
		m = locURL.match(/showSystem\((\d+)/);
		if (m) {
			f.s = parseInt(m[1]);
			f.ln = locLink.textContent?.trim();
			return;
		}
		// planet
		m = locURL.match(/showPlanet\((\d+)/);
		if (m) {
			f.p = parseInt(m[1]);
			f.ln = locLink.textContent?.trim();
			return;
		}
		xerror("Unknown location", locURL);
	}

	function enrichLocation(f) {
		if (typeof f.x !== 'undefined') {
			return true; // already enriched
		}
		if (f.c) { // translate colony ID to global coordinates
			const c = getMyColony(f.c);
			if (c) {
				updateCoordinates(f, c);
				//xlog(`Updated coordinates from colony ${c.n} to fleet ${f.n}`);
				return true;
			}
		}
		if (f.s) { // translate system ID to global coordinates
			const si = retrieveSystemInfo(f.s);
			if (si) { // already cached
				updateCoordinates(f, si);
				//xlog(`Updated coordinates from system ${si.n} to fleet ${f.n}`);
				return true;
			}
			fetchSystemInfo(f.s, (systemInfo) => {
				// TODO
			});
			return false;
		}
		if (f.p) { // translate planet ID to global coordinates
			//fetchSystemInfo(f.s, (systemInfo) => {
			//	updateCoordinates(refPoint, systemInfo);
			//});
			return false;
		}
		return false;
	}

	const now = Date.now();

	// check cached value if usable
	if (checkUpToDate(KEY_MYFLEETS_TS, now, 2*MINUTE_MS)) {
		xlog("MyFleetsOverview: up-to-date");
		return;
	}

	// parse fleets (fleet ID and fleet name)
	const fleets = [];
    byId("fleetSort").querySelectorAll('a[href*="/fleet.php?fleet="]').forEach((node) => {
		const f = {};
        f.f = parseInt(node.href.match(/fleet=(\d+)/)[1]);
        f.n = node.text.trim();
		const divs = node.parentNode.parentNode.querySelectorAll(':scope > div');
		const locLink = divs[5]?.querySelector("a");
		if (locLink) {
			parseLocation(f, locLink);
			if (enrichLocation(f)) {
				f.ts = now;
			}
		}
		fleets.push(f);
    });

	// store it
	updateMyFleets(fleets);
	localStorage.setItem(KEY_MYFLEETS_TS, now);
	xlog(`Stored my fleets overview`);
}

function getMyFleets() {
	return JSON.parse(localStorage.getItem(KEY_MYFLEETS));
}

function getMyFleet(fid) {
	if (!myFleetById) {
		myFleetById = {};
		getMyFleets().forEach((f) => {
			myFleetById[f.f] = f;
		});
	}
	return myFleetById[fid];
}

function parseMyColonies() {

	const topmenu = byId("topmenu");
	if (!topmenu) {
		xlog("createTopologyMenu: No top menu - ignoring");
		return;
	}

	const now = Date.now();

	// check cached value if usable
	if (checkUpToDate(KEY_MYCOLS_TS, now, 60*MINUTE_MS)) {
		xlog("MyColonies: up-to-date");
		return;
	}

	// parse colony info
	let colonies = [];
	byId("colonylist").querySelectorAll('a[href*="/view_colony.php?colony="]').forEach((node) => {
		const col = {};
		col.c = parseInt(node.href.match(/colony=(\d+)/)[1]);
		col.n = node.text.trim();
		col.style = node.getAttribute("style");
		col.s = parseInt(node.parentNode.querySelector('a[href*="javascript:showSystem("]').href.match(/showSystem\((\d+)/)[1]);
		col.w = parseInt(node.parentNode.querySelector('a[href*="javascript:showPlanet("]').href.match(/showPlanet\((\d+)/)[1]);
		colonies.push(col);
	});

	// enrich by system coordinates
	enrichAllBySystem(colonies, function() {
		storeMyColonies(colonies, now);
	});
}

function storeMyColonies(colonies, now) {
	const coloniesJSON = JSON.stringify(colonies);
	localStorage.setItem(KEY_MYCOLS, coloniesJSON);
	localStorage.setItem(KEY_MYCOLS_TS, now);
	xlog(`Stored my colonies: ${coloniesJSON}`);
}

function getMyColonies() {
	return JSON.parse(localStorage.getItem(KEY_MYCOLS));
}

function getMyColony(cid) {
	if (!myColonyById) {
		myColonyById = {};
		getMyColonies().forEach((c) => {
			myColonyById[c.c] = c;
		});
	}
	return myColonyById[cid];
}

function resetRallyPoints(empiredb) {
	xlog(`RP: resetting RP info, empiredb=${empiredb}`);
	localStorage.removeItem(KEY_RP_TS(empiredb));
}

function getRallyPoints() {
	const stored = localStorage.getItem(KEY_RP);
	return stored ? JSON.parse(stored) : {};
}

function parseRallyPoints(empiredb) {
	xlog(`RP: processing empiredb=${empiredb}`);
	const now = Date.now();

	// patch the "add" button to invalidate cached info if pressed
	document.querySelector('button[type="submit"][value="1"][name="add"]').addEventListener('click', function(e) {resetRallyPoints(empiredb);});

	// check cached value if usable
	if (checkUpToDate(KEY_RP_TS(empiredb), now, 3*MINUTE_MS)) {
		xlog("RP: up-to-date");
		return;
	}

	// get stored rally points
	const rpMap = getRallyPoints();

	// remove old records for empiredb
	removeKeys(rpMap, function (key, value) {
		return key.endsWith(`.${empiredb}`);
	});

	// parse content
	byId("midcolumn").querySelectorAll("tr > td > span.fakeLink:nth-child(1)").forEach((node) => {
		const m = node.getAttribute("onclick").match(XYZ_URL_REGEX);
		if (m) {
			const columns = node.parentNode.parentNode.querySelectorAll("td");
			const titles = columns[0].querySelector("span").title.trim().split(/\s+/);
			const comment = columns[5].textContent.trim();
			const recordId = columns[6].querySelector('a[href*="edit="]')?.href.match(/edit=(\d+)/)[1];
			const rp = {
				'n': node.textContent.trim(),
				'x': parseInt(m[1]),
				'y': parseInt(m[2]),
				'z': parseInt(m[3]),
				't': titles[0][0].toUpperCase() + titles[1][0].toUpperCase(),
				'c': comment,
			};
			rpMap[`${recordId}.${empiredb}`] = rp;
		}
	});

	// store to cache
	localStorage.setItem(KEY_RP, JSON.stringify(rpMap));
	localStorage.setItem(KEY_RP_TS(empiredb), now);
	xlog("stored in " + KEY_RP);

	// remove "old" version of RP (migration from previous RP storage version)
	if (localStorage.getItem(KEY_RP_OLD(empiredb))) {
		xlog("Old RP deleted");
		localStorage.removeItem(KEY_RP_OLD(0));
		localStorage.removeItem(KEY_RP_OLD(1));
	}
	xlog("RP updated");

	// update WH list from empire DB
	updateWHList(rpMap);
}

function parseSensorNet(empiredb) {

	function isUnknown(s) {
		return !s || s.toUpperCase() == "UNKNOWN" || s.startsWith("?");
	}

	function mergeKnown(recTo, recFrom) {
		function copyIfBetter(x) {
			if (isUnknown(recTo[x]) && !isUnknown(recFrom[x])) {
				recTo[x] = recFrom[x];
			}
		}
		copyIfBetter("n");
		copyIfBetter("p");
		copyIfBetter("r");
		copyIfBetter("ss");
		copyIfBetter("sp");
		copyIfBetter("st");
		copyIfBetter("ro");
	}

	// use better of the two
	function mergeRecords(rec1, rec2) { // rec1 is "already stored", rec2 is "just parsed"; TODO merege!
		if (!rec1) { // empty record can't be better
			return rec2;
		}
		if (rec1.ts < rec2.ts) { // older record can't be better
			mergeKnown(rec2, rec1);
			return rec2;
		}
		mergeKnown(rec1, rec2);
		return rec1; // do not overwrite
	}

	xlog(`SN: processing empiredb=${empiredb}`);
	const now = Date.now();

	// check cached value if usable
	if (checkUpToDate(KEY_SN_TS(empiredb), now, 2*MINUTE_MS)) {
		xlog(`SN (empiredb=${empiredb}): up-to-date`);
		return;
	}

	// parse content
	const snMap = getSensorNet(); // maps signature to fleet/scan record

	// remove old records for empiredb
	removeKeys(snMap, function (key, value) {
		return !value || value.e === empiredb;
	});

	byId("midcolumn").querySelectorAll('div > div > div[id^="scan"').forEach((node) => {
		const divs = node.querySelectorAll(':scope > div');
		// first row
		const divs0 = divs[0].querySelectorAll(':scope > div');
		const signature = absLastWordOf(divs0[0].textContent.trim());
		const gametime = parseInt(divs0[1].getAttribute("gametime")) * 1000;
		const scanner = divs0[2].textContent.trim();
		// second row
		const divs1 = divs[1].querySelectorAll(':scope > div');
		const divs10 = divs1[0].querySelectorAll(':scope > div');
		const name = divs10[0].querySelectorAll(':scope > div')[1].textContent.trim();
		const player = divs10[1].querySelectorAll(':scope > div')[1].textContent.trim();
		const faction = divs10[2].querySelectorAll(':scope > div')[1].textContent.trim();
		const relation = divs10[3].querySelectorAll(':scope > div')[1].textContent.trim();
		// third row
		const divs11 = divs1[1].querySelectorAll(':scope > div');
		const divs110 = divs11[0].querySelectorAll(':scope > div');
		const ships = divs110[0].querySelectorAll(':scope > div')[1].textContent.trim();
		const tonnage = divs110[1].querySelectorAll(':scope > div')[1].textContent.trim();
		const speed = absFirstWordOf(divs110[3].querySelectorAll(':scope > div')[1].textContent.trim());
		const divs111 = divs11[1].querySelectorAll(':scope > div');
		const pos = divs111[0].querySelectorAll(':scope > div')[1];
		const posName = pos.textContent.trim();
		const posXYZ = pos.querySelector("span")?.getAttribute("onclick").match(XYZ_URL_REGEX);
		const destination = divs111[1].querySelectorAll(':scope > div')[1].textContent.trim();
		// fourth row
		const roster = Array.from(divs[2].querySelectorAll(':scope > div')).map(x => x.textContent.trim()).join(",");
		// create data value
		const sn = {
			'e': empiredb,
			'sc': scanner,
			's': signature,
			'ts': gametime,
			'n': name,
			'p': player,
			'fc': faction,
			'r': relation[0].toUpperCase(),
			'ss': ships,
			'st': tonnage?.replace(/ t$/, ''),
			'sp': speed,
			'po': posName,
			'x': posXYZ ? parseInt(posXYZ[1]) : -1,
			'y': posXYZ ? parseInt(posXYZ[2]) : -1,
			'z': posXYZ ? parseInt(posXYZ[3]) : -1,
			'de': destination,
			'ro': roster
		};
		snMap[signature] = mergeRecords(snMap[signature], sn);
	});

	// store to cache
	const snJSON = JSON.stringify(snMap);
	// xlog("SN storing: " + snJSON);
	localStorage.setItem(KEY_SN, snJSON);
	localStorage.setItem(KEY_SN_TS(empiredb), now);
	xlog(`SN (empiredb=${empiredb}) updated`);
}

function getSensorNet() {
	const stored = localStorage.getItem(KEY_SN);
	return stored ? JSON.parse(stored) : {};
}

/*
======================================================================
=== Frontend things...
======================================================================
*/

function absNewWindow(title, content, filterable = false) {
	// create window
	const newWin = window.open("", "_blank",
		"scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=900,height=800,left=400,top=100");
	newWin.document.write(content);
	newWin.document.title = title;
	// apply default/global style
	const styleElement = document.createElement('style');
	styleElement.textContent = ABS_WINDOW_STYLE;
	newWin.document.head.appendChild(styleElement);

	// initialize filtering - if needed
	if (filterable) {
		newWin.document.getElementById("topologyHead").querySelectorAll(".absFilter").forEach((node) => {
			node.addEventListener('click', (event) => {
				// compute mask for hiding
				let mask = 0;
				node.parentNode.querySelectorAll(".absFilter").forEach((checkbox) => {
					mask |= checkbox.checked * parseInt(checkbox.value);
				});
				// hide rows that are masked
				event.currentTarget.ownerDocument.getElementById("topologyTable").querySelectorAll("tr").forEach((row) => {
					row.style.display = (mask & parseInt(row.getAttribute("value"))) ? "none" : "";
				});
			});
		});
	}

	return newWin;
}

function chooseUpdatedStyle(tsdif) {
	let color = null;
	if (tsdif >= -15) {
		color = MY_GREEN;
	} else if (tsdif >= -60) {
		color = null; // no style, i.e. use default color
	} else if (tsdif >= -180) {
		color = MY_ORANGE;
	} else if (tsdif >= -720) {
		color = MY_RED;
	} else {
		color = "black";
	}
	return color ? `style="color:${color};"` : "";
}

function getTimestampInfo(ts, now) {
	if (!ts) {
		return ["???", "", ""];
	}
	const tsdif = Math.round((ts - now)/60000);
	const updatedStyle = chooseUpdatedStyle(tsdif);
	return [formatDateTime(ts), updatedStyle, tsdif];
}

function showRefPointInfo() {
	const title = refPoint.f ? `title="#${refPoint.f}"` : "";
	return `From <span class="yellow" ${title}>${refPoint.n}</span> (${refPoint.x},${refPoint.y},${refPoint.z})`;
}

// returns html for "Update info" for absWindow - one or two time differences, with optional url(s)
function showUpdateInfo(key1, url1, msg1, key2, url2, msg2) {

	function showUpdateInfo1(key, url, msg) {
		const v = localStorage.getItem(key);
		const d = v ? new Date(parseInt(v)) : null;
		const [tsFormated, tsStyle, tsDif] = getTimestampInfo(d, new Date());
		const title = msg ? `${msg} - ${tsFormated}` : tsFormated;
		return url
			? `<a href="${url}" target="maingame" title="${title}" ${tsStyle}>${tsDif} min</a>`
			: `<span title="${title}" ${tsStyle}>${tsDif} min</span>`;
	}

	return key2
		? `${showUpdateInfo1(key1, url1, msg1)} / ${showUpdateInfo1(key2, url2, msg2)}`
		: showUpdateInfo1(key1, url1, msg2);
}

function showRallyPointsReport() {

	function chooseRallyPointRowStyle(xtype, xname) {
		const color = {"E":MY_RED, "F": MY_GREEN}[xtype[0]] || {"?":MY_ORANGE, "!":MY_YELLOW}[xname[0]];
		return color ? `style="color:${color};"` : "";
	}

	function getMaskValue(rpType) {
		return rpType ? {"F":0x01, "N": 0x02, "E": 0x04}[rpType[0]] || 0x00 : 0x02;
	}

	// prepare rally points data
	const data = prepareData(Object.values(getRallyPoints()));

	// prepare rows
	let rows = "";
	rows += `<tr>
		<th title="Rally point type; first letter is (E)nemy,(N)eutral,(F)riend, and second is (C)olony,(W)ormhole etc">Type</th>
		<th title="Rally point name; see tooltip for description">Name</th>
		<th>Coordinates</th>
		<th title="Direct distance, in mkm">Dist.(mkm)</th>
		<th title="Distance using (one) wormhole, if it's shorter">Alt.(mkm)</th>
		<th title="Horizontal direction, in o'clock notation">Horiz.</th>
		<th title="Vertical direction, in degrees">Vert.</th>
		</tr>`;
	data.forEach((d) => {
		const maskValue = getMaskValue(d.t);
		rows += `<tr value="${maskValue}" ${chooseRallyPointRowStyle(d.t, d.n)}>
			<td>${d.t}</td>
			<td>${getHREF(d, refPoint.f, null, d.n, d.c)}</td>
			<td>${d.x},${d.y},${d.z}</td>
			<td>${d.dist.toFixed(2)}</td>
			<td>${d.wh ? getHREF(d.wh, refPoint.f, null, d.altdist.toFixed(2), d.wh.n) : ""}</td>
			<td>${d.horiz}′</td>
			<td>${d.vert}º</td>
			</tr>`;
	});

	// prepare table
	const updateInfo = showUpdateInfo(
		KEY_RP_TS(0), "/rally_points.php?empiredb=0", "Your database",
		KEY_RP_TS(1), "/rally_points.php?empiredb=1", "Empire database"
	);
	const tmpContent = `
		<div id="topologyHead" class="topline">
			<span class="toplineleft">${showRefPointInfo()}</span>
			<span>Hide:
				<input class="absFilter cbgreen" type="checkbox" title="Friends" value="0x01">
				<input class="absFilter cbgray" type="checkbox" title="Neutrals" value="0x02">
				<input class="absFilter cbred" type="checkbox" title="Enemies" value="0x04">
			</span>
			<span class="toplineright">Last update: ${updateInfo}</span>
		</div>
		<table id="topologyTable">
			${rows}
		</table>`;

	absNewWindow("Rally Points Report", tmpContent, true);
}

function showMyColoniesReport() {

	// prepare colony data
	const data = prepareData(getMyColonies());

	// prepare rows
	let rows = "";
	rows += `<tr>
		<th>Name</th>
		<th>Coordinates</th>
		<th title="Direct distance, in mkm">Dist.(mkm)</th>
		<th title="Distance using (one) wormhole, if it's shorter">Alt.(mkm)</th>
		<th title="Horizontal direction, in o'clock notation">Horiz.</th>
		<th title="Vertical direction, in degrees">Vert.</th>
		</tr>`;
	data.forEach((d) => {
		rows += `<tr>
			<td style="${d.style}">${getHREF(d, refPoint.f, d.c, d.n, `system #${d.s}&#10;planet #${d.w}&#10;colony #${d.c}`)}</td>
			<td>${d.x},${d.y},${d.z}</td>
			<td>${d.dist.toFixed(2)}</td>
			<td>${d.wh ? getHREF(d.wh, refPoint.f, null, d.altdist.toFixed(2), d.wh.n) : ""}</td>
			<td>${d.horiz}′</td>
			<td>${d.vert}º</td>
			</tr>`;
	});

	// prepare table
	const updateInfo = showUpdateInfo(KEY_MYCOLS_TS);
	const tmpContent = `
		<div class="topline">
			<span class="toplineleft">${showRefPointInfo()}</span>
			<span class="toplineright">Last update: ${updateInfo}</span>
		</div>
		<table id="topologyTable">
			${rows}
		</table>`;

	absNewWindow("My Colonies Report", tmpContent);
}

function showNearestInfo() {
	// prepare my colonies data
	const data = prepareData(getMyColonies());

	let d = data ? data[0] : null;
	if (!d) {
		return; // no nearest colony detected, quit
	}

	const coordsElement = document.getElementById("navData").querySelector("div#positionRight > div > a");
	if (!coordsElement) {
		return; // can't determine element to write info to, quit
	}

	// show nearest colony, and it's direction(s)
	const colonyHREF = `<span>${getHREF(d, refPoint.f, d.c, d.n, null, d.style)}</span>`;
	const e = document.createElement('span');
	const bs = "&nbsp;&nbsp;&nbsp;"; // big HTML space
	e.innerHTML = `${bs}${colonyHREF}` + ((d.dist > 0.0) ? `${bs}${d.dist.toFixed(1)}mkm${bs}<big>${d.horiz}′</big><small>${bs}${d.vert}º</small>` : "");
	e.style.color = "yellow";
	e.title = "Nearest colony, distance, horizontal direction (in o'clock notation) and vertical direction (in degrees)";
	coordsElement.after(e);
}

function showAllColoniesReport() {

	function chooseColonyRowStyle(relation) {
		const color = {"E":MY_RED, "F": MY_GREEN}[relation];
		return color ? `style="color:${color};"` : "";
	}

	function getMaskValue(relation) {
		return relation ? {"F":0x01, "N": 0x02, "E": 0x04}[relation] || 0x00 : 0x02;
	}

	// prepare rally points data
	const data = prepareData(Object.values(getAllColonies()));

	// prepare rows
	const now = new Date();
	let rows = "";
	rows += `<tr>
		<th>Name</th>
		<th>Player</th>
		<th>Population</th>
		<th>Size (km²)</th>
		<th>Coordinates</th>
		<th title="Direct distance, in mkm">Dist.(mkm)</th>
		<th title="Distance using (one) wormhole, if it's shorter">Alt.(mkm)</th>
		<th title="Horizontal direction, in o'clock notation">Horiz.</th>
		<th title="Vertical direction, in degrees">Vert.</th>
		<th title="Relative time how old is this information; colored for better overview">Updated</th>
		</tr>`;
	data.forEach((d) => {
		const colStyle = chooseColonyRowStyle(d.r);
		const [tsFormated, tsStyle, tsDif] = getTimestampInfo(new Date(d.ts), now);
		const maskValue = getMaskValue(d.r);
		rows += `<tr value="${maskValue}">
			<td ${colStyle}>${getHREF(d, refPoint.f, d.c, d.n, `system #${d.s}&#10;planet #${d.w}`)}</td>
			<td ${colStyle}>${d.p}</td>
			<td>${numberWithCommas(d.cp)}</td>
			<td>${numberWithCommas(d.cs)}</td>
			<td>${d.x},${d.y},${d.z}</td>
			<td>${d.dist.toFixed(2)}</td>
			<td>${d.wh ? getHREF(d.wh, refPoint.f, null, d.altdist.toFixed(2), d.wh.n) : ""}</td>
			<td>${d.horiz}′</td>
			<td>${d.vert}º</td>
			<td title="${tsFormated}" ${tsStyle}">${tsDif} min</td>
			</tr>`;
	});

	// prepare table
	const tmpContent = `
		<div id="topologyHead" class="topline">
			<span class="toplineleft">${showRefPointInfo()}</span>
			<span>Hide:
				<input class="absFilter cbgreen" type="checkbox" title="Friends" value="0x01">
				<input class="absFilter cbgray" type="checkbox" title="Neutrals" value="0x02">
				<input class="absFilter cbred" type="checkbox" title="Enemies" value="0x04">
			</span>
			<span class="toplineright">Last update: see rows</span>
		</div>
		<table id="topologyTable">
			${rows}
		</table>`;

	absNewWindow("All Colonies Report", tmpContent, true);
}

function showMyFleetsReport() {

	// prepare rally points data
	const data = prepareData(Object.values(getMyFleets()));

	// prepare rows
	const now = new Date();
	let rows = "";
	rows += `<tr>
		<th title="Fleet name; see tooltip for fleet ID">Name</th>
		<th>Coordinates</th>
		<th title="Direct distance, in mkm">Dist.(mkm)</th>
		<th title="Distance using (one) wormhole, if it's shorter">Alt.(mkm)</th>
		<th title="Horizontal direction, in o'clock notation">Horiz.</th>
		<th title="Vertical direction, in degrees">Vert.</th>
		<th title="Relative time how old is this information; colored for better overview">Updated</th>
		</tr>`;
	data.forEach((d) => {
		const [tsFormated, tsStyle, tsDif] = getTimestampInfo(new Date(d.ts), now);
		rows += `<tr>
			<td title="#${d.f}">${getHREF(d, refPoint.f, null, d.n, null)}</td>
			<td title="${d.ln ? d.ln : ''}">${d.x},${d.y},${d.z}</td>
			<td>${d.dist.toFixed(2)}</td>
			<td>${d.wh ? getHREF(d.wh, refPoint.f, null, d.altdist.toFixed(2), d.wh.n) : ""}</td>
			<td>${d.horiz}′</td>
			<td>${d.vert}º</td>
			<td title="${tsFormated}" ${tsStyle}">${tsDif} min</td>
			</tr>`;
	});

	// prepare table
	const updateInfo = showUpdateInfo(KEY_MYFLEETS_TS, "/overview.php?view=2", "Fleet overview");
	const tmpContent = `
		<div class="topline">
			<span class="toplineleft">${showRefPointInfo()}</span>
			<span class="toplineright">Last update: ${updateInfo}</span>
		</div>
		<table id="topologyTable">
			${rows}
		</table>`;

	absNewWindow("My Fleets Report", tmpContent);
}



function showAllFleetsReport() {

	function chooseFleetRowStyle(relation) {
		const color = {"E":MY_RED, "F": MY_GREEN}[relation];
		return color ? `style="color:${color};"` : "";
	}

	function getMaskValue(relation, ships, tonnage) {
		let v = {"F":0x01, "N": 0x02, "?": 0x02, "E": 0x04}[relation[0]] || 0x00;
		if (safeInteger(ships) <= 1 && safeInteger(tonnage) < 25) {
			v |= 0x08;
		}
		return v;
	}

	// prepare rally points data
	const data = prepareData(Object.values(getSensorNet()));

	// prepare rows
	const now = new Date();
	let rows = "";
	rows += `<tr>
		<th title="Fleet name; see tooltip for signature">Name</th>
		<th title="Owner name and faction, if known">Player</th>
		<th title="Number of ships; see tooltip for speed and roster/fleet list if known">Ships</th>
		<th title="Fleet tonnage; see tooltip for speed and roster/fleet list if known">Tonnage</th>
		<th>Coordinates</th>
		<th title="Direct distance, in mkm">Dist.(mkm)</th>
		<th title="Horizontal direction, in o'clock notation">Horiz.</th>
		<th title="Vertical direction, in degrees">Vert.</th>
		<th title="Relative time how old is this information; colored for better overview">Updated</th>
		</tr>`;
	data.forEach((d) => {
		const fleetStyle = chooseFleetRowStyle(d.r);
		const maskValue = getMaskValue(d.r, d.ss, d.st);
		const [tsFormated, tsStyle, tsDif] = getTimestampInfo(new Date(d.ts), now);
		const tooltip = `signature: ${d.s}&#10;empiredb: ${d.e}&#10;scanner: ${d.sc}&#10;speed: ${d.sp} km/d&#10;roster: ${d.ro}`;
		rows += `<tr value="${maskValue}">
			<td title="${tooltip}" ${fleetStyle}>${getHREF(d, refPoint.f, null, d.n, null)}</td>
			<td title="${tooltip}" ${fleetStyle}>${d.p} (${d.fc})</td>
			<td title="${tooltip}">${d.ss}</td>
			<td title="${tooltip}">${d.st}</td>
			<td title="position: ${d.po}&#10;destination: ${d.de}">${d.x},${d.y},${d.z}</td>
			<td>${d.dist.toFixed(2)}</td>
			<td>${d.horiz}′</td>
			<td>${d.vert}º</td>
			<td title="${tsFormated}" ${tsStyle}">${tsDif} min</td>
			</tr>`;
	});

	// prepare table
	const updateInfo = showUpdateInfo(
		KEY_SN_TS(0), "/sensor_net.php?empiredb=0", "Your database",
		KEY_SN_TS(1), "/sensor_net.php?empiredb=1", "Empire database"
	);
	const tmpContent = `
		<div id="topologyHead" class="topline">
			<span class="toplineleft">${showRefPointInfo()}</span>
			<span>Hide:
				<input class="absFilter cbgreen" type="checkbox" title="Friends" value="0x01">
				<input class="absFilter cbgray" type="checkbox" title="Neutrals" value="0x02">
				<input class="absFilter cbred" type="checkbox" title="Enemies" value="0x04">
				<input class="absFilter cborange" type="checkbox" title="Small" value="0x08">
			</span>
			<span class="toplineright">Last update: ${updateInfo}</span>
		</div>
		<table id="topologyTable">
			${rows}
		</table>`;

	// create new browser windows with the report
	absNewWindow("All Fleets Report", tmpContent, true);
}

function setReferencePoint() {
	let val = prompt("Input global coordinates as reference point (for distance reports)", "");
	if (val === null) {
		return; // cancel was pressed
	}
	const m = val.match(/(\-*\d+)[,\s]+(\-*\d+)[,\s]+(\-*\d+)/);
	Object.assign(refPoint, {
		"x": parseInt(m[1]),
		"y": parseInt(m[2]),
		"z": parseInt(m[3]),
		"n": "CustomRefPoint",
		"f": null
	});
}

function createTopologyMenu() {
	//xlog("createTopologyMenu ");

	const topmenu = byId("topmenu");
	if (!topmenu) {
		xlog("createTopologyMenu: No top menu - ignoring");
		return;
	}

	const e = document.createElement("li");
	e.id = "topologyMenu";
	e.innerHTML = `
		<a href="#" class="hide_mobile hide_small menu_title" id="topologyMenuTitle">[ABS]</a>
		<div class="">
			<ul>
				<li><a id="absRPRep"><span class="fa fa-list">&nbsp;</span>Rally Points</a></li>
				<li><a id="absMyColsRep"><span class="fa fa-house-building">&nbsp;</span>My Colonies</a></li>
				<li><a id="absAllColsRep"><span class="fa fa-thin fa-house-building">&nbsp;</span>All Colonies</a></li>
				<li><a id="absMyFleetsRep"><span class="fa fa-shuttle-space"></span>&nbsp;My Fleets</a></li>
				<li><a id="absAllFleetsRep"><span class="fa fa-thin fa-shuttle-space"></span>&nbsp;All Fleets</a></li>
				<li><a id="absSetRefPoint"><span class="fa fa-location-dot"></span>&nbsp;Set Reference Point</a></li>
			</ul>
		</div>`;
	topmenu.append(e);

	// append click listener(s)
	byId("absRPRep").addEventListener('click', showRallyPointsReport);
	byId("absMyColsRep").addEventListener('click', showMyColoniesReport);
	byId("absAllColsRep").addEventListener('click', showAllColoniesReport);
	byId("absMyFleetsRep").addEventListener('click', showMyFleetsReport);
	byId("absAllFleetsRep").addEventListener('click', showAllFleetsReport);
	byId("absSetRefPoint").addEventListener('click', setReferencePoint);
}

// send an event to server
function startTopology() {
	const urlstr = document.URL;
	xlog("startTopology: " + urlstr);

	createTopologyMenu();
	setTimeout(parseMyColonies, 500);

	if (urlstr.match(/atmoburn\.com\/rally_points\.php/i)) {
		// Rally points = https://beta5.atmoburn.com/rally_points.php?empiredb=1
		xlog("Rally Points screen: " + urlstr);
		const empiredb = urlstr.match(/\?empiredb=1/) ? 1 : 0;
		// https://beta5.atmoburn.com/rally_points.php?delid=469&empiredb=0
		if (urlstr.match(/\?delid=/)) {
			resetRallyPoints(empiredb);
		}
		parseRallyPoints(empiredb);
	} else if (urlstr.match(/atmoburn\.com\/sensor_net\.php/i)) {
		// Sensor Net = https://beta5.atmoburn.com/sensor_net.php?empiredb=1
		xlog("Sensor Net screen: " + urlstr);
		const empiredb = urlstr.match(/\?empiredb=1/) ? 1 : 0;
		parseSensorNet(empiredb);
	} else if (urlstr.match(/atmoburn\.com\/fleet\.php/i) || urlstr.match(/atmoburn\.com\/fleet\//i)) {
		// Fleet = https://beta5.atmoburn.com/fleet.php?fleet=1177 or https://beta5.atmoburn.com/fleet/1177
		xlog("Fleet screen: " + urlstr);
		parseMyFleets();
		setTimeout(parseFleetScreen, 300);
	} else if (urlstr.match(/atmoburn\.com\/overview\.php\?view=2/i)) {
		// Fleets: https://*.atmoburn.com/overview.php?view=2
		xlog("Fleets overview screen: " + urlstr);
		parseMyFleetsOverview();
	} else if (urlstr.match(/atmoburn\.com\/view_colony\.php/i)) {
		// Colony = https://beta5.atmoburn.com/view_colony.php?colony=258
		xlog("Colony screen: " + urlstr);
		parseMyFleets();
		setTimeout(parseColonyScreen, 300);
	} else if (urlstr.match(/atmoburn\.com\/extras\/fleet_refuel_info.php/i)) {
		// Fuel bunker (iframe) = https://beta5.atmoburn.com/extras/fleet_refuel_info.php?fleet=1177
		xlog("Fuel bunker IFRAME: " + urlstr);
		setTimeout(parseFleetFuelBunker, 1000);
	} else if (urlstr.match(/atmoburn\.com\/extras\/scan.php/i)) {
		// Scan (fleet): https://*.atmoburn.com/extras/scan.php?fleet=12312&noheadline=1
		// Scan (colony): https://beta5.atmoburn.com/extras/scan.php?colony=615&noheadline=1
		xlog("Scan IFRAME: " + urlstr);
		setTimeout(parseScan, 1000);
	} else {
		xlog("startTopology: (no action)");
	}
}

startTopology();