FF Progs

Improves FFlogs.

  1. // ==UserScript==
  2. // @name FF Progs
  3. // @name:en FF Progs
  4. // @description Improves FFlogs.
  5. // @description:en Improves FFlogs.
  6. // @version 1.4.2
  7. // @namespace k_fizzel
  8. // @author Chad Bradly
  9. // @website https://www.fflogs.com/character/id/12781922
  10. // @icon https://assets.rpglogs.com/img/ff/favicon.png?v=2
  11. // @match https://*.fflogs.com/*
  12. // @match https://*.warcraftlogs.com/*
  13. // @require https://code.jquery.com/jquery-3.2.0.min.js
  14. // @grant unsafeWindow
  15. // @grant GM_addStyle
  16. // @grant GM_getValue
  17. // @grant GM_setValue
  18. // @grant GM_deleteValue
  19. // @license MIT License
  20. // ==/UserScript==
  21.  
  22. (function () {
  23. "use strict";
  24.  
  25. // If you want to add more filter expression presets, you can add them here.
  26. const PIN_PRESETS = {
  27. "No Filter": "",
  28. calulateddamage: "type='calculateddamage'",
  29. damage: "type='damage'",
  30. cast: "type='cast'",
  31. Targetability: "type='targetabilityupdate'",
  32. GCD: "ability.isOffGCD=false",
  33. "Kill Event": "kill",
  34. LB: `2$Main$#ffff14$script$let l;pinMatchesFightEvent=(e,f)=>{switch(e.type){case"limitbreakupdate":return l&&l===e.timestamp||(l=e.timestamp),!0;case"calculateddamage":if("Player"===e.target.type&&e.timestamp===l)return!0;break;case"heal":if(!e.isTick&&e.timestamp===l)return!0}return!1};`,
  35. Magical: "ability.type=1024",
  36. Physical: "ability.type=128",
  37. Heal: "ability.type=8",
  38. DOT: "ability.type=64",
  39. "Buff/Debuff": "ability.type=1",
  40. Darkness: "ability.type=124",
  41. True: "ability.type=32",
  42. System: "ability.type=0",
  43. };
  44.  
  45. const ABILITY_TYPES = {
  46. 0: "System",
  47. 1: "Buff/Debuff",
  48. // 2: "Unknown",
  49. // 4: "Unknown",
  50. 8: "Heal",
  51. // 16: "Unknown",
  52. 32: "True",
  53. 64: "DOT",
  54. 124: "Darkness",
  55. // 125: "Darkness?",
  56. // 126: "Darkness?",
  57. // 127: "Darkness?",
  58. 128: "Physical",
  59. // 256: "Magical?",
  60. // 512: "Unknown",
  61. 1024: "Magical",
  62. };
  63. const PASSIVE_LB_GAIN = [
  64. ["75"], // one bar
  65. ["180"], // two bars
  66. ["220", "170", "160", "154", "144", "140"], // three bars
  67. ];
  68.  
  69. const REPORTS_PATH_REGEX = /\/reports\/.+/;
  70. const ZONE_RANKINGS_PATH_REGEX = /\/zone\/rankings\/.+/;
  71. const CHARACTER_PATH_REGEX = /\/character\/.+/;
  72. const PROFILE_PATH_REGEX = /\/profile/;
  73. const LB_REGEX = /The limit break gauge updated to (\d+). There are (\d+) total bars./;
  74. const apiKey = GM_getValue("apiKey");
  75.  
  76. const getQueryParams = () => {
  77. const queryParams = new URLSearchParams(window.location.search);
  78. const params = Object.fromEntries(queryParams.entries());
  79. return params;
  80. };
  81.  
  82. const changeQueryParams = (defaultParams) => {
  83. const url = new URL(window.location);
  84.  
  85. const queryParams = new URLSearchParams(url.search);
  86.  
  87. Object.entries(defaultParams).forEach(([key, value]) => {
  88. if (value !== null && value !== undefined && value !== "") {
  89. queryParams.set(key, value);
  90. } else {
  91. queryParams.delete(key);
  92. }
  93. });
  94.  
  95. changeView(queryParams);
  96. };
  97.  
  98. const updateTable = (onTableChange) => {
  99. const tableContainer = document.querySelector("#table-container");
  100. if (tableContainer) {
  101. const observer = new MutationObserver(onTableChange);
  102. observer.observe(tableContainer, {
  103. attributes: true,
  104. characterData: true,
  105. childList: true,
  106. });
  107. }
  108. };
  109.  
  110. function parseTime(cell) {
  111. const [_, sign, m, s, ms] = $(cell)
  112. .text()
  113. .match(/(-?)(\d+):(\d+)\.(\d+)/);
  114. const totalMs = (+m * 60000 + +s * 1000 + +ms) * (sign === "-" ? -1 : 1);
  115. return totalMs;
  116. }
  117.  
  118. const characterAllStar = (rank, outOf, rDPS, rankOneRDPS) => {
  119. return Math.min(Math.max(100 * (rDPS / rankOneRDPS), 100 - (rank / outOf) * 100) + 20 * (rDPS / rankOneRDPS), 120);
  120. };
  121.  
  122. // AdBlock
  123. $(
  124. "#top-banner, .side-rail-ads, #bottom-banner, #subscription-message-tile-container, #playwire-video-container, #right-ad-box, #right-vertical-banner, #gear-box-ad, .ad-placement-sticky-footer, .content-sidebar, #ap-ea8a4fe5-container, #tile-content-ap, #builds-banner"
  125. ).remove();
  126. $(".content-with-sidebar").css("display", "block");
  127. $("#table-container").css("margin", "0 0 0 0");
  128.  
  129. // Reports Page
  130. if (REPORTS_PATH_REGEX.test(location.pathname)) {
  131. // Add XIV Analysis Button
  132. $("#filter-analyze-tab").before(
  133. `<a target="_blank" class="big-tab view-type-tab" id="xivanalysis-tab"><span class="zmdi zmdi-time-interval"></span> <span class="big-tab-text"><br>xivanalysis</span></a>`
  134. );
  135. $("#xivanalysis-tab").on("click", () => {
  136. window.open(`https://xivanalysis.com/report-redirect/${location.href}`, "_blank");
  137. });
  138.  
  139. if (!$("#filter-rankings-tab").length) {
  140. $("#filter-replay-tab").before(
  141. `<a href="#" class="big-tab view-type-tab" id="filter-rankings-tab" onclick="return changeFilterView('rankings', this)" oncontextmenu="changeFilterView('rankings', this)"><span class="zmdi zmdi-sort"></span><span class="big-tab-text"><br>Rankings</span></a>`
  142. );
  143. }
  144.  
  145. // Fixes Cursor behavior on Filter Type Tabs
  146. $("#filter-type-tabs").css("cursor", "default");
  147.  
  148. const rankOnes = {};
  149. let jobs;
  150.  
  151. const onTableChange = () => {
  152. let queryParams = getQueryParams();
  153. let lastLbGain;
  154. let lastTimeDiff;
  155. let lastSkillName;
  156. let lastSelectedPreset;
  157.  
  158. // Filter Presets
  159. if (!queryParams.view || queryParams.view === "events" || queryParams.view === "timeline" || queryParams.view === "execution") {
  160. if (!$("#presets").length) {
  161. $("#graph-title-strip > div:first-child").after(
  162. `<div style="margin-left: auto; padding-right: 8px;" id="presets">
  163. No Overwrite:
  164. <input type="checkbox" id="no-overwrite" style="margin-right: 8px;">
  165. Filter Presets:
  166. <select id="presets-select" style="margin-right: 8px;">
  167. ${Object.entries(PIN_PRESETS)
  168. .map(([name, pin]) => `<option value="${pin}">${name}</option>`)
  169. .join("")}
  170. </select>
  171. </div>`
  172. );
  173.  
  174. $("#presets-select").on("change", (e) => {
  175. const selected = $("#presets-select").val();
  176. const name = $("#presets-select option:selected").text();
  177.  
  178. if (!selected) {
  179. changeQueryParams({
  180. type: "",
  181. view: "",
  182. source: "",
  183. hostility: "",
  184. pins: "",
  185. start: "",
  186. end: "",
  187. });
  188. lastSelectedPreset = null;
  189. return;
  190. }
  191. if (name === "LB") {
  192. changeQueryParams({
  193. type: "summary",
  194. view: "events",
  195. source: "",
  196. pins: PIN_PRESETS.LB,
  197. });
  198. lastSelectedPreset = name
  199. return;
  200. }
  201. if (name === "Kill Event") {
  202. changeQueryParams({
  203. type: "resources",
  204. view: "events",
  205. source: "",
  206. hostility: "1",
  207. pins: "",
  208. start: fightSegmentEndTime - 5 * 1000,
  209. end: fightSegmentEndTime,
  210. });
  211. lastSelectedPreset = name
  212. return;
  213. }
  214. const pinTemplate = `2$Off$#244F4B$expression$${selected}`;
  215. if ($("#no-overwrite").is(":checked") && lastSelectedPreset !== "LB" && lastSelectedPreset !== "Kill Event") {
  216. queryParams = getQueryParams();
  217. changeQueryParams({ pins: queryParams.pins ? `${queryParams.pins}^${pinTemplate}` : pinTemplate });
  218. } else {
  219. changeQueryParams({ pins: pinTemplate });
  220. }
  221. lastSelectedPreset = name
  222. });
  223. }
  224. }
  225.  
  226. // Rankings Tab
  227. if (queryParams.view === "rankings") {
  228. if (!GM_getValue("apiKey")) return;
  229. const rows = [];
  230. if (!jobs) {
  231. fetch(`https://www.fflogs.com/v1/classes?api_key=${GM_getValue("apiKey")}`)
  232. .then((res) => res.json())
  233. .then((data) => {
  234. jobs = data[0].specs;
  235. rows.forEach((row) => {
  236. updatePoints(row);
  237. });
  238. })
  239. .catch((err) => console.error(err));
  240. } else {
  241. setTimeout(() => {
  242. rows.forEach((row) => {
  243. updatePoints(row);
  244. });
  245. }, 0);
  246. }
  247.  
  248. const updatePoints = async (row) => {
  249. const queryParams = getQueryParams();
  250. const rank = Number($(row).find("td:nth-child(2)").text().replace("~", ""));
  251. const outOf = Number($(row).find("td:nth-child(3)").text().replace(",", ""));
  252. const dps = Number($(row).find("td:nth-child(6)").text().replace(",", ""));
  253. const jobName = $(row).find("td:nth-child(5) > a").attr("class") || "";
  254. const jobName2 = $(row).find("td:nth-child(5) > a:nth-last-child(1)").attr("class") || "";
  255. const playerMetric = queryParams.playermetric || "rdps";
  256.  
  257. if (jobName2 !== "players-table-realm") {
  258. $(row)
  259. .find("td:nth-child(7)")
  260. .html(`<center><img src="https://cdn.7tv.app/emote/62523dbbbab59cfd1b8b889d/1x.webp" title="No api v1 endpoint for combined damage." style="height: 15px;"></center>`);
  261. return;
  262. }
  263.  
  264. const updateCharecterAllStar = async () => {
  265. $(row).find("td:nth-child(7)").html(characterAllStar(rank, outOf, dps, rankOnes[jobName][playerMetric]).toFixed(2));
  266. };
  267.  
  268. if (!rankOnes[jobName]) {
  269. rankOnes[jobName] = {};
  270. }
  271.  
  272. if (!rankOnes[jobName][playerMetric]) {
  273. const url = `https://www.fflogs.com/v1/rankings/encounter/${reportsCache.filterFightBoss}?metric=${playerMetric}&spec=${
  274. jobs.find((job) => job.name.replace(" ", "") === jobName)?.id
  275. }&api_key=${GM_getValue("apiKey")}`;
  276. fetch(url)
  277. .then((res) => res.json())
  278. .then((data) => {
  279. rankOnes[jobName][playerMetric] = Number(data.rankings[0].total.toFixed(1));
  280. updateCharecterAllStar();
  281. })
  282. .catch((err) => console.error(err));
  283. } else {
  284. updateCharecterAllStar();
  285. }
  286. };
  287.  
  288. $(".player-table").each((_i, table) => {
  289. $(table)
  290. .find("thead tr th:nth-child(6)")
  291. .after(
  292. `<th class="sorting ui-state-default" tabindex="0" aria-controls="DataTables_Table_0" rowspan="1" colspan="1" aria-label="Patch: activate to sort column ascending"><div class="DataTables_sort_wrapper">Points<span class="DataTables_sort_icon css_right ui-icon ui-icon-caret-2-n-s"></span></div></th>`
  293. );
  294. $(table)
  295. .find("tbody tr")
  296. .each((_i, row) => {
  297. $(row)
  298. .find("td:nth-child(6)")
  299. .after(`<td class="rank-per-second primary main-table-number"><center><span class="zmdi zmdi-spinner zmdi-hc-spin" style="color:white font-size:24px"></center></span></td>`);
  300. rows.push(row);
  301. });
  302. });
  303. }
  304.  
  305. // Events Tab
  306. if (queryParams.view === "events") {
  307. if (queryParams.type === "resources") {
  308. return;
  309. }
  310. $(".events-table")
  311. .find("thead tr th:nth-child(1)")
  312. .after(`<th class="ui-state-default sorting_disabled" rowspan="1" colspan="1"><div class="DataTables_sort_wrapper">Diff<span class="DataTables_sort_icon"></span></div></th>`);
  313.  
  314. $(".main-table-number").each((_i, cell) => {
  315. if (lastTimeDiff !== undefined) {
  316. const time = parseTime(cell);
  317. const diff = ((time - lastTimeDiff) / 1000).toFixed(3);
  318. const whiteListlastSkill = ["Ten", "Chi", "Jin", "Savage Claw"];
  319. const whiteListSkill = ["Quadruple Technical Finish", "Pneuma", "Star Prism"];
  320. let bgColor = "";
  321. const skillName = $(cell).next().next().find("a").text();
  322. if (queryParams.type === "casts" && !!queryParams.source && !whiteListlastSkill.includes(lastSkillName) && !!lastSkillName && !whiteListSkill.includes(skillName) && !!skillName) {
  323. if (diff < 0.576 && diff > 0.2) {
  324. bgColor = "background-color: chocolate !important;";
  325. }
  326. if (diff > 5) {
  327. bgColor = "background-color: gray !important;";
  328. }
  329. }
  330. lastSkillName = skillName;
  331. $(cell).after(`<td style="width: 2em; text-align: right; ${bgColor}">${diff}</td>`);
  332. lastTimeDiff = time;
  333. } else {
  334. $(cell).after(`<td style="width: 2em; text-align: right;"> - </td>`);
  335. lastTimeDiff = parseTime(cell);
  336. }
  337. });
  338.  
  339. if (queryParams.type === "casts" && queryParams.hostility === "1") {
  340. $(".event-ability-cell a").each((_i, cell) => {
  341. const actionId = $(cell).attr("href").split("/")[5];
  342. const hexId = parseInt(actionId).toString(16);
  343. $(cell).text(`${$(cell).text()} [${hexId}]`);
  344. });
  345. }
  346. }
  347.  
  348. // LB Pin
  349. if (queryParams.view === "events" && queryParams.type === "summary" && queryParams.pins === PIN_PRESETS.LB) {
  350. $(".events-table")
  351. .find("thead tr th:nth-last-child(3)")
  352. .after(`<th class="ui-state-default sorting_disabled" rowspan="1" colspan="1"><div class="DataTables_sort_wrapper">Active<span class="DataTables_sort_icon"></span></div></th>`);
  353. $(".events-table")
  354. .find("thead tr th:nth-last-child(2)")
  355. .after(`<th class="ui-state-default sorting_disabled" rowspan="1" colspan="1"><div class="DataTables_sort_wrapper">Bars<span class="DataTables_sort_icon"></span></div></th>`);
  356.  
  357. $(".event-description-cell").each((_i, cell) => {
  358. const text = $(cell).text();
  359. if (text === "Event") {
  360. $(cell).html(`<div class="DataTables_sort_wrapper">Limit Break Total<span class="DataTables_sort_icon"></span></div>`);
  361. return;
  362. }
  363.  
  364. if (!LB_REGEX.test(text)) {
  365. $(cell).before(`<td style="width: 2em; text-align: right; white-space: nowrap;"> * </td>`);
  366. $(cell).after(`<td style="width: 2em; text-align: right;"> * </td>`);
  367. return;
  368. }
  369.  
  370. const lb = text.match(LB_REGEX);
  371. const currentLb = Number(lb?.[1]);
  372. const currentBars = Number(lb?.[2]);
  373.  
  374. if (lb) {
  375. let diff;
  376. if (lastLbGain !== undefined) {
  377. diff = (currentLb - lastLbGain).toLocaleString();
  378. } else {
  379. diff = " - ";
  380. }
  381. lastLbGain = currentLb;
  382. let actualDiff = diff > 0 ? `+${diff}` : diff;
  383.  
  384. if (PASSIVE_LB_GAIN[currentBars - 1].includes(diff)) {
  385. // passive lb gain
  386. diff = " - ";
  387. } else {
  388. // active lb gain
  389. }
  390.  
  391. $(cell).before(`<td style="width: 2em; text-align: right; white-space: nowrap;">${diff}</td>`);
  392. $(cell).html(`${Number(currentLb).toLocaleString()} / ${(Number(currentBars) * 10000).toLocaleString()} <span style="float: right;">${actualDiff}</span>`);
  393. $(cell).after(`<td style="width: 2em; text-align: right;">${currentBars}</td>`);
  394. }
  395. });
  396. }
  397. };
  398.  
  399. updateTable(onTableChange);
  400. }
  401.  
  402. // Zone Rankings Page
  403. if (ZONE_RANKINGS_PATH_REGEX.test(location.pathname)) {
  404. const onTableChange = () => {
  405. // Auto Translate Report Links
  406. $(".main-table-name").each((_i, cell) => {
  407. if ($(cell).find(".main-table-guild").attr("href").includes("translate=true")) return;
  408. if ($(cell).find(".main-table-guild").attr("href").includes("guild")) return;
  409. $(cell)
  410. .find(".main-table-guild")
  411. .attr("href", `${$(cell).find(".main-table-guild").attr("href")}&translate=true`);
  412. });
  413. };
  414.  
  415. onTableChange();
  416. updateTable(onTableChange);
  417. }
  418.  
  419. // Character Page
  420. if (CHARACTER_PATH_REGEX.test(location.pathname)) {
  421. // Chad Bradly's Profile Customization
  422. const CHAD_ID_REGEX = /\/character\/id\/12781922/;
  423. const CHAD_NAME_REGEX = /\/character\/na\/sargatanas\/chad%20bradly/;
  424. const CHAD_ICON_URL = "https://media.tenor.com/epNMHGvRyHcAAAAd/gigachad-chad.gif";
  425.  
  426. if (CHAD_ID_REGEX.test(location.pathname) || CHAD_NAME_REGEX.test(location.pathname)) {
  427. $("#character-portrait-image").attr("src", CHAD_ICON_URL);
  428. }
  429. }
  430.  
  431. // Profile Page
  432. if (PROFILE_PATH_REGEX.test(location.pathname)) {
  433. const $extension = $(`
  434. <div id="extension" class="dialog-block">
  435. <div id="extension-title" class="dialog-title">FF Progs</div>
  436. <div id="extension-content" style="margin:1em"></div>
  437. </div>
  438. `);
  439.  
  440. const $apiInputContainer = $(`
  441. <div id="api-input-container" style="margin:1em">
  442. <div>Enter your FFLogs API Key</div>
  443. <input type="text" id="api-key-input" style="margin-left: 10px" value="${apiKey || ""}">
  444. <input type="button" id="api-save-button" style="margin-left: 10px" value="${apiKey ? "Update API Key" : "Save API Key"}">
  445. </div>
  446. `);
  447.  
  448. const $apiStatus = $(`
  449. <div id="api-status" style="margin:1em; display: ${apiKey ? "block" : "none"}">
  450. <div>API Key ${apiKey ? "saved" : "not saved"}</div>
  451. <input type="button" id="api-remove-button" style="margin-left: 10px" value="Remove API Key">
  452. </div>
  453. `);
  454.  
  455. const saveApiKey = () => {
  456. const newApiKey = $("#api-key-input").val().trim();
  457. if (newApiKey) {
  458. GM_setValue("apiKey", newApiKey);
  459. $apiStatus.show().find("div").text("API Key saved");
  460. $apiInputContainer.hide();
  461. setTimeout(() => {
  462. $apiStatus.hide();
  463. $apiInputContainer.show();
  464. }, 2000);
  465. }
  466. };
  467.  
  468. const removeApiKey = () => {
  469. GM_deleteValue("apiKey");
  470. $apiStatus.show().find("div").text("API Key removed");
  471. $apiStatus.find("#api-remove-button").remove();
  472. $apiInputContainer.show();
  473. setTimeout(() => {
  474. $apiStatus.hide();
  475. }, 2000);
  476. };
  477.  
  478. $extension.insertAfter("#api");
  479. $apiInputContainer.appendTo("#extension-content");
  480. $apiStatus.appendTo("#extension-content");
  481.  
  482. $apiInputContainer.on("click", "#api-save-button", saveApiKey);
  483. $apiStatus.on("click", "#api-remove-button", removeApiKey);
  484. }
  485. })();