// ==UserScript==
// @name ylOppTacticsPreview (Modified)
// @namespace douglaskampl
// @version 5.5.0
// @description Shows the most recent tactics used by an opponent
// @author kostrzak16 (feat. Douglas and xente)
// @match https://www.managerzone.com/?p=match&sub=scheduled
// @icon https://www.google.com/s2/favicons?sz=64&domain=managerzone.com
// @grant GM_addStyle
// @grant GM_getResourceText
// @grant GM_getValue
// @grant GM_setValue
// @require https://cdnjs.cloudflare.com/ajax/libs/spin.js/2.3.2/spin.min.js
// @resource ylotp https://mzdv.me/mz/userscript/other/ylotp550.css
// @run-at document-idle
// @license MIT
// ==/UserScript==
(() => {
'use strict';
/**
* @class OpponentTacticsPreview
* @description A class responsible for all functionality related to previewing opponent tactics.
*/
class OpponentTacticsPreview {
/**
* @description A set of constant values used throughout the class.
* @static
* @readonly
*/
static CONSTANTS = {
MATCH_TYPE_GROUPS: {
'All': [
{ id: 'no_restriction', label: 'Senior' },
{ id: 'u23', label: 'U23' },
{ id: 'u21', label: 'U21' },
{ id: 'u18', label: 'U18' }
],
'World League': [
{ id: 'world_series', label: 'Senior WL' },
{ id: 'u23_world_series', label: 'U23 WL' },
{ id: 'u21_world_series', label: 'U21 WL' },
{ id: 'u18_world_series', label: 'U18 WL' }
],
'Official League': [
{ id: 'series', label: 'Senior League' },
{ id: 'u23_series', label: 'U23 League' },
{ id: 'u21_series', label: 'U21 League' },
{ id: 'u18_series', label: 'U18 League' }
]
},
URLS: {
CLUBHOUSE: 'https://www.managerzone.com/?p=clubhouse',
MATCH_LIST: 'https://www.managerzone.com/ajax.php?p=matches&sub=list&sport=soccer',
MATCH_STATS: (matchId) => `https://www.managerzone.com/matchviewer/getMatchFiles.php?type=stats&mid=${matchId}&sport=soccer`,
MATCH_RESULT: (matchId) => `https://www.managerzone.com/?p=match&sub=result&mid=${matchId}`,
MATCH_CHECK: (matchId) => `https://www.managerzone.com/ajax.php?p=matchViewer&sub=check-match&type=2d&sport=soccer&mid=${matchId}`,
PITCH_IMG: (matchId) => `https://www.managerzone.com/dynimg/pitch.php?match_id=${matchId}`,
OFFICIAL_LEAGUE_SCHEDULE: (type, sid, tid) => `https://www.managerzone.com/ajax.php?p=league&type=${type}&sid=${sid}&tid=${tid}&sport=soccer&sub=schedule`
},
STORAGE_KEYS: {
MATCH_LIMIT: 'ylopp_match_limit',
SAVED_TEAMS: 'ylopp_saved_teams',
USER_TEAM_ID: 'ylopp_user_team_id',
LEAGUE_CACHE_KEY: 'ylopp_league_data'
},
DEFAULTS: {
MATCH_LIMIT: 10,
MAX_SAVED_TEAMS: 15,
MAX_MATCH_LIMIT: 100
},
SELECTORS: {
FIXTURES_LIST: '#fixtures-results-list-wrapper',
STATS_XENTE: '#legendDiv',
ELO_SCHEDULED: '#eloScheduledSelect',
HOME_TEAM: '.home-team-column.flex-grow-1'
},
CACHE_EXPIRATION_MS: 24 * 60 * 60 * 1000
};
/**
* @constructor
* @description Initializes the class properties.
*/
constructor() {
this.myTeam = null;
this.myTeamId = null;
this.currentOpponentTid = '';
this.spinnerInstance = null;
this.playstyleCache = {};
this.tooltipElement = null;
this.tooltipHideTimeout = null;
this.observer = new MutationObserver(() => {
this.insertIconsAndListeners();
});
}
/**
* @description Retrieves the user's preferred match limit from storage.
* @returns {number} The match limit.
*/
getMatchLimit = () => {
return GM_getValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.MATCH_LIMIT, OpponentTacticsPreview.CONSTANTS.DEFAULTS.MATCH_LIMIT);
};
/**
* @description Sets the match limit in storage and provides user feedback.
* @param {string} limit - The new limit value as a string.
* @param {HTMLElement} confirmElem - The element to show the confirmation message.
*/
setMatchLimit = (limit, confirmElem) => {
const numLimit = parseInt(limit, 10);
if (!isNaN(numLimit) && numLimit > 0 && numLimit <= OpponentTacticsPreview.CONSTANTS.DEFAULTS.MAX_MATCH_LIMIT) {
GM_setValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.MATCH_LIMIT, numLimit);
if (confirmElem) {
confirmElem.innerHTML = '<i class="fa fa-check"></i> Atualizado :)';
confirmElem.classList.add('visible');
setTimeout(() => {
confirmElem.classList.remove('visible');
}, 2000);
}
}
};
/**
* @description Retrieves the list of recently saved teams.
* @returns {Array<Object>} An array of team objects.
*/
getSavedTeams = () => {
return GM_getValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.SAVED_TEAMS, []);
};
/**
* @description Saves a team ID and name to storage.
* @param {string} teamId - The ID of the team.
* @param {string} teamName - The name of the team.
*/
saveTeam = (teamId, teamName) => {
if (!teamId || !teamName || teamName.startsWith('Team ')) {
return;
}
let teams = this.getSavedTeams();
const existingIndex = teams.findIndex(team => team.id === teamId);
if (existingIndex > -1) {
teams.splice(existingIndex, 1);
}
teams.unshift({ id: teamId, name: teamName });
const trimmedTeams = teams.slice(0, OpponentTacticsPreview.CONSTANTS.DEFAULTS.MAX_SAVED_TEAMS);
GM_setValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.SAVED_TEAMS, trimmedTeams);
};
/**
* @description Starts observing the DOM for mutations to re-insert icons.
*/
startObserving = () => {
const fixturesList = document.querySelector(OpponentTacticsPreview.CONSTANTS.SELECTORS.FIXTURES_LIST);
if (fixturesList) {
this.observer.observe(fixturesList, { childList: true, subtree: true });
}
};
/**
* @description Displays a global loading spinner.
*/
showLoadingSpinner = () => {
if (this.spinnerInstance) return;
const spinnerContainer = document.createElement('div');
spinnerContainer.id = 'spinjs-overlay';
document.body.appendChild(spinnerContainer);
this.spinnerInstance = new Spinner({ color: '#FFFFFF', lines: 12, top: '50%', left: '50%' }).spin(spinnerContainer);
};
/**
* @description Hides the global loading spinner.
*/
hideLoadingSpinner = () => {
if (this.spinnerInstance) {
this.spinnerInstance.stop();
this.spinnerInstance = null;
}
const spinnerContainer = document.getElementById('spinjs-overlay');
if (spinnerContainer) {
spinnerContainer.remove();
}
};
/**
* @description Extracts a team name from an HTML document by finding the most frequent name.
* @param {HTMLDocument} htmlDocument - The HTML document to parse.
* @param {string} teamId - The ID of the team.
* @returns {string|null} The team name or null if not found.
*/
extractTeamNameFromHtml = (htmlDocument, teamId) => {
const nameCounts = new Map();
htmlDocument.querySelectorAll('.teams-wrapper a.clippable').forEach(link => {
const linkUrl = new URL(link.href, location.href);
const linkTid = linkUrl.searchParams.get('tid');
if (linkTid === teamId) {
const fullName = link.querySelector('.full-name')?.textContent.trim();
if (fullName) {
nameCounts.set(fullName, (nameCounts.get(fullName) || 0) + 1);
}
}
});
if (nameCounts.size > 0) {
const mostFrequentName = [...nameCounts.entries()].reduce((a, b) => b[1] > a[1] ? b : a)[0];
return mostFrequentName;
}
const boldTeamNameElement = htmlDocument.querySelector('.teams-wrapper a.clippable > strong > .full-name');
const boldName = boldTeamNameElement ? boldTeamNameElement.textContent.trim() : null;
return boldName;
};
/**
* @description Fetches and processes the latest tactics for a given team.
* @async
* @param {string} teamId - The ID of the team.
* @param {string} matchType - The type of match to search for.
*/
fetchLatestTactics = async (teamId, matchType) => {
const modal = document.getElementById('interaction-modal');
if (modal) this.fadeOutAndRemove(modal);
this.showLoadingSpinner();
try {
const response = await fetch(
OpponentTacticsPreview.CONSTANTS.URLS.MATCH_LIST, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: `type=played&hidescore=false&tid1=${teamId}&offset=&selectType=${matchType}&limit=max`,
credentials: 'include'
});
if (!response.ok) throw new Error(`Network response was not ok: ${response.statusText}`);
const data = await response.json();
const parser = new DOMParser();
const htmlDocument = parser.parseFromString(data.list, 'text/html');
const actualTeamName = this.extractTeamNameFromHtml(htmlDocument, teamId);
const finalTeamName = actualTeamName || `Team ${teamId}`;
this.saveTeam(teamId, finalTeamName);
this.currentOpponentTid = teamId;
let matches = Array.from(htmlDocument.querySelectorAll('dl > dd.odd'))
.filter(this.isRelevantMatch)
.map(entry => this.parseMatchEntry(entry, finalTeamName))
.filter(Boolean);
const officialLeagueTypes = ['series', 'u23_series', 'u21_series', 'u18_series'];
if (officialLeagueTypes.includes(matchType)) {
const leagueMatches = await this.fetchLeagueScheduleMatches(htmlDocument, finalTeamName, teamId);
matches.push(...leagueMatches);
}
const uniqueMatches = Array.from(new Map(matches.map(m => [m.mid, m])).values());
this.processTacticsData(uniqueMatches, matchType, finalTeamName);
} catch (error) {
this.hideLoadingSpinner();
const message = document.createElement('div');
message.className = 'no-tactics-message';
message.textContent = 'Failed to fetch tactics data.';
const container = this.createTacticsContainer('Error', 'Data Fetch');
container.querySelector('.tactics-list').appendChild(message);
document.body.appendChild(container);
container.classList.add('fade-in');
} finally {
this.hideLoadingSpinner();
}
};
/**
* @description Fetches official league schedule matches, utilizing a single, expiring cache.
* @async
* @param {HTMLDocument} initialHtmlDoc - The initial HTML document to find the league link.
* @param {string} opponentName - The name of the opponent team.
* @param {string} teamId - The ID of the team.
* @returns {Promise<Array<Object>>} A promise that resolves to an array of parsed match data.
*/
fetchLeagueScheduleMatches = async (initialHtmlDoc, opponentName, teamId) => {
try {
const leagueLink = initialHtmlDoc.querySelector('.responsive-hide.match-reference-text-wrapper a');
if (!leagueLink) {
return [];
}
const url = new URL(leagueLink.href, location.href);
const sid = url.searchParams.get('sid');
const type = url.searchParams.get('type');
if (!sid || !type) {
return [];
}
const cacheKey = OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.LEAGUE_CACHE_KEY;
const cachedData = GM_getValue(cacheKey, {});
const cacheEntry = cachedData[sid];
if (cacheEntry && (Date.now() - cacheEntry.timestamp < OpponentTacticsPreview.CONSTANTS.CACHE_EXPIRATION_MS)) {
const filteredMatches = cacheEntry.data.filter(m => m.homeTeamName === opponentName || m.awayTeamName === opponentName);
return filteredMatches.map(m => this.parseMatchData(m, opponentName));
}
const scheduleUrl = OpponentTacticsPreview.CONSTANTS.URLS.OFFICIAL_LEAGUE_SCHEDULE(type, sid, teamId);
const response = await fetch(scheduleUrl);
if (!response.ok) throw new Error(`Network response for league schedule was not ok: ${response.statusText}`);
const text = await response.text();
const scheduleDoc = new DOMParser().parseFromString(text, 'text/html');
const allMatches = [];
const rows = scheduleDoc.querySelectorAll('.hitlist.marker tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length !== 3) {
return;
}
const scoreLink = cells[1].querySelector('a');
if (!scoreLink) {
return;
}
const score = scoreLink?.textContent.trim();
if (!score || score.toLowerCase().includes('x')) {
return;
}
const mid = new URL(scoreLink.href, location.href).searchParams.get('mid');
const homeTeamName = cells[0].textContent.trim();
const awayTeamName = cells[2].textContent.trim();
allMatches.push({ mid, homeTeamName, awayTeamName, score });
});
cachedData[sid] = {
data: allMatches,
timestamp: Date.now()
};
GM_setValue(cacheKey, cachedData);
return allMatches
.filter(m => m.homeTeamName === opponentName || m.awayTeamName === opponentName)
.map(m => this.parseMatchData(m, opponentName));
} catch (error) {
return [];
}
};
/**
* @description Checks if a match entry is relevant.
* @param {HTMLElement} entry - The match entry element.
* @returns {boolean} True if the match is relevant, otherwise false.
*/
isRelevantMatch = (entry) => {
const wrapper = entry.querySelector('.responsive-hide.match-reference-text-wrapper');
return !wrapper || wrapper.querySelector('a') !== null;
};
/**
* @description Parses a match entry from an HTML element.
* @param {HTMLElement} entry - The match entry element.
* @param {string} opponentName - The name of the opponent team.
* @returns {Object|null} The parsed match data or null.
*/
parseMatchEntry = (entry, opponentName) => {
const link = entry.querySelector('a.score-shown');
if (!link) return null;
const dl = link.closest('dl');
const score = link.textContent.trim();
const homeTeamName = dl.querySelector('.home-team-column .full-name')?.textContent.trim() || 'Home';
const awayTeamName = dl.querySelector('.away-team-column .full-name')?.textContent.trim() || 'Away';
const mid = new URL(link.href, location.href).searchParams.get('mid');
if (!mid) return null;
return this.parseMatchData({ mid, homeTeamName, awayTeamName, score }, opponentName);
};
/**
* @description Parses match data and determines home/away goals.
* @param {Object} matchData - The raw match data.
* @param {string} opponentName - The name of the opponent team.
* @returns {Object} The parsed match data with goal information.
*/
parseMatchData = (matchData, opponentName) => {
let [homeGoals, awayGoals] = [0, 0];
if (matchData.score.includes('-')) {
const parts = matchData.score.split('-').map(x => parseInt(x.trim(), 10));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
[homeGoals, awayGoals] = parts;
}
}
const opponentIsHome = (matchData.homeTeamName === opponentName);
return { ...matchData, homeGoals, awayGoals, opponentIsHome };
};
/**
* @description Creates and displays the tactics container with match data.
* @param {Array<Object>} matches - An array of match objects.
* @param {string} matchType - The type of match.
* @param {string} opponentName - The name of the opponent team.
*/
processTacticsData = (matches, matchType, opponentName) => {
const container = this.createTacticsContainer(matchType, opponentName);
document.body.appendChild(container);
const listWrapper = container.querySelector('.tactics-list');
const limit = this.getMatchLimit();
const limitedMatches = matches.slice(0, limit);
if (limitedMatches.length === 0) {
const message = document.createElement('div');
message.className = 'no-tactics-message';
message.textContent = 'No recent valid tactics found for this team and category.';
listWrapper.appendChild(message);
container.classList.add('fade-in');
return;
}
limitedMatches.forEach(match => {
const tacticUrl = OpponentTacticsPreview.CONSTANTS.URLS.PITCH_IMG(match.mid);
const resultUrl = OpponentTacticsPreview.CONSTANTS.URLS.MATCH_RESULT(match.mid);
const canvas = this.createCanvasWithReplacedColors(tacticUrl, match.opponentIsHome);
const item = document.createElement('div');
item.className = 'tactic-item';
const opponentGoals = match.opponentIsHome ? match.homeGoals : match.awayGoals;
const otherGoals = match.opponentIsHome ? match.awayGoals : match.homeGoals;
if (opponentGoals > otherGoals) item.classList.add('tactic-win');
else if (opponentGoals < otherGoals) item.classList.add('tactic-loss');
else item.classList.add('tactic-draw');
const statusIndicator = document.createElement('div');
statusIndicator.className = 'playstyle-status-indicator';
item.appendChild(statusIndicator);
const linkA = document.createElement('a');
linkA.href = resultUrl;
linkA.target = '_blank';
linkA.className = 'tactic-link';
linkA.appendChild(canvas);
const scoreP = document.createElement('p');
scoreP.textContent = `${match.homeTeamName} ${match.score} ${match.awayTeamName}`;
linkA.appendChild(scoreP);
item.appendChild(linkA);
this.addPlaystyleHover(match.mid, this.currentOpponentTid, item);
listWrapper.appendChild(item);
});
container.classList.add('fade-in');
};
/**
* @description Shows the interaction modal for selecting tactics options.
* @param {string} teamId - The ID of the team.
* @param {HTMLElement} sourceElement - The element that triggered the modal.
*/
showInteractionModal = (teamId, sourceElement) => {
const existingModal = document.getElementById('interaction-modal');
if (existingModal) this.fadeOutAndRemove(existingModal);
const modal = document.createElement('div');
modal.id = 'interaction-modal';
modal.classList.add('fade-in');
const header = document.createElement('div');
header.className = 'interaction-modal-header';
const title = document.createElement('span');
header.appendChild(title);
const controlsWrapper = document.createElement('div');
controlsWrapper.style.display = 'flex';
controlsWrapper.style.alignItems = 'center';
controlsWrapper.style.gap = '10px';
const settingsIcon = document.createElement('span');
settingsIcon.className = 'settings-icon';
settingsIcon.innerHTML = '⚙';
controlsWrapper.appendChild(settingsIcon);
const closeIcon = document.createElement('i');
closeIcon.className = 'fa fa-times ylotp-close-icon';
closeIcon.onclick = () => this.fadeOutAndRemove(modal);
controlsWrapper.appendChild(closeIcon);
header.appendChild(controlsWrapper);
modal.appendChild(header);
const teamInputSection = this.createTeamInputSection(modal, teamId);
this.createTabbedButtons(modal, teamInputSection.teamIdInput);
const settingsPanel = this.createSettingsPanel(modal);
settingsIcon.onclick = () => {
settingsPanel.style.display = settingsPanel.style.display === 'block' ? 'none' : 'block';
};
document.body.appendChild(modal);
const rect = sourceElement.getBoundingClientRect();
modal.style.position = 'absolute';
modal.style.left = `${window.scrollX + rect.left}px`;
const initialTop = window.scrollY + rect.bottom + 5;
modal.style.top = `${initialTop}px`;
const modalRect = modal.getBoundingClientRect();
if (modalRect.bottom > window.innerHeight) {
modal.style.top = `${window.scrollY + window.innerHeight - modalRect.height - 10}px`;
}
};
/**
* @description Creates the team ID input section for the modal.
* @param {HTMLElement} container - The modal container.
* @param {string} initialTeamId - The initial team ID to populate the input.
* @returns {Object} An object containing the team ID input and recents dropdown.
*/
createTeamInputSection = (container, initialTeamId) => {
const section = document.createElement('div');
section.className = 'interaction-section team-input-section';
const label = document.createElement('label');
label.textContent = 'Team ID:';
label.htmlFor = 'team-id-input';
section.appendChild(label);
const teamIdInput = document.createElement('input');
teamIdInput.type = 'text';
teamIdInput.id = 'team-id-input';
teamIdInput.value = initialTeamId;
section.appendChild(teamIdInput);
const select = this.createRecentsDropdown(teamIdInput);
section.appendChild(select);
container.appendChild(section);
return { teamIdInput, recentsSelect: select };
};
/**
* @description Creates the "Recent Teams" dropdown for the modal.
* @param {HTMLElement} teamIdInput - The team ID input element.
* @returns {HTMLSelectElement} The created select element.
*/
createRecentsDropdown = (teamIdInput) => {
const select = document.createElement('select');
select.className = 'recents-select';
const defaultOption = document.createElement('option');
defaultOption.textContent = 'Recent Teams';
defaultOption.value = '';
select.appendChild(defaultOption);
this.getSavedTeams().forEach(team => {
const option = document.createElement('option');
option.value = team.id;
option.textContent = `${team.name} (${team.id})`;
select.appendChild(option);
});
select.onchange = () => {
if (select.value) {
teamIdInput.value = select.value;
}
};
return select;
};
/**
* @description Creates the tabbed buttons for match type selection.
* @param {HTMLElement} container - The modal container.
* @param {HTMLInputElement} teamIdInput - The team ID input element.
*/
createTabbedButtons = (container, teamIdInput) => {
const tabContainer = document.createElement('div');
tabContainer.className = 'tab-container';
const tabHeaders = document.createElement('div');
tabHeaders.className = 'tab-headers';
const tabContents = document.createElement('div');
tabContents.className = 'tab-contents';
Object.entries(OpponentTacticsPreview.CONSTANTS.MATCH_TYPE_GROUPS).forEach(([groupName, types], index) => {
const header = document.createElement('button');
header.className = 'tab-header';
header.textContent = groupName;
const content = document.createElement('div');
content.className = 'tab-content';
types.forEach(type => {
const button = document.createElement('button');
button.textContent = type.label;
button.onclick = () => {
const teamId = teamIdInput.value.trim();
if (!teamId || isNaN(parseInt(teamId, 10))) {
const error = document.createElement('div');
error.className = 'error-message';
error.textContent = 'Please enter a valid Team ID.';
const existingError = tabContainer.querySelector('.error-message');
if (existingError) existingError.remove();
tabContainer.insertBefore(error, tabContents);
return;
}
this.fetchLatestTactics(teamId, type.id);
};
content.appendChild(button);
});
header.onclick = () => {
tabContainer.querySelectorAll('.tab-header').forEach(h => h.classList.remove('active'));
tabContainer.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none');
header.classList.add('active');
content.style.display = 'flex';
};
tabHeaders.appendChild(header);
tabContents.appendChild(content);
if (index === 0) {
header.classList.add('active');
content.style.display = 'flex';
} else {
content.style.display = 'none';
}
});
tabContainer.appendChild(tabHeaders);
tabContainer.appendChild(tabContents);
container.appendChild(tabContainer);
};
/**
* @description Creates the settings panel for the modal.
* @param {HTMLElement} modalContainer - The modal container.
* @returns {HTMLElement} The created settings panel.
*/
createSettingsPanel = (modalContainer) => {
const panel = document.createElement('div');
panel.className = 'settings-panel';
panel.style.display = 'none';
const limitLabel = document.createElement('label');
limitLabel.textContent = `MatchLimit (1-${OpponentTacticsPreview.CONSTANTS.DEFAULTS.MAX_MATCH_LIMIT}):`;
panel.appendChild(limitLabel);
const inputWrapper = document.createElement('div');
inputWrapper.style.display = 'flex';
inputWrapper.style.alignItems = 'center';
const limitInput = document.createElement('input');
limitInput.type = 'text';
limitInput.inputMode = 'numeric';
limitInput.pattern = '[0-9]*';
limitInput.value = this.getMatchLimit();
const confirmationSpan = document.createElement('span');
confirmationSpan.className = 'save-confirmation';
limitInput.oninput = () => {
limitInput.value = limitInput.value.replace(/\D/g, '');
confirmationSpan.classList.remove('visible');
};
limitInput.onchange = () => this.setMatchLimit(limitInput.value, confirmationSpan);
inputWrapper.appendChild(limitInput);
inputWrapper.appendChild(confirmationSpan);
panel.appendChild(inputWrapper);
const note = document.createElement('small');
note.textContent = 'Note: the actual number of matches found may be restricted by MZ\'s own limits.';
panel.appendChild(note);
modalContainer.appendChild(panel);
return panel;
};
/**
* @description Creates the container for displaying tactics.
* @param {string} matchType - The type of match.
* @param {string} opponent - The opponent's name.
* @returns {HTMLElement} The created tactics container.
*/
createTacticsContainer = (matchType, opponent) => {
const existingContainer = document.getElementById('tactics-container');
if (existingContainer) {
this.fadeOutAndRemove(existingContainer);
}
const container = document.createElement('div');
container.id = 'tactics-container';
container.className = 'tactics-container';
const header = document.createElement('div');
header.className = 'tactics-header';
const title = document.createElement('div');
title.className = 'match-info-text';
let matchTypeLabel = matchType;
for (const group in OpponentTacticsPreview.CONSTANTS.MATCH_TYPE_GROUPS) {
const found = OpponentTacticsPreview.CONSTANTS.MATCH_TYPE_GROUPS[group].find(t => t.id === matchType);
if (found) {
matchTypeLabel = found.label;
break;
}
}
title.innerHTML = `<div class="title-main">${opponent} (${matchTypeLabel})</div>`;
header.appendChild(title);
const closeButton = document.createElement('button');
closeButton.className = 'close-button';
closeButton.textContent = '×';
closeButton.onclick = () => this.fadeOutAndRemove(container);
header.appendChild(closeButton);
container.appendChild(header);
const listWrapper = document.createElement('div');
listWrapper.className = 'tactics-list';
container.appendChild(listWrapper);
return container;
};
/**
* @description Fades out and removes an element from the DOM.
* @param {HTMLElement} el - The element to fade out.
*/
fadeOutAndRemove = (el) => {
if (!el) return;
el.classList.remove('fade-in');
el.classList.add('fade-out');
setTimeout(() => el.remove(), 200);
};
/**
* @description Identifies the user's team name from the current page.
* @returns {string|null} The user's team name or null.
*/
identifyUserTeamName = () => {
const ddRows = document.querySelectorAll('#fixtures-results-list > dd.odd');
if (ddRows.length === 0) return null;
const countMap = new Map();
ddRows.forEach(dd => {
const homeName = dd.querySelector('.home-team-column .full-name')?.textContent.trim();
const awayName = dd.querySelector('.away-team-column .full-name')?.textContent.trim();
if (homeName) countMap.set(homeName, (countMap.get(homeName) || 0) + 1);
if (awayName) countMap.set(awayName, (countMap.get(awayName) || 0) + 1);
});
if (countMap.size === 0) return null;
const mostFrequentName = [...countMap.entries()].reduce((a, b) => b[1] > a[1] ? b : a)[0];
return mostFrequentName;
};
/**
* @description Inserts the magnifying glass icons and sets up event listeners.
*/
insertIconsAndListeners = () => {
if (!this.myTeam) this.myTeam = this.identifyUserTeamName();
if (!this.myTeam) {
return;
}
document.querySelectorAll('dd.odd').forEach(dd => {
const selectWrapper = dd.querySelector('.set-default-wrapper');
if (selectWrapper && !selectWrapper.querySelector('.magnifier-icon')) {
const homeTeamName = dd.querySelector('.home-team-column .full-name')?.textContent.trim();
const awayTeamName = dd.querySelector('.away-team-column .full-name')?.textContent.trim();
const homeTeamLink = dd.querySelector('.home-team-column a.clippable');
const awayTeamLink = dd.querySelector('.away-team-column a.clippable');
let opponentName = null;
let opponentTid = null;
if (homeTeamName === this.myTeam && awayTeamName && awayTeamLink) {
opponentName = awayTeamName;
if (awayTeamLink.href) opponentTid = new URL(awayTeamLink.href, location.href).searchParams.get('tid');
} else if (awayTeamName === this.myTeam && homeTeamName && homeTeamLink) {
opponentName = homeTeamName;
if (homeTeamLink.href) opponentTid = new URL(homeTeamLink.href, location.href).searchParams.get('tid');
}
if (opponentName && opponentTid && (opponentTid !== this.myTeamId)) {
const iconWrapper = document.createElement('span');
iconWrapper.className = 'magnifier-icon';
iconWrapper.dataset.tid = opponentTid;
iconWrapper.dataset.opponent = opponentName;
iconWrapper.title = 'Check opponent latest tactics';
iconWrapper.textContent = '🔍';
selectWrapper.querySelector('select')?.insertAdjacentElement('afterend', iconWrapper);
}
}
});
};
/**
* @description Creates a canvas with a pitch image and replaces colors.
* @param {string} imageUrl - The URL of the image.
* @param {boolean} opponentIsHome - True if the opponent is the home team.
* @returns {HTMLCanvasElement} The created canvas element.
*/
createCanvasWithReplacedColors = (imageUrl, opponentIsHome) => {
const canvas = document.createElement('canvas');
canvas.width = 150;
canvas.height = 200;
const context = canvas.getContext('2d');
const image = new Image();
image.crossOrigin = 'Anonymous';
image.onload = () => {
if (opponentIsHome) {
context.translate(canvas.width / 2, canvas.height / 2);
context.rotate(Math.PI);
context.translate(-canvas.width / 2, -canvas.height / 2);
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const darkGreen = { r: 0, g: 100, b: 0 };
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
const isBlack = r < 30 && g < 30 && b < 30;
const isYellow = r > 200 && g > 200 && b < 100;
if (opponentIsHome) {
if (isYellow) { data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; }
else if (isBlack) { data[i] = darkGreen.r; data[i + 1] = darkGreen.g; data[i + 2] = darkGreen.b; }
} else {
if (isBlack) { data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; }
else if (isYellow) { data[i] = darkGreen.r; data[i + 1] = darkGreen.g; data[i + 2] = darkGreen.b; }
}
}
const tempData = new Uint8ClampedArray(data);
for (let y = 1; y < canvas.height - 1; y++) {
for (let x = 1; x < canvas.width - 1; x++) {
const i = (y * canvas.width + x) * 4;
if (data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 0) {
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const ni = ((y + dy) * canvas.width + (x + dx)) * 4;
if (!(data[ni] === 0 && data[ni + 1] === 0 && data[ni + 2] === 0)) {
tempData[i] = 255; tempData[i + 1] = 255; tempData[i + 2] = 255;
}
}
}
}
}
}
context.putImageData(new ImageData(tempData, canvas.width, canvas.height), 0, 0);
};
image.src = imageUrl;
return canvas;
};
/**
* @description Ensures a match file is ready for fetching stats.
* @async
* @param {string} matchId - The ID of the match.
* @param {number} [attempt=1] - The current attempt number.
* @returns {Promise<void>} A promise that resolves when the file is ready.
*/
ensureMatchFileIsReady = (matchId, attempt = 1) => {
const maxAttempts = 5;
return new Promise(async (resolve, reject) => {
if (attempt > maxAttempts) {
const error = new Error(`File preparation failed after ${maxAttempts} attempts.`);
return reject(error);
}
try {
const response = await fetch(OpponentTacticsPreview.CONSTANTS.URLS.MATCH_CHECK(matchId));
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
switch (data.response) {
case 'ok':
resolve();
break;
case 'queued':
setTimeout(() => this.ensureMatchFileIsReady(matchId, attempt + 1).then(resolve).catch(reject), 3000);
break;
default:
reject(new Error(`Match file unavailable: ${data.response}.`));
break;
}
} catch (error) {
reject(error);
}
});
};
_parseTimeToSeconds = (timeString) => {
if (!timeString || !timeString.includes(':')) return 0;
const parts = timeString.split(':');
const minutes = parseInt(parts[0], 10);
const seconds = parseFloat(parts[1]);
return isNaN(minutes) || isNaN(seconds) ? 0 : (minutes * 60) + seconds;
};
_getSortedGoalEvents = (xml) => {
const events = [];
xml.querySelectorAll('Player > Goal').forEach(node => {
const timeAttr = node.getAttribute('clock') || node.getAttribute('time');
if (!timeAttr) return;
const timeInSeconds = this._parseTimeToSeconds(timeAttr);
const teamId = node.getAttribute('team');
if (teamId) events.push({ time: timeInSeconds, type: 'Goal', teamId });
});
return events.sort((a, b) => a.time - b.time);
};
_calculateScoreAtTime = (sortedGoalEvents, targetTime, homeTeamId, awayTeamId) => {
let homeScore = 0;
let awayScore = 0;
for (const event of sortedGoalEvents) {
if (event.time >= targetTime) break;
if (event.teamId === homeTeamId) homeScore++;
if (event.teamId === awayTeamId) awayScore++;
}
return { homeScore, awayScore };
};
/**
* @description Fetches and formats playstyle changes from match stats.
* @async
* @param {string} mid - The ID of the match.
* @param {string} opponentTid - The ID of the opponent team.
* @returns {Promise<string>} A promise that resolves to an HTML string with playstyle data.
*/
fetchPlaystyleChanges = async (mid, opponentTid) => {
await this.ensureMatchFileIsReady(mid);
const response = await fetch(OpponentTacticsPreview.CONSTANTS.URLS.MATCH_STATS(mid));
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const text = await response.text();
const xml = new DOMParser().parseFromString(text, 'text/xml');
const opponentTeamNode = xml.querySelector(`Team[id="${opponentTid}"]`);
if (!opponentTeamNode) return 'Opponent data not found.';
const isVisiting = opponentTeamNode.getAttribute('visiting') === '1';
const homeTeamId = xml.querySelector('Team[visiting="0"]')?.getAttribute('id');
const awayTeamId = xml.querySelector('Team[visiting="1"]')?.getAttribute('id');
const tooltipLines = [];
tooltipLines.push(`Tactic: ${opponentTeamNode.getAttribute('tactic') || 'N/A'}`);
tooltipLines.push(`Playstyle: ${opponentTeamNode.getAttribute('playstyle') || 'N/A'}`);
tooltipLines.push(`Aggression: ${opponentTeamNode.getAttribute('aggression') || 'N/A'}`);
const changeNodes = [...xml.querySelectorAll('Events Tactic')].filter(node => node.getAttribute('teamId') === opponentTid);
if (changeNodes.length > 0) {
const sortedGoalEvents = this._getSortedGoalEvents(xml);
tooltipLines.push('<br><strong>Changes</strong>');
changeNodes.forEach(node => {
const changeType = node.getAttribute('type');
if (!['playstyle', 'aggression', 'tactic'].includes(changeType)) return;
const clock = node.getAttribute('clock') || node.getAttribute('time');
const timeInSeconds = this._parseTimeToSeconds(clock);
const minute = Math.floor(timeInSeconds / 60);
const newSetting = node.getAttribute('new_setting');
const { homeScore, awayScore } = this._calculateScoreAtTime(sortedGoalEvents, timeInSeconds, homeTeamId, awayTeamId);
const scoreString = isVisiting ? `${awayScore}-${homeScore}` : `${homeScore}-${awayScore}`;
tooltipLines.push(`Min ${minute}: ${changeType} → ${newSetting} (Score: ${scoreString})`);
});
}
const result = tooltipLines.length > 0 ? tooltipLines.join('<br>') : 'No relevant tactical data found.';
return result;
};
/**
* @description Adds hover functionality to tactic items to show playstyle data.
* @param {string} mid - The ID of the match.
* @param {string} opponentTid - The ID of the opponent team.
* @param {HTMLElement} tacticItemElement - The element representing the tactic.
*/
addPlaystyleHover = (mid, opponentTid, tacticItemElement) => {
const indicatorElement = tacticItemElement.querySelector('.playstyle-status-indicator');
const loadAndShowTooltip = async () => {
const cacheKey = `${mid}-${opponentTid}`;
const cacheEntry = this.playstyleCache[cacheKey];
if (cacheEntry?.status === 'success' || cacheEntry?.status === 'error') {
this.tooltipElement.innerHTML = cacheEntry.content;
} else {
this.tooltipElement.innerHTML = 'Loading...';
}
if (this.tooltipElement.style.display !== 'block') {
this.tooltipElement.style.display = 'block';
}
if (!cacheEntry || cacheEntry.status === 'error') {
this.playstyleCache[cacheKey] = { status: 'loading' };
if (indicatorElement) {
indicatorElement.innerHTML = '';
new Spinner({ lines: 8, length: 3, width: 2, radius: 4, scale: 0.5, color: '#FFFFFF', position: 'relative' }).spin(indicatorElement);
}
try {
const content = await this.fetchPlaystyleChanges(mid, opponentTid);
this.playstyleCache[cacheKey] = { status: 'success', content };
if (this.tooltipElement.style.display === 'block') this.tooltipElement.innerHTML = content;
if (indicatorElement) indicatorElement.innerHTML = '✅';
} catch (error) {
const errorMessage = 'Match data is not available. Probably a WO :)';
this.playstyleCache[cacheKey] = { status: 'error', content: errorMessage };
if (this.tooltipElement.style.display === 'block') this.tooltipElement.innerHTML = errorMessage;
if (indicatorElement) indicatorElement.innerHTML = '❌';
}
}
};
tacticItemElement.addEventListener('mouseenter', () => {
clearTimeout(this.tooltipHideTimeout);
loadAndShowTooltip();
});
tacticItemElement.addEventListener('mousemove', (ev) => {
this.tooltipElement.style.top = `${ev.pageY + 15}px`;
this.tooltipElement.style.left = `${ev.pageX + 5}px`;
});
tacticItemElement.addEventListener('mouseleave', () => {
this.tooltipHideTimeout = setTimeout(() => {
this.tooltipElement.style.display = 'none';
}, 200);
});
};
/**
* @description Creates a global tooltip element.
*/
createGlobalTooltip = () => {
this.tooltipElement = document.createElement('div');
this.tooltipElement.className = 'playstyle-tooltip';
document.body.appendChild(this.tooltipElement);
this.tooltipElement.addEventListener('mouseenter', () => clearTimeout(this.tooltipHideTimeout));
this.tooltipElement.addEventListener('mouseleave', () => {
this.tooltipHideTimeout = setTimeout(() => { this.tooltipElement.style.display = 'none'; }, 200);
});
};
/**
* @description Waits for ELO values from another script before inserting icons.
*/
waitForEloValues = () => {
const interval = setInterval(() => {
const elements = document.querySelectorAll(OpponentTacticsPreview.CONSTANTS.SELECTORS.HOME_TEAM);
if (elements.length > 0 && elements[elements.length - 1]?.innerHTML.includes('br')) {
clearInterval(interval);
this.insertIconsAndListeners();
}
}, 100);
setTimeout(() => clearInterval(interval), 1500);
};
/**
* @description Handles global click events for the script's UI.
* @param {Event} e - The click event object.
*/
handleClickEvents = (e) => {
const clickedMagnifier = e.target.closest('.magnifier-icon');
if (clickedMagnifier) {
e.preventDefault();
e.stopPropagation();
const tid = clickedMagnifier.dataset.tid;
const name = clickedMagnifier.dataset.opponent;
if (!tid) return;
this.saveTeam(tid, name);
this.showInteractionModal(tid, clickedMagnifier);
return;
}
const interactionModal = document.getElementById('interaction-modal');
if (interactionModal && !interactionModal.contains(e.target)) {
this.fadeOutAndRemove(interactionModal);
}
const tacticsContainer = document.getElementById('tactics-container');
if (tacticsContainer && !e.target.closest('#tactics-container')) {
this.fadeOutAndRemove(tacticsContainer);
}
};
/**
* @description Initializes the user's team ID.
* @async
*/
initUserTeamId = async () => {
let storedId = GM_getValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.USER_TEAM_ID);
if (storedId) {
this.myTeamId = storedId;
return;
}
try {
const response = await fetch(OpponentTacticsPreview.CONSTANTS.URLS.CLUBHOUSE);
const text = await response.text();
const match = text.match(/dynimg\/badge\.php\?team_id=(\d+)/);
if (match && match[1]) {
this.myTeamId = match[1];
GM_setValue(OpponentTacticsPreview.CONSTANTS.STORAGE_KEYS.USER_TEAM_ID, this.myTeamId);
}
} catch (error) {}
};
/**
* @description Main initialization function for the userscript.
* @async
*/
init = async () => {
GM_addStyle(GM_getResourceText('ylotp'));
this.createGlobalTooltip();
await this.initUserTeamId();
const statsXenteRunning = document.querySelector(OpponentTacticsPreview.CONSTANTS.SELECTORS.STATS_XENTE);
const eloScheduledSelected = document.querySelector(OpponentTacticsPreview.CONSTANTS.SELECTORS.ELO_SCHEDULED)?.checked;
if (statsXenteRunning && eloScheduledSelected) {
this.waitForEloValues();
} else {
this.insertIconsAndListeners();
}
this.startObserving();
document.body.addEventListener('click', this.handleClickEvents, true);
};
}
const otp = new OpponentTacticsPreview();
otp.init();
})();