ylOppTacticsPreview (Modified)

Shows the latest tactics used by an upcoming opponent from the scheduled matches page

  1. // ==UserScript==
  2. // @name ylOppTacticsPreview (Modified)
  3. // @namespace douglaskampl
  4. // @version 4.8
  5. // @description Shows the latest tactics used by an upcoming opponent from the scheduled matches page
  6. // @author kostrzak16 (feat. Douglas and xente)
  7. // @match https://www.managerzone.com/?p=match&sub=scheduled
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=managerzone.com
  9. // @grant GM_addStyle
  10. // @grant GM_getResourceText
  11. // @require https://cdnjs.cloudflare.com/ajax/libs/spin.js/2.3.2/spin.min.js
  12. // @resource oppTacticsPreviewStyles https://br18.org/mz/userscript/other/oppTacticsPreview.css
  13. // @run-at document-idle
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. 'use strict';
  19.  
  20. GM_addStyle(GM_getResourceText('oppTacticsPreviewStyles'));
  21.  
  22. const CONSTANTS = {
  23. MAX_OPPONENT_TACTICS: 10,
  24. SELECTORS: {
  25. FIXTURES_LIST: '#fixtures-results-list-wrapper',
  26. STATS_XENTE: '#legendDiv',
  27. ELO_SCHEDULED: '#eloScheduledSelect',
  28. HOME_TEAM: '.home-team-column.flex-grow-1',
  29. SELECT_WRAPPER: 'dd.set-default-wrapper'
  30. },
  31. MATCH_TYPES: ['u18', 'u21', 'u23', 'no_restriction'],
  32. MATCH_STATS_URL: (matchId) => 'https://www.managerzone.com/matchviewer/getMatchFiles.php?type=stats&mid=' + matchId + '&sport=soccer'
  33. };
  34.  
  35. let ourTeamName = null;
  36. let selectedMatchTypeG = '';
  37. let currentTidValue = '';
  38. let currentOpponent = '';
  39. let currentOpponentTid = '';
  40. let lastMagnifierRect = null;
  41. let spinnerInstance = null;
  42.  
  43. const observer = new MutationObserver(() => {
  44. insertIconsAndListeners();
  45. });
  46.  
  47. function startObserving() {
  48. const fixturesList = document.querySelector(CONSTANTS.SELECTORS.FIXTURES_LIST);
  49. if (fixturesList) {
  50. observer.observe(fixturesList, {
  51. childList: true,
  52. subtree: true
  53. });
  54. }
  55. }
  56.  
  57. function showLoadingSpinner() {
  58. if (spinnerInstance) return;
  59.  
  60. const spinnerContainer = document.createElement('div');
  61. spinnerContainer.id = 'spinjs-overlay';
  62. spinnerContainer.style.position = 'fixed';
  63. spinnerContainer.style.top = '0';
  64. spinnerContainer.style.left = '0';
  65. spinnerContainer.style.width = '100vw';
  66. spinnerContainer.style.height = '100vh';
  67. spinnerContainer.style.background = 'rgba(0, 0, 0, 0.4)';
  68. spinnerContainer.style.zIndex = '999999';
  69. document.body.appendChild(spinnerContainer);
  70.  
  71. const opts = {
  72. lines: 12,
  73. length: 16,
  74. width: 6,
  75. radius: 20,
  76. scale: 1,
  77. corners: 1,
  78. color: '#FFC0CB',
  79. opacity: 0.25,
  80. rotate: 0,
  81. direction: 1,
  82. speed: 1,
  83. trail: 60,
  84. fps: 20,
  85. zIndex: 2e9,
  86. className: 'spinner',
  87. top: '50%',
  88. left: '50%',
  89. shadow: false,
  90. hwaccel: false,
  91. position: 'absolute'
  92. };
  93.  
  94. spinnerInstance = new Spinner(opts).spin(spinnerContainer);
  95. }
  96.  
  97. function hideLoadingSpinner() {
  98. if (spinnerInstance) {
  99. spinnerInstance.stop();
  100. spinnerInstance = null;
  101. }
  102. const spinnerContainer = document.getElementById('spinjs-overlay');
  103. if (spinnerContainer) spinnerContainer.remove();
  104. }
  105.  
  106. async function fetchLatestTactics(tidValue, opponent, matchType, opponentTid) {
  107. selectedMatchTypeG = matchType;
  108. currentTidValue = tidValue;
  109. currentOpponent = opponent;
  110. currentOpponentTid = opponentTid;
  111. try {
  112. showLoadingSpinner();
  113.  
  114. const response = await fetch(
  115. 'https://www.managerzone.com/ajax.php?p=matches&sub=list&sport=soccer',
  116. {
  117. method: 'POST',
  118. headers: {
  119. 'Accept': 'application/json',
  120. 'Content-Type': 'application/x-www-form-urlencoded'
  121. },
  122. body: 'type=played&hidescore=false&tid1=' + tidValue + '&offset=&selectType=' + matchType + '&limit=default',
  123. credentials: 'include'
  124. }
  125. );
  126.  
  127. if (!response.ok) throw new Error('Network response was not ok');
  128. const data = await response.json();
  129. processTacticsData(data);
  130. } catch (_) {
  131. } finally {
  132. hideLoadingSpinner();
  133. }
  134. }
  135.  
  136. function processTacticsData(data) {
  137. const parser = new DOMParser();
  138. const htmlDocument = parser.parseFromString(data.list, 'text/html');
  139. const scoreShownLinks = htmlDocument.querySelectorAll('a.score-shown');
  140. const container = createTacticsContainer(selectedMatchTypeG, currentOpponent);
  141. document.body.appendChild(container);
  142. const listWrapper = container.querySelector('.tactics-list');
  143. if (scoreShownLinks.length === 0) {
  144. const message = document.createElement('div');
  145. message.style.textAlign = 'center';
  146. message.style.color = '#555';
  147. message.style.fontSize = '12px';
  148. message.style.padding = '10px';
  149. message.textContent = 'No recent tactics found for the selected match type.';
  150. listWrapper.appendChild(message);
  151. container.classList.add('fade-in');
  152. return;
  153. }
  154. scoreShownLinks.forEach((link, index) => {
  155. if (index >= CONSTANTS.MAX_OPPONENT_TACTICS) return;
  156. const dl = link.closest('dl');
  157. const theScore = link.textContent.trim();
  158. const homeTeamName = dl.querySelector('.home-team-column .full-name')?.textContent.trim() || 'Home';
  159. const awayTeamName = dl.querySelector('.away-team-column .full-name')?.textContent.trim() || 'Away';
  160. const homeTeamLink = dl.querySelector('.home-team-column a.clippable');
  161. const awayTeamLink = dl.querySelector('.away-team-column a.clippable');
  162. let homeTid = null, awayTid = null;
  163. if (homeTeamLink) {
  164. homeTid = new URLSearchParams(new URL(homeTeamLink.href, location.href).search).get('tid');
  165. }
  166. if (awayTeamLink) {
  167. awayTid = new URLSearchParams(new URL(awayTeamLink.href, location.href).search).get('tid');
  168. }
  169. let homeGoals = 0;
  170. let awayGoals = 0;
  171. if (theScore.includes('-')) {
  172. const parts = theScore.split('-').map(x => x.trim());
  173. if (parts.length === 2) {
  174. homeGoals = parseInt(parts[0]) || 0;
  175. awayGoals = parseInt(parts[1]) || 0;
  176. }
  177. }
  178. const mid = extractMidFromUrl(link.href);
  179. const tacticUrl = 'https://www.managerzone.com/dynimg/pitch.php?match_id=' + mid;
  180. const resultUrl = 'https://www.managerzone.com/?p=match&sub=result&mid=' + mid;
  181. const opponentIsHome = (homeTid === currentTidValue);
  182. const canvas = createCanvasWithReplacedColors(tacticUrl, opponentIsHome);
  183. const item = document.createElement('div');
  184. item.className = 'tactic-item';
  185. let opponentGoals = opponentIsHome ? homeGoals : awayGoals;
  186. let otherGoals = opponentIsHome ? awayGoals : homeGoals;
  187. if (opponentGoals > otherGoals) {
  188. item.style.backgroundColor = '#daf8da';
  189. } else if (opponentGoals < otherGoals) {
  190. item.style.backgroundColor = '#f8dada';
  191. } else {
  192. item.style.backgroundColor = '#f0f0f0';
  193. }
  194. const linkA = document.createElement('a');
  195. linkA.href = resultUrl;
  196. linkA.target = '_blank';
  197. linkA.className = 'tactic-link';
  198. linkA.style.color = '#333';
  199. linkA.style.textDecoration = 'none';
  200. linkA.appendChild(canvas);
  201. const scoreP = document.createElement('p');
  202. scoreP.textContent = homeTeamName + ' ' + theScore + ' ' + awayTeamName;
  203. linkA.appendChild(scoreP);
  204. item.appendChild(linkA);
  205. addPlaystyleHover(mid, canvas, currentOpponentTid);
  206. listWrapper.appendChild(item);
  207. });
  208. container.classList.add('fade-in');
  209. }
  210.  
  211. function showMatchTypeModal(tidValue, opponent, sourceElement, opponentTid) {
  212. const existingModal = document.getElementById('match-type-modal');
  213. if (existingModal) {
  214. fadeOutAndRemove(existingModal);
  215. }
  216. const modal = document.createElement('div');
  217. modal.id = 'match-type-modal';
  218. modal.classList.add('fade-in');
  219. const label = document.createElement('label');
  220. label.textContent = 'Select match type:';
  221. modal.appendChild(label);
  222. const select = document.createElement('select');
  223. CONSTANTS.MATCH_TYPES.forEach(type => {
  224. const option = document.createElement('option');
  225. option.value = type;
  226. let labelText;
  227. if (type === 'no_restriction') {
  228. labelText = 'Senior';
  229. } else {
  230. labelText = type.replace('_', ' ').toUpperCase();
  231. }
  232. option.textContent = labelText;
  233. select.appendChild(option);
  234. });
  235. modal.appendChild(select);
  236. const btnGroup = document.createElement('div');
  237. btnGroup.className = 'btn-group';
  238. const okButton = document.createElement('button');
  239. okButton.textContent = 'OK';
  240. okButton.onclick = () => {
  241. fadeOutAndRemove(modal);
  242. fetchLatestTactics(tidValue, opponent, select.value, opponentTid);
  243. };
  244. const cancelButton = document.createElement('button');
  245. cancelButton.textContent = 'Cancel';
  246. cancelButton.onclick = () => fadeOutAndRemove(modal);
  247. btnGroup.append(okButton, cancelButton);
  248. modal.appendChild(btnGroup);
  249. document.body.appendChild(modal);
  250.  
  251. const rect = sourceElement.getBoundingClientRect();
  252. lastMagnifierRect = {
  253. left: window.scrollX + rect.left,
  254. top: window.scrollY + rect.top,
  255. bottom: window.scrollY + rect.bottom,
  256. width: rect.width,
  257. height: rect.height
  258. };
  259. modal.style.position = 'absolute';
  260. modal.style.top = (lastMagnifierRect.bottom + 5) + 'px';
  261. modal.style.left = lastMagnifierRect.left + 'px';
  262. }
  263.  
  264. function createTacticsContainer(matchType, opponent) {
  265. const existingContainer = document.getElementById('tactics-container');
  266. if (existingContainer) {
  267. fadeOutAndRemove(existingContainer);
  268. }
  269. const container = document.createElement('div');
  270. container.id = 'tactics-container';
  271. container.className = 'tactics-container';
  272. const header = document.createElement('div');
  273. header.className = 'tactics-header';
  274. const title = document.createElement('div');
  275. title.className = 'match-info-text';
  276. const modalTitleMatchType = matchType === 'no_restriction' ? 'Senior' : matchType.replace('_', ' ').toUpperCase();
  277. title.innerHTML = '<div class="title-main">' + (opponent ? opponent : '') + ' (' + modalTitleMatchType + ')</div><div class="title-subtitle">' + opponent + '\'s latest tactics are represented by black dots with white outlines: <span style="display:inline-block;width:6px;height:6px;background:#000;border:1px solid #fff;margin-left:2px;vertical-align:middle;"></span></div>';
  278. header.appendChild(title);
  279. const closeButton = document.createElement('button');
  280. closeButton.className = 'close-button';
  281. closeButton.textContent = '×';
  282. closeButton.onclick = () => fadeOutAndRemove(container);
  283. header.appendChild(closeButton);
  284. container.appendChild(header);
  285. const listWrapper = document.createElement('div');
  286. listWrapper.className = 'tactics-list';
  287. container.appendChild(listWrapper);
  288. document.body.appendChild(container);
  289. if (lastMagnifierRect) {
  290. const modalWidth = 420;
  291. const leftPos = lastMagnifierRect.left + (lastMagnifierRect.width / 2) - (modalWidth / 2);
  292. const topPos = lastMagnifierRect.bottom - 350;
  293. container.style.position = 'absolute';
  294. container.style.top = topPos + 'px';
  295. container.style.left = leftPos + 'px';
  296. container.style.transform = 'none';
  297. }
  298. return container;
  299. }
  300.  
  301. function fadeOutAndRemove(el) {
  302. el.classList.remove('fade-in');
  303. el.classList.add('fade-out');
  304. setTimeout(() => {
  305. if (el.parentNode) el.parentNode.removeChild(el);
  306. }, 200);
  307. }
  308.  
  309. function identifyUserTeamName() {
  310. const ddRows = document.querySelectorAll('dd.odd');
  311. const countMap = new Map();
  312. let totalMatches = 0;
  313. ddRows.forEach(dd => {
  314. const homeName = dd.querySelector('.home-team-column .full-name')?.textContent.trim();
  315. const awayName = dd.querySelector('.away-team-column .full-name')?.textContent.trim();
  316. if (homeName && awayName) {
  317. totalMatches++;
  318. countMap.set(homeName, (countMap.get(homeName) || 0) + 1);
  319. countMap.set(awayName, (countMap.get(awayName) || 0) + 1);
  320. }
  321. });
  322. for (const [name, count] of countMap.entries()) {
  323. if (count === totalMatches) {
  324. return name;
  325. }
  326. }
  327. return null;
  328. }
  329.  
  330. function insertIconsAndListeners() {
  331. ourTeamName = ourTeamName || identifyUserTeamName();
  332. if (!ourTeamName) return;
  333. document.querySelectorAll('dd.odd').forEach(dd => {
  334. const selectWrapper = dd.querySelector(CONSTANTS.SELECTORS.SELECT_WRAPPER);
  335. if (selectWrapper) {
  336. const select = selectWrapper.querySelector('select');
  337. if (select && !selectWrapper.querySelector('.magnifier-icon')) {
  338. const homeTeamName = dd.querySelector('.home-team-column .full-name')?.textContent.trim();
  339. const awayTeamName = dd.querySelector('.away-team-column .full-name')?.textContent.trim();
  340. let opponentName = null;
  341. let opponentTid = null;
  342. const homeTeamLink = dd.querySelector('.home-team-column a.clippable');
  343. const awayTeamLink = dd.querySelector('.away-team-column a.clippable');
  344. let homeTid = null, awayTid = null;
  345. if (homeTeamLink) {
  346. homeTid = new URLSearchParams(new URL(homeTeamLink.href, location.href).search).get('tid');
  347. }
  348. if (awayTeamLink) {
  349. awayTid = new URLSearchParams(new URL(awayTeamLink.href, location.href).search).get('tid');
  350. }
  351. if (homeTeamName === ourTeamName && awayTeamName && awayTid) {
  352. opponentName = awayTeamName;
  353. opponentTid = awayTid;
  354. } else if (awayTeamName === ourTeamName && homeTeamName && homeTid) {
  355. opponentName = homeTeamName;
  356. opponentTid = homeTid;
  357. } else {
  358. return;
  359. }
  360.  
  361. if (!opponentTid) return;
  362.  
  363. const iconWrapper = document.createElement('span');
  364. iconWrapper.className = 'magnifier-icon';
  365. iconWrapper.dataset.tid = opponentTid;
  366. iconWrapper.dataset.opponent = opponentName;
  367. iconWrapper.title = 'Click to check latest tactics for this opponent';
  368. iconWrapper.textContent = '🔍';
  369.  
  370. select.insertAdjacentElement('afterend', iconWrapper);
  371. }
  372. }
  373. });
  374. }
  375.  
  376. function extractMidFromUrl(url) {
  377. return new URLSearchParams(new URL(url, location.href).search).get('mid');
  378. }
  379.  
  380. function processImage(context, canvas, image, opponentIsHome) {
  381. if (opponentIsHome) {
  382. context.translate(canvas.width / 2, canvas.height / 2);
  383. context.rotate(Math.PI);
  384. context.translate(-canvas.width / 2, -canvas.height / 2);
  385. }
  386. context.drawImage(image, 0, 0, canvas.width, canvas.height);
  387. const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
  388. const data = imageData.data;
  389. const darkGreen = { r: 0, g: 100, b: 0 };
  390. for (let i = 0; i < data.length; i += 4) {
  391. const r = data[i];
  392. const g = data[i + 1];
  393. const b = data[i + 2];
  394. const isBlack = (r < 30 && g < 30 && b < 30);
  395. const isYellow = (r > 200 && g > 200 && b < 100);
  396. if (opponentIsHome) {
  397. if (isYellow) {
  398. data[i] = 0; data[i + 1] = 0; data[i + 2] = 0;
  399. } else if (isBlack) {
  400. data[i] = darkGreen.r; data[i + 1] = darkGreen.g; data[i + 2] = darkGreen.b;
  401. }
  402. } else {
  403. if (isBlack) {
  404. data[i] = 0; data[i + 1] = 0; data[i + 2] = 0;
  405. } else if (isYellow) {
  406. data[i] = darkGreen.r; data[i + 1] = darkGreen.g; data[i + 2] = darkGreen.b;
  407. }
  408. }
  409. }
  410. const tempData = new Uint8ClampedArray(data);
  411. for (let y = 0; y < canvas.height; y++) {
  412. for (let x = 0; x < canvas.width; x++) {
  413. const i = (y * canvas.width + x) * 4;
  414. if (data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 0) {
  415. for (let dy = -1; dy <= 1; dy++) {
  416. for (let dx = -1; dx <= 1; dx++) {
  417. if (dx === 0 && dy === 0) continue;
  418. const nx = x + dx;
  419. const ny = y + dy;
  420. if (nx >= 0 && nx < canvas.width && ny >= 0 && ny < canvas.height) {
  421. const ni = (ny * canvas.width + nx) * 4;
  422. if (!(data[ni] === 0 && data[ni + 1] === 0 && data[ni + 2] === 0)) {
  423. tempData[ni] = 255; tempData[ni + 1] = 255; tempData[ni + 2] = 255;
  424. }
  425. }
  426. }
  427. }
  428. }
  429. }
  430. }
  431. context.putImageData(new ImageData(tempData, canvas.width, canvas.height), 0, 0);
  432. }
  433.  
  434. function createCanvas(width, height) {
  435. const canvas = document.createElement('canvas');
  436. canvas.width = width;
  437. canvas.height = height;
  438. canvas.style.pointerEvents = 'auto';
  439. return canvas;
  440. }
  441.  
  442. function createCanvasWithReplacedColors(imageUrl, opponentIsHome) {
  443. const canvas = createCanvas(150, 200);
  444. const context = canvas.getContext('2d');
  445. const image = new Image();
  446. image.crossOrigin = 'Anonymous';
  447. image.onload = () => processImage(context, canvas, image, opponentIsHome);
  448. image.src = imageUrl;
  449. return canvas;
  450. }
  451.  
  452. async function fetchPlaystyleChanges(mid, opponentTid) {
  453. try {
  454. const res = await fetch(CONSTANTS.MATCH_STATS_URL(mid));
  455. const txt = await res.text();
  456. const parser = new DOMParser();
  457. const xml = parser.parseFromString(txt, 'text/xml');
  458. const tactics = xml.querySelectorAll('Events Tactic');
  459. const out = [];
  460. tactics.forEach(n => {
  461. if (n.getAttribute('teamId') !== opponentTid) {
  462. return;
  463. }
  464. const tType = n.getAttribute('type');
  465. if (tType === 'playstyle' || tType === 'aggression' || tType === 'tactic') {
  466. const time = n.getAttribute('time');
  467. const setting = n.getAttribute('new_setting');
  468. out.push('Minute ' + time + ': ' + tType + ' -> ' + setting);
  469. }
  470. });
  471. return out.length ? out.join('<br>') : 'No playstyle, mentality or pressing changes detected';
  472. } catch (_) {
  473. return 'No info';
  474. }
  475. }
  476.  
  477. function addPlaystyleHover(mid, canvas, opponentTid) {
  478. const tooltip = document.createElement('div');
  479. tooltip.style.position = 'absolute';
  480. tooltip.style.background = '#333';
  481. tooltip.style.color = '#fff';
  482. tooltip.style.padding = '5px';
  483. tooltip.style.borderRadius = '3px';
  484. tooltip.style.fontSize = '12px';
  485. tooltip.style.display = 'none';
  486. tooltip.style.zIndex = '9999';
  487. document.body.appendChild(tooltip);
  488.  
  489. canvas.addEventListener('mouseover', async (ev) => {
  490. tooltip.style.display = 'block';
  491. tooltip.style.top = ev.pageY + 15 + 'px';
  492. tooltip.style.left = ev.pageX + 5 + 'px';
  493. tooltip.innerHTML = 'Loading...';
  494. const info = await fetchPlaystyleChanges(mid, opponentTid);
  495. tooltip.innerHTML = info;
  496. });
  497. canvas.addEventListener('mousemove', (ev) => {
  498. tooltip.style.top = ev.pageY + 15 + 'px';
  499. tooltip.style.left = ev.pageX + 5 + 'px';
  500. });
  501. canvas.addEventListener('mouseout', () => {
  502. tooltip.style.display = 'none';
  503. });
  504. }
  505.  
  506. function waitForEloValues() {
  507. const interval = setInterval(() => {
  508. const elements = document.querySelectorAll(CONSTANTS.SELECTORS.HOME_TEAM);
  509. if (elements.length > 0 && elements[elements.length - 1]?.innerHTML.includes('br')) {
  510. clearInterval(interval);
  511. insertIconsAndListeners();
  512. }
  513. }, 100);
  514. setTimeout(() => {
  515. clearInterval(interval);
  516. insertIconsAndListeners();
  517. }, 1500);
  518. }
  519.  
  520. function handleClickEvents(e) {
  521. const clickedMagnifier = e.target.closest('.magnifier-icon');
  522.  
  523. if (clickedMagnifier) {
  524. e.preventDefault();
  525. e.stopPropagation();
  526. const tidValue = clickedMagnifier.dataset.tid;
  527. const opponent = clickedMagnifier.dataset.opponent;
  528. if (!tidValue) return;
  529. showMatchTypeModal(ourTeamName === opponent ? ourTeamName : tidValue, opponent, clickedMagnifier, tidValue);
  530. return;
  531. }
  532.  
  533. const tacticsContainer = document.getElementById('tactics-container');
  534. const matchTypeModal = document.getElementById('match-type-modal');
  535.  
  536. if (tacticsContainer && !tacticsContainer.contains(e.target) && !clickedMagnifier) {
  537. fadeOutAndRemove(tacticsContainer);
  538. }
  539. if (matchTypeModal && !matchTypeModal.contains(e.target) && !clickedMagnifier) {
  540. fadeOutAndRemove(matchTypeModal);
  541. }
  542. }
  543.  
  544. function run() {
  545. const statsXenteRunning = document.querySelector(CONSTANTS.SELECTORS.STATS_XENTE);
  546. const eloScheduledSelected = document.querySelector(CONSTANTS.SELECTORS.ELO_SCHEDULED)?.checked;
  547. if (statsXenteRunning && eloScheduledSelected) {
  548. waitForEloValues();
  549. } else {
  550. insertIconsAndListeners();
  551. }
  552. startObserving();
  553. }
  554.  
  555. document.body.addEventListener('click', handleClickEvents);
  556. run();
  557. })();