@lovebowls/leagueelements
Version:
League Elements package for LoveBowls
253 lines (215 loc) • 9.74 kB
JavaScript
/**
* Form calculation utilities for league analysis
* Provides reusable functions for calculating team form scores and historical form data
*/
export class FormUtils {
/**
* Standard form weights for recent matches (most recent to oldest)
* Higher weights for more recent matches to emphasize current form
*/
static FORM_WEIGHTS = [1.0, 0.8, 0.6, 0.4, 0.2];
/**
* Get all matches for a team up to a specific date, sorted by date (most recent first)
* @param {string} teamId - The team ID
* @param {Date|null} targetDate - The target date (null for all matches)
* @param {Array<Object>} allMatches - Array of all match objects
* @param {string} tableFilter - Filter type ('overall', 'home', 'away', 'form')
* @returns {Array<Object>} Filtered and sorted matches
*/
static getTeamMatchesUpToDate(teamId, targetDate, allMatches, tableFilter = 'overall') {
if (!allMatches || !Array.isArray(allMatches)) {
return [];
}
return allMatches
.filter(match => {
// Must have valid date and result
if (!match.date || !match.result) return false;
// Must involve this team
if (match.homeTeam._id !== teamId && match.awayTeam._id !== teamId) return false;
// Must respect table filter (home/away)
if (tableFilter === 'home' && match.homeTeam._id !== teamId) return false;
if (tableFilter === 'away' && match.awayTeam._id !== teamId) return false;
// Must be before or on target date (if specified)
if (targetDate) {
const matchDate = new Date(match.date);
matchDate.setHours(0, 0, 0, 0);
const targetTimestamp = new Date(targetDate);
targetTimestamp.setHours(0, 0, 0, 0);
return matchDate.getTime() <= targetTimestamp.getTime();
}
return true;
})
.sort((a, b) => new Date(b.date) - new Date(a.date)); // Most recent first
}
/**
* Calculate form score from a specific set of matches for a team
* @param {Array<Object>} matches - Array of match objects (should be sorted most recent first)
* @param {string} teamId - The team ID to calculate form for
* @param {number} maxMatches - Maximum number of matches to consider (default: 5)
* @returns {number} Form score (0-3 range)
*/
static calculateFormScoreFromMatches(matches, teamId, maxMatches = 5) {
if (!matches || matches.length === 0) {
return 0;
}
const weights = FormUtils.FORM_WEIGHTS.slice(0, maxMatches);
let totalScore = 0;
let totalWeight = 0;
matches.forEach((match, index) => {
if (index >= maxMatches) return; // Only consider specified number of matches
const weight = weights[index] || 0.1; // Fallback weight for matches beyond standard weights
let matchPoints = 0;
// Determine result for this team
const homeTeamId = match.homeTeam._id;
const awayTeamId = match.awayTeam._id;
const homeScore = match.result.homeScore;
const awayScore = match.result.awayScore;
if (homeTeamId === teamId) {
if (homeScore > awayScore) matchPoints = 1.0; // Win
else if (homeScore === awayScore) matchPoints = 0.5; // Draw
else matchPoints = 0.0; // Loss
} else if (awayTeamId === teamId) {
if (awayScore > homeScore) matchPoints = 1.0; // Win
else if (awayScore === homeScore) matchPoints = 0.5; // Draw
else matchPoints = 0.0; // Loss
}
totalScore += matchPoints * weight;
totalWeight += weight;
});
// Scale the score to maintain a reasonable range
// Use the actual total weight, not a fixed divisor
const scaledScore = totalWeight > 0 ? (totalScore / totalWeight) : 0;
return Math.round(scaledScore * 1000) / 1000; // Round to 3 decimal places
}
/**
* Calculate form score at a specific date for a team
* @param {string} teamId - The team ID
* @param {Date|null} targetDate - The target date (null for current form)
* @param {Array<Object>} allMatches - Array of all match objects
* @param {string} tableFilter - Filter type ('overall', 'home', 'away', 'form')
* @param {number} maxMatches - Maximum number of matches to consider (default: 5)
* @returns {number} Form score (0-3 range)
*/
static calculateFormScoreAtDate(teamId, targetDate, allMatches, tableFilter = 'overall', maxMatches = 5) {
const teamMatches = FormUtils.getTeamMatchesUpToDate(teamId, targetDate, allMatches, tableFilter);
return FormUtils.calculateFormScoreFromMatches(teamMatches.slice(0, maxMatches), teamId, maxMatches);
}
/**
* Generate form over time data for multiple teams
* @param {Array<string>} teamIds - Array of team IDs to analyze
* @param {Array<Object>} allMatches - Array of all match objects
* @param {string} tableFilter - Filter type ('overall', 'home', 'away', 'form')
* @param {number} maxMatches - Maximum number of matches to consider for form (default: 5)
* @returns {Object} Form over time data structure
*/
static generateFormOverTimeData(teamIds, allMatches, tableFilter = 'overall', maxMatches = 5) {
if (!teamIds || !Array.isArray(teamIds) || teamIds.length === 0) {
return { dates: [], teamSeries: {}, allTeamIds: [] };
}
// Get all valid matches involving any of the teams (against any opponent)
const validMatches = allMatches.filter(match => {
return match.date &&
match.result &&
typeof match.result.homeScore === 'number' &&
typeof match.result.awayScore === 'number' &&
(teamIds.includes(match.homeTeam._id) || teamIds.includes(match.awayTeam._id));
});
if (validMatches.length === 0) {
const emptyData = { dates: [], teamSeries: {}, allTeamIds: teamIds };
teamIds.forEach(teamId => {
emptyData.teamSeries[teamId] = [];
});
return emptyData;
}
// Get unique dates sorted chronologically
const uniqueDateTimestamps = [...new Set(
validMatches.map(match => {
const d = new Date(match.date);
d.setHours(0, 0, 0, 0);
return d.getTime();
})
)].sort((a, b) => a - b);
// Initialize data structure
const formOverTimeData = {
dates: uniqueDateTimestamps,
teamSeries: {},
allTeamIds: teamIds
};
// Calculate form scores for each team at each date
teamIds.forEach(teamId => {
formOverTimeData.teamSeries[teamId] = [];
uniqueDateTimestamps.forEach(dateTimestamp => {
const targetDate = new Date(dateTimestamp);
const formScore = FormUtils.calculateFormScoreAtDate(
teamId,
targetDate,
allMatches,
tableFilter,
maxMatches
);
formOverTimeData.teamSeries[teamId].push(formScore);
});
});
return formOverTimeData;
}
/**
* Calculate form rank movements between two time periods
* @param {Array<string>} teamIds - Array of team IDs
* @param {Array<Object>} allMatches - Array of all match objects
* @param {string} tableFilter - Filter type ('overall', 'home', 'away', 'form')
* @returns {Object} Map of teamId to rank movement
*/
static calculateFormRankMovements(teamIds, allMatches, tableFilter = 'overall') {
const movements = {};
teamIds.forEach(teamId => {
// Use the generalized function to get team matches
const teamMatches = FormUtils.getTeamMatchesUpToDate(teamId, null, allMatches, tableFilter);
if (teamMatches.length < 2) {
// Not enough matches to calculate movement
movements[teamId] = 0;
return;
}
// Current form: last 5 matches using generalized function
const currentFormScore = FormUtils.calculateFormScoreFromMatches(teamMatches.slice(0, 5), teamId);
// Previous form: exclude most recent match, take next 5 using generalized function
const previousFormScore = FormUtils.calculateFormScoreFromMatches(teamMatches.slice(1, 6), teamId);
// Store both scores for comparison
movements[teamId] = {
teamId,
currentFormScore,
previousFormScore
};
});
// Create arrays for current and previous form rankings
const currentFormRankings = teamIds.map(teamId => ({
teamId,
formScore: movements[teamId].currentFormScore || 0
})).sort((a, b) => b.formScore - a.formScore);
const previousFormRankings = teamIds.map(teamId => ({
teamId,
formScore: movements[teamId].previousFormScore || 0
})).sort((a, b) => b.formScore - a.formScore);
// Create rank maps
const currentRanks = {};
const previousRanks = {};
currentFormRankings.forEach((team, index) => {
currentRanks[team.teamId] = index + 1;
});
previousFormRankings.forEach((team, index) => {
previousRanks[team.teamId] = index + 1;
});
// Calculate final movements
const finalMovements = {};
teamIds.forEach(teamId => {
const currentRank = currentRanks[teamId];
const previousRank = previousRanks[teamId];
if (currentRank !== undefined && previousRank !== undefined) {
// Movement is previous rank - current rank (positive = moved up, negative = moved down)
finalMovements[teamId] = previousRank - currentRank;
} else {
finalMovements[teamId] = 0;
}
});
return finalMovements;
}
}