// ==UserScript==
// @name LOTRO Wiki - Shortest Path Between Map Points
// @namespace http://tampermonkey.net/
// @version 2024-09-16u3
// @description Calculates and draws an overlay of the shortest path between points on a map page in the LOTRO Wiki (e.g. for doing explorer deeds)
// @author Morde
// @match https://lotro-wiki.com/wiki/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=lotro-wiki.com
// @grant none
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// Wait until the map has loaded
var interval = setInterval(() => {
if (document.getElementById("mw-content-text")) return;
// We have to check like this because the map page can be navigated to via a frontend router
// (e.g. when clicking a coordinate link), so the URL can change without a page load actually happening
var expectedNumPoints = window.location.href
.matchAll(/(\d+\.\d+(?:S|N),\d+\.\d+(?:W|E))/g)
.toArray().length;
if (!expectedNumPoints) return;
var container = document.querySelector("#content > div");
if (!container) return;
var points = Array.from(
document.querySelectorAll("#content > div > img[style]")
).map((ele) => ({
x:
(Number(ele.style.left.slice(0, -1)) / 100) *
Number(container.attributes.width.value),
y:
(Number(ele.style.top.slice(0, -1)) / 100) *
Number(container.attributes.height.value),
}));
console.log(expectedNumPoints);
if (points.length != expectedNumPoints) return;
clearInterval(interval);
var weights = {};
for (var i = 0; i < points.length; i++) {
for (var j = 0; j < points.length; j++) {
if (i == j) continue;
if (!weights[i]) weights[i] = {};
weights[i][j] =
((points[i].x - points[j].x) ** 2 +
(points[i].y - points[j].y) ** 2) **
0.5;
}
}
console.table(weights);
// Find a tentative permutation with the lowest sum of weight transitions
var candidate = getBestCandidate(weights);
drawBetweenPoints(candidate.chain, points, container);
}, 100);
function getBestCandidate(weights) {
var numWeights = Object.keys(weights).length;
var results = [];
var start = new Date();
for (var i = 0; i < numWeights; i++) results.push(getCandidate(weights, i));
// filter out duplicates
results = Object.values(
results.reduce((acc, x) => {
acc[x.chain.join(",")] = x;
return acc;
}, {})
);
results.sort((a, b) => a.distance - b.distance);
console.table(results);
var bestChain = results[0].chain;
var bestDist = results[0].distance;
console.log("Improving...");
results = results.slice(0, Math.ceil(results.length / 2)); // check the more promising-looking half
results.forEach(({ chain, distance }) => {
var origDist = distance;
var { chain, distance } = improveCandidate(
chain,
distance,
weights,
true
);
if (distance < bestDist) {
console.log({ origDist, distance });
bestChain = chain;
bestDist = distance;
}
});
var { chain, distance } = finalizeCandidate(bestChain, bestDist, weights);
bestChain = chain;
bestDist = distance;
var elapsed = new Date() - start;
console.log(`Took ${elapsed / 1000}s`);
console.log(bestDist);
return { chain: bestChain, distance: bestDist };
}
function getCandidate(weights, current) {
var chain = [current];
var used = new Set(chain);
var numWeights = Object.keys(weights).length;
var distance = 0;
while (chain.length < numWeights) {
var entries = Object.entries(weights[current]).map(([key, value]) => [
Number(key),
value,
]);
var options = entries.filter(([key, value]) => !used.has(key));
var [key, value] = options.reduce(
([bestKey, bestValue], [key, value]) => {
return value < bestValue ? [key, value] : [bestKey, bestValue];
},
[-1, Number.POSITIVE_INFINITY]
);
if (key < 0) break;
used.add(key);
chain.push(key);
distance += value;
current = key;
}
return improveCandidate(chain, distance, weights);
}
function improveCandidate(chain, distance, weights, double = false) {
var improved = true;
while (improved) {
improved = false;
var bestChain = chain;
var bestDist = distance;
for (var i0 = 0; i0 < chain.length; i0++) {
// Try all possible variations where chain[i0] is moved to other parts of the chain
for (var j0 = 0; j0 <= chain.length; j0++) {
if (i0 == j0) continue;
var testChain0 = chain.slice();
var value = testChain0[i0];
var offset = i0 < j0 ? -1 : 0;
testChain0.splice(i0, 1);
testChain0.splice(j0 + offset, 0, value);
if (!double) {
var testDist = getDistance(testChain0, weights);
if (testDist < bestDist) {
bestChain = testChain0;
bestDist = testDist;
improved = true;
}
} else {
// Try moving another part of the chain at the same time
for (var i1 = 0; i1 < chain.length; i1++) {
// Try all possible variations where chain[i1] is moved to other parts of the chain
for (var j1 = 0; j1 <= chain.length; j1++) {
var testChain1 = testChain0.slice();
if (i1 != j1) {
value = testChain1[i1];
offset = i1 < j1 ? -1 : 0;
testChain1.splice(i1, 1);
testChain1.splice(j1 + offset, 0, value);
}
var testDist = getDistance(testChain1, weights);
if (testDist < bestDist) {
bestChain = testChain1;
bestDist = testDist;
improved = true;
}
}
}
}
}
}
// Save change
if (improved) {
chain = bestChain;
distance = bestDist;
}
}
return { chain, distance };
}
function finalizeCandidate(chain, distance, weights) {
var improved = true;
while (improved) {
improved = false;
var bestChain = chain;
var bestDist = distance;
// Try to find a subset reversal that shortens the chain
for (var i = 0; i < chain.length && !improved; i++) {
for (var j = i + 1; j < chain.length && !improved; j++) {
var testChain = chain.slice();
var _i = i;
var _j = j;
// reverse between i and j
while (_i < _j) {
var value = testChain[_i];
testChain[_i] = testChain[_j];
testChain[_j] = value;
_i++;
_j--;
}
var testDist = getDistance(testChain, weights);
if (testDist < bestDist) {
bestChain = testChain;
bestDist = testDist;
improved = true;
}
}
}
// Save change
if (improved) {
chain = bestChain;
distance = bestDist;
}
}
return { chain, distance };
}
function getDistance(chain, weights) {
var result = 0;
var prev = chain[0];
for (var i = 1; i < chain.length; i++) {
var next = chain[i];
result += weights[prev][next];
prev = next;
}
return result;
}
function drawBetweenPoints(indeces, points, container) {
var prev = indeces[0];
var imgs = Array.from(
document.querySelectorAll("#content > div > img[style]")
);
console.log(indeces.map((x) => imgs[x]));
for (var i = 1; i < indeces.length; i++) {
var next = indeces[i];
var prevPoint = points[prev];
var nextPoint = points[next];
drawFrom(prevPoint, nextPoint, container);
prev = next;
}
}
function drawFrom(pointA, pointB, container) {
// Create a div of width=dist(pointA, pointB) at the midpoint between the points, rotated to be the angle between them
var dist = ((pointA.x - pointB.x) ** 2 + (pointA.y - pointB.y) ** 2) ** 0.5;
var midX = (pointA.x + pointB.x) / 2 - dist / 2;
var midY = (pointA.y + pointB.y) / 2;
var angle =
(Math.atan2(pointA.y - pointB.y, pointA.x - pointB.x) * 180) / Math.PI;
var div = document.createElement("div");
div.style.position = "absolute";
div.style.height = "3px";
div.style.left = midX + "px";
div.style.top = midY + "px";
div.style.width = dist + "px";
div.style.rotate = angle + "deg";
div.style.background = "#0ff";
container.childNodes[0].after(div);
}
})();