A UserScript to display comments from the Unofficial Genshin Impact Map on the Official Map.
Click Here :3
A UserScript to display comments from the Unofficial Genshin Impact Map on the Official Map.
Click Here :3
// ==UserScript== | |
// @name Teyvat Comments | |
// @namespace https://koding.dev/ | |
// @version 0.6 | |
// @description Teyvat Map comments from AppSample | |
// @author KodingDev, atouu | |
// @match https://act.hoyolab.com/ys/app/interactive-map/index.html* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=hoyolab.com | |
// @grant none | |
// | |
// @downloadURL https://gist.githubusercontent.com/atouu/23c03510db72c505430cfeb6e56e087f/raw/script.user.js | |
// @updateURL https://gist.githubusercontent.com/atouu/23c03510db72c505430cfeb6e56e087f/raw/script.user.js | |
// ==/UserScript== | |
// --- TRANSFORMATION CALCULATIONS --- | |
/** | |
* Adds two vectors together | |
* | |
* @param {[number, number]} a | |
* @param {[number, number]} b | |
* @returns {[number, number]} The sum of the two vectors | |
*/ | |
const vecAdd = (a, b) => [a[0] + b[0], a[1] + b[1]]; | |
/** | |
* Subtracts two vectors | |
* | |
* @param {[number, number]} a | |
* @param {[number, number]} b | |
* @returns {[number, number]} The difference of the two vectors | |
*/ | |
const vecSub = (a, b) => [a[0] - b[0], a[1] - b[1]]; | |
/** | |
* Scales a vector by a scalar | |
* | |
* @param {[number, number]} a | |
* @param {number} s | |
* @returns {[number, number]} The scaled vector | |
*/ | |
const vecScale = (a, s) => [a[0] * s, a[1] * s]; | |
/** | |
* Calculates the length of a vector | |
* | |
* @param {[number, number]} x | |
* @returns {number} The length of the vector | |
*/ | |
const vecLength = (x) => Math.sqrt(Math.pow(x[0], 2) + Math.pow(x[1], 2)); | |
/** | |
* Flips the Y coordinate of a vector | |
* | |
* @param {[number, number]} x | |
* @returns {[number, number]} The flipped vector | |
*/ | |
const vecFlipY = (x) => [x[0], -x[1]]; | |
// Define the control points for both spaces | |
const OFFICIAL_CONTROLS = { | |
2: [ // TEYVAT | |
[-5140.75, 154.5], // Emperor of Fire and Iron | |
[3288.5, 6761], // Serai Island - Statue of the Seven | |
[2525.5, 8455], // Statue of the Seven - Tsurumi Island | |
[889, -483.5], // Teleport Waypoint - Stormbearer Point | |
], | |
7: [ // Enkanomiya | |
[-662.86, -1235.21], // North - Teleport Waypoint | |
[-1379.35, 382.64], // The Serpent's Bowels - Teleport Waypoint | |
[242, 1063.5], // Kanado's Locus - Teleport Waypoint | |
[889.86, -803.78], // Northeast - Teleport Waypoint | |
], | |
9: [ // The Chasm | |
[-356.55, -404.77], // Ad-Hoc Main Tunnel - Teleport Waypoint | |
[390.30, -244.05], // Main Mining Area - Time Trial | |
[150.78, 212.5], // Stony Halls - Teleport Waypoint | |
[-263.93, 269.092], // The Glowing Narrows - Teleport Waypoint | |
], | |
34: [ // Sea of Bygone Eras | |
[-522.37, -93.25], // Initium Iani - Teleport Waypoint | |
[22, 153.5], // Collegium Phonascorum - Radiant Spincrystal | |
[22.75, -142.75], // Alta Semita - Teleport Waypoint | |
[443.75, -80.25], // Clivus Capitolinus - Radiant Spincrystal | |
], | |
36: [ // Ancient Sacred Mountain | |
[96.5, 536], // Flame-Melding Ritual Grounds - Teleport Waypoint | |
[-538, 196.5], // Sea of Shifting Sentience - Teleport Waypoint | |
[-194.25, -514], // Summoning Hall - Teleport Waypoint | |
[392.5, 236.5], // East - Teleport Waypoint | |
] | |
}; | |
const APPSAMPLE_CONTROLS = { | |
2: [ // TEYVAT | |
[-0.10802, 0.18615], // Emperor of Fire and Iron | |
[0.61536, -0.38097], // Serai Island - Statue of the Seven | |
[0.54988, -0.52639], // Statue of the Seven - Tsurumi Island | |
[0.40944, 0.24092], // Teleport Waypoint - Stormbearer Point | |
], | |
7: [ // Enkanomiya | |
[-0.29593, 0.51515], // North - Teleport Waypoint | |
[-0.54133, -0.03897], // The Serpent's Bowels - Teleport Waypoint | |
[0.01398, -0.27216], // Kanado's Locus - Teleport Waypoint | |
[0.23588, 0.36738], // Northeast - Teleport Waypoint | |
], | |
9: [ // The Chasm | |
[-0.1053, 0.26974], // Ad-Hoc Main Tunnel - Teleport Waypoint | |
[0.4063, 0.15964], // Main Mining Area - Time Trial | |
[0.24223, -0.1531], // Stony Halls - Teleport Waypoint | |
[-0.04185, -0.19186], // The Glowing Narrows - Teleport Waypoint | |
], | |
34: [ // Sea of Bygone Eras | |
[-0.14675, 0.00112], // Initium Iani - Teleport Waypoint | |
[0.04017, -0.08361], // Collegium Phonascorum - Radiant Spincrystal | |
[0.04043, 0.01811], // Alta Semita - Teleport Waypoint | |
[0.18499, -0.00335], // Clivus Capitolinus - Radiant Spincrystal | |
], | |
36: [ // Ancient Sacred Mountain | |
[0.0497, -0.27604], // Flame-Melding Ritual Grounds - Teleport Waypoint | |
[-0.27707, -0.1012], // Sea of Shifting Sentience - Teleport Waypoint | |
[-0.10004, 0.26471], // Summoning Hall - Teleport Waypoint | |
[0.20214, -0.1218], // East - Teleport Waypoint | |
] | |
}; | |
const OFFICIAL_TO_UNOFFICIAL_SCALE = {} | |
for (let key in APPSAMPLE_CONTROLS) { | |
// On the unofficial map, +Y is north, while on the official map it's south. Flip Y on all unofficial coords. | |
APPSAMPLE_CONTROLS[key].forEach((x, i) => (APPSAMPLE_CONTROLS[key][i] = vecFlipY(x))); | |
OFFICIAL_TO_UNOFFICIAL_SCALE[key] = (() => { | |
let scale = 0; | |
for (let i = 1; i < OFFICIAL_CONTROLS[key].length; i++) { | |
const relOfficial = vecSub(OFFICIAL_CONTROLS[key][i], OFFICIAL_CONTROLS[key][0]); | |
const relUnofficial = vecSub(APPSAMPLE_CONTROLS[key][i], APPSAMPLE_CONTROLS[key][0]); | |
scale += vecLength(relUnofficial) / vecLength(relOfficial); | |
} | |
return scale / (OFFICIAL_CONTROLS[key].length - 1); | |
})(); | |
} | |
// Calculate average scale based on all coordinates | |
/** | |
* Converts coordinates from the Official coordinate system to the AppSample coordinate system | |
* | |
* @param {[number, number]} x The coordinates in the Official coordinate system | |
* @returns {[number, number]} The coordinates in the AppSample coordinate system | |
*/ | |
const toUnofficial = (x, id) => { | |
const relOfficial = vecSub(x, OFFICIAL_CONTROLS[id][0]); | |
const relUnofficial = vecScale(relOfficial, OFFICIAL_TO_UNOFFICIAL_SCALE[id]); | |
const absUnofficial = vecAdd(relUnofficial, APPSAMPLE_CONTROLS[id][0]); | |
return vecFlipY(absUnofficial); | |
}; | |
// Regex | |
const IMGUR_REGEX = | |
/(^(http|https):\/\/)?(i\.)?imgur.com\/((?<gallery>gallery\/)(?<galleryid>\w+)|(?<album>a\/)(?<albumid>\w+)#?)?(?<imgid>\w*)/g; | |
// Globals | |
const MARKER_DATA = []; | |
/** | |
* Calculates the distance between two points | |
* | |
* @param {number} x1 The X coordinate of the first point | |
* @param {number} y1 The Y coordinate of the first point | |
* @param {number} x2 The X coordinate of the second point | |
* @param {number} y2 The Y coordinate of the second point | |
* @returns {number} The distance between the two points | |
*/ | |
function distance(x1, y1, x2, y2) { | |
return Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2); | |
} | |
/** | |
* Finds the nearest marker to the given coordinates | |
* | |
* @param {number} x The X coordinate in the AppSample coordinate system | |
* @param {number} y The Y coordinate in the AppSample coordinate system | |
* @returns {object} The nearest marker | |
*/ | |
function findNearestMarker(x, y) { | |
return MARKER_DATA.map((marker) => ({ | |
...marker, | |
dist: distance(x, y, marker.lng, marker.lat), | |
})).sort((a, b) => a.dist - b.dist)[0]; | |
} | |
/** | |
* Gets the marker info from the AppSample API | |
* | |
* @param {object} marker The marker to get info for | |
* @returns {object} The marker info | |
*/ | |
function getMarkerInfo(marker) { | |
const params = new URLSearchParams(); | |
params.append("action", "get"); | |
params.append("app", "gim"); | |
params.append("ttl", "7000"); | |
params.append("categoryId", marker.type); | |
params.append("docId", marker.id); | |
params.append("sort", ""); | |
params.append("page", "1"); | |
params.append("pageSize", "50"); | |
return fetch( | |
`https://cache-proxy.lemonapi.com/skywise/comment/v1?${params.toString()}` | |
).then((res) => res.json()); | |
} | |
/** | |
* Waits for a condition to be true | |
* | |
* @param {function} condition The condition to wait for | |
* @returns {Promise} A promise that resolves when the condition is true | |
*/ | |
function wait(condition) { | |
return new Promise((resolve) => { | |
const interval = setInterval(() => { | |
if (condition()) { | |
clearInterval(interval); | |
resolve(); | |
} | |
}, 100); | |
}); | |
} | |
(async function () { | |
"use strict"; | |
// --- INIT --- | |
// Fetch the AppSample marker data | |
const markerRes = await fetch( | |
"https://game-data.lemonapi.com/gim/markers_all.v4.json" | |
).then((res) => res.json()); | |
// Update the marker data to include header information | |
MARKER_DATA.push( | |
...markerRes.data.map((marker) => | |
marker.reduce((acc, cur, i) => { | |
acc[markerRes.headers[i]] = cur; | |
return acc; | |
}, {}) | |
) | |
); | |
// --- LISTENER --- | |
// Wait for the map to load | |
await wait(() => document.getElementsByClassName("mhy-map-container").length > 0); | |
const mapContainer = document.getElementsByClassName("mhy-map-container")[0] | |
mapContainer.__vue__.$watch("loading", (loading) => { | |
if (loading) return | |
const leafletMap = mapContainer.__vue__.$refs.mhyMap.map; | |
leafletMap.on("popupopen", async (d) => { | |
// Wait for the popup content to populate | |
await wait(() => d.popup._content.vueInstance); | |
const popup = d.popup._content.vueInstance; | |
const { x_pos: x, y_pos: y } = popup.marker; | |
// Perform a lookup of the marker | |
const [convX, convY] = toUnofficial([x, y], popup.mapId); | |
const marker = findNearestMarker(convX, convY); | |
// Get the marker info | |
const markerInfo = await getMarkerInfo(marker); | |
// Sort the comments by upvotes | |
const comments = markerInfo.data.comments.sort((a, b) => b.vote - a.vote); | |
if (!comments.length) return; | |
// Update the popup content | |
const content = popup.$el.getElementsByClassName("map-popup__content")[0]; | |
// Add the header | |
{ | |
const h4 = document.createElement("h4"); | |
h4.style.marginBottom = ".1rem"; | |
h4.style.marginTop = ".1rem"; | |
const span = document.createElement("span"); | |
span.classList.add("map-popup__name-link"); | |
span.href = ""; | |
span.target = "_blank"; | |
const titleSpan = document.createElement("span"); | |
titleSpan.classList.add("label"); | |
titleSpan.innerText = "Unofficial Comments"; | |
span.appendChild(titleSpan); | |
h4.appendChild(span); | |
content.appendChild(h4); | |
} | |
for (const comment of comments) { | |
// Decode the content | |
const commentContent = new DOMParser().parseFromString( | |
comment.content, | |
"text/html" | |
).body.innerText; | |
// Add the DIV | |
const div = document.createElement("div"); | |
div.style.borderTop = ".01rem solid rgba(74, 83, 102, .5)"; | |
// Detect the image | |
let image = comment.image | |
? `https://game-cdn.appsample.com${comment.image}` | |
: ""; | |
// Match against Imgur links in the comment | |
const imgurMatch = IMGUR_REGEX.exec(comment.content); | |
if (imgurMatch) { | |
const { imgid } = imgurMatch.groups; | |
if (imgid && imgid.length) image = `https://i.imgur.com/${imgid}.png`; | |
} | |
// Add the image if it exists | |
if (image && image.length) { | |
const img = document.createElement("img"); | |
img.setAttribute("preview", "preview") | |
img.classList.add("map-popup__img"); | |
img.src = image; | |
// Fix layout shift | |
img.style.maxWidth = "100%"; | |
img.style.aspectRatio = "16 / 9"; | |
img.style.objectFit = "cover"; | |
img.style.objectPosition = "center"; | |
div.appendChild(img); | |
} | |
// Add the desc | |
const desc = document.createElement("pre"); | |
desc.classList.add("map-popup__desc"); | |
desc.innerHTML = commentContent.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1">$1</a>') | |
desc.style.userSelect = "text"; | |
div.appendChild(desc); | |
const details = document.createElement("div"); | |
details.classList.add("map-popup__contribute"); | |
details.style.display = "flex"; | |
details.style.justifyContent = "space-between"; | |
// Add the contributor & votes | |
const data = { | |
Contributor: comment.aname, | |
Votes: comment.vote, | |
}; | |
for (const [key, value] of Object.entries(data)) { | |
const contributeList = document.createElement("div"); | |
contributeList.classList.add("map-popup__contribute-list"); | |
contributeList.tabIndex = 0; | |
const contributorLabel = document.createElement("span"); | |
contributorLabel.classList.add("contributor-label"); | |
contributorLabel.innerText = key; | |
contributeList.appendChild(contributorLabel); | |
const ul = document.createElement("ul"); | |
contributeList.appendChild(ul); | |
const li = document.createElement("li"); | |
li.classList.add("first"); | |
li.style.marginLeft = "3px"; | |
ul.appendChild(li); | |
const span = document.createElement("span"); | |
span.innerText = value; | |
li.appendChild(span); | |
details.appendChild(contributeList); | |
} | |
div.appendChild(details); | |
// Add the time | |
const time = document.createElement("div"); | |
time.classList.add("map-popup__time"); | |
time.innerText = `Update Time: ${new Date( | |
comment.time | |
).toLocaleString()}`; | |
div.appendChild(time); | |
content.appendChild(div); | |
popup.$previewRefresh() | |
} | |
}); | |
}) | |
})(); |