276 lines
9.9 KiB
TypeScript
276 lines
9.9 KiB
TypeScript
import type { Game, Team } from './apiResponseTypes'
|
|
import type { Player } from './playersService'
|
|
import { MODERN_STAT_ERA_START, SITE_URL, isNotUndefined } from './utilities'
|
|
|
|
// TODO make a stats object that has properties for current regular season, current post season,
|
|
// last 4 games, historical seasons, career totals
|
|
// could split into batting/pitching/fielding so only necessary ones are called to save time
|
|
|
|
export interface FieldingStat {
|
|
game: Game | 'TOT'
|
|
player: Player
|
|
team: Team
|
|
pos: 'P' | 'C' | '1B' | '2B' | '3B' | 'SS' | 'LF' | 'CF' | 'RF' | 'TOT'
|
|
xCheckCount: number
|
|
hit: number
|
|
error: number
|
|
stolenBaseCheckCount: number
|
|
stolenBaseCount: number
|
|
caughtStealingCount: number
|
|
passedBallCount: number
|
|
winProbabilityAdded: number
|
|
weightedFieldingPercent: number
|
|
caughtStealingPercent: number
|
|
}
|
|
|
|
interface FieldingStatRaw {
|
|
game: Game | 'TOT'
|
|
player: Player
|
|
team: Team
|
|
pos: 'P' | 'C' | '1B' | '2B' | '3B' | 'SS' | 'LF' | 'CF' | 'RF' | 'TOT'
|
|
'x-ch': number
|
|
hit: number
|
|
error: number
|
|
'sb-ch': number
|
|
sb: number
|
|
cs: number
|
|
pb: number
|
|
wpa: number
|
|
'wf%': number
|
|
'cs%': number | ''
|
|
}
|
|
|
|
interface LegacyFieldingStat {
|
|
player: Player
|
|
team: Team
|
|
pos: 'P' | 'C' | '1B' | '2B' | '3B' | 'SS' | 'LF' | 'CF' | 'RF' | 'TOT' | 'PH'
|
|
xch: number
|
|
xhit: number
|
|
error: number
|
|
pb: number
|
|
sbc: number
|
|
csc: number
|
|
}
|
|
|
|
export async function fetchFieldingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise<FieldingStat[]> {
|
|
// different endpoint for pre-modern stats (/plays) era
|
|
if (seasonNumber < MODERN_STAT_ERA_START) {
|
|
return await fetchLegacyFieldingStatsBySeasonAndPlayerId(seasonNumber, playerId, isRegularSeason)
|
|
}
|
|
|
|
const response = await fetch(`${SITE_URL}/api/v3/plays/fielding?season=${seasonNumber}&player_id=${playerId}&group_by=playerposition&s_type=${isRegularSeason ? 'regular' : 'post'}`)
|
|
|
|
const fieldingStatsResponse: {
|
|
count: number
|
|
stats: FieldingStatRaw[]
|
|
} = await response.json()
|
|
|
|
if (fieldingStatsResponse.count === 0) return []
|
|
|
|
return fieldingStatsResponse.stats.map(normalizeFieldingStat)
|
|
}
|
|
|
|
export async function fetchFieldingStatsBySeasonAndTeamId(seasonNumber: number, teamId: number, isRegularSeason: boolean): Promise<FieldingStat[]> {
|
|
// different endpoint for pre-modern stats (/plays) era
|
|
if (seasonNumber < MODERN_STAT_ERA_START) {
|
|
return []
|
|
}
|
|
|
|
// TODO might want to make this playerpositionteam grouping (currently does not exist)
|
|
const response = await fetch(`${SITE_URL}/api/v3/plays/fielding?season=${seasonNumber}&team_id=${teamId}&group_by=playerposition&s_type=${isRegularSeason ? 'regular' : 'post'}`)
|
|
|
|
const fieldingStatsResponse: {
|
|
count: number
|
|
stats: FieldingStatRaw[]
|
|
} = await response.json()
|
|
|
|
if (fieldingStatsResponse.count === 0) return []
|
|
|
|
return fieldingStatsResponse.stats.map(normalizeFieldingStat)
|
|
}
|
|
|
|
export async function fetchFieldingStatsBySeason(seasonNumber: number, isRegularSeason: boolean): Promise<FieldingStat[]> {
|
|
// different endpoint for pre-modern stats (/plays) era
|
|
if (seasonNumber < MODERN_STAT_ERA_START) {
|
|
return []
|
|
}
|
|
|
|
const response = await fetch(`${SITE_URL}/api/v3/plays/fielding?season=${seasonNumber}&limit=10000&group_by=playerposition&s_type=${isRegularSeason ? 'regular' : 'post'}`)
|
|
|
|
const fieldingStatsResponse: {
|
|
count: number
|
|
stats: FieldingStatRaw[]
|
|
} = await response.json()
|
|
|
|
if (fieldingStatsResponse.count === 0) return []
|
|
|
|
return fieldingStatsResponse.stats.map(normalizeFieldingStat)
|
|
}
|
|
|
|
async function fetchLegacyFieldingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise<FieldingStat[]> {
|
|
const response = await fetch(`${SITE_URL}/api/v3/fieldingstats/totals?season=${seasonNumber}&player_id=${playerId}&s_type=${isRegularSeason ? 'regular' : 'post'}`)
|
|
|
|
const legacyFieldingStatsResponse: {
|
|
count: number
|
|
stats: LegacyFieldingStat[]
|
|
} = await response.json()
|
|
|
|
if (legacyFieldingStatsResponse.count === 0) return []
|
|
|
|
return legacyFieldingStatsResponse.stats
|
|
.map(makeModernFieldingStatFromLegacy)
|
|
.filter(isNotUndefined)
|
|
}
|
|
|
|
export async function fetchFieldingStatsForLastFourGamesBySeasonAndPlayerId(seasonNumber: number, playerId: number): Promise<FieldingStat[]> {
|
|
const response = await fetch(`${SITE_URL}/api/v3/plays/fielding?season=${seasonNumber}&player_id=${playerId}&group_by=playerpositiongame&limit=4&s_type=regular&sort=newest`)
|
|
|
|
const fieldingStatsResponse: {
|
|
count: number
|
|
stats: FieldingStatRaw[]
|
|
} = await response.json()
|
|
|
|
if (fieldingStatsResponse.count > 4) {
|
|
throw new Error(`fieldingStatsService.fetchFieldingStatsForLastFourBySeasonAndPlayerId - Expected at most 4 games, return contained ${fieldingStatsResponse.count}`)
|
|
}
|
|
|
|
return fieldingStatsResponse.stats.map(normalizeFieldingStat)
|
|
}
|
|
|
|
export function aggregateFieldingStats(fieldingStats: FieldingStat[]): FieldingStat[] {
|
|
const fieldingStatsByPosition = fieldingStats.reduce((statsByPos: { [key: string]: FieldingStat }, stat: FieldingStat) => {
|
|
if (!statsByPos[stat.pos]) {
|
|
statsByPos[stat.pos] = {
|
|
game: 'TOT',
|
|
player: stat.player,
|
|
team: stat.team,
|
|
pos: stat.pos,
|
|
xCheckCount: stat.xCheckCount,
|
|
hit: stat.hit,
|
|
error: stat.error,
|
|
stolenBaseCheckCount: stat.stolenBaseCheckCount,
|
|
stolenBaseCount: stat.stolenBaseCount,
|
|
caughtStealingCount: stat.caughtStealingCount,
|
|
passedBallCount: stat.passedBallCount,
|
|
winProbabilityAdded: stat.winProbabilityAdded,
|
|
weightedFieldingPercent: stat.weightedFieldingPercent,
|
|
caughtStealingPercent: stat.caughtStealingPercent
|
|
}
|
|
} else {
|
|
statsByPos[stat.pos].xCheckCount += stat.xCheckCount
|
|
statsByPos[stat.pos].hit += stat.hit
|
|
statsByPos[stat.pos].error += stat.error
|
|
statsByPos[stat.pos].stolenBaseCheckCount += stat.stolenBaseCheckCount
|
|
statsByPos[stat.pos].stolenBaseCount += stat.stolenBaseCount
|
|
statsByPos[stat.pos].caughtStealingCount += stat.caughtStealingCount
|
|
statsByPos[stat.pos].passedBallCount += stat.passedBallCount
|
|
statsByPos[stat.pos].winProbabilityAdded += stat.winProbabilityAdded
|
|
statsByPos[stat.pos].weightedFieldingPercent = weightedFieldingPercentage({
|
|
xChecks: statsByPos[stat.pos].xCheckCount,
|
|
hits: statsByPos[stat.pos].hit,
|
|
errors: statsByPos[stat.pos].error
|
|
})
|
|
statsByPos[stat.pos].caughtStealingPercent = caughtStealingPercentage({
|
|
stolenBaseAttempts: statsByPos[stat.pos].stolenBaseCheckCount,
|
|
caughtStealingCount: statsByPos[stat.pos].caughtStealingCount
|
|
})
|
|
}
|
|
|
|
return statsByPos
|
|
}, {})
|
|
return Object.values(fieldingStatsByPosition)
|
|
}
|
|
|
|
export function totaledFieldingStats(fieldingStats: FieldingStat[]): FieldingStat | undefined {
|
|
if (fieldingStats.length === 0) return undefined
|
|
|
|
const totalStat: FieldingStat = {
|
|
game: 'TOT',
|
|
player: fieldingStats[0].player,
|
|
team: fieldingStats[0].team,
|
|
pos: 'TOT',
|
|
xCheckCount: 0,
|
|
hit: 0,
|
|
error: 0,
|
|
stolenBaseCheckCount: 0,
|
|
stolenBaseCount: 0,
|
|
caughtStealingCount: 0,
|
|
passedBallCount: 0,
|
|
winProbabilityAdded: 0,
|
|
weightedFieldingPercent: 0,
|
|
caughtStealingPercent: 0
|
|
}
|
|
|
|
fieldingStats.forEach(stat => {
|
|
totalStat.xCheckCount += stat.xCheckCount
|
|
totalStat.hit += stat.hit
|
|
totalStat.error += stat.error
|
|
totalStat.stolenBaseCheckCount += stat.stolenBaseCheckCount
|
|
totalStat.stolenBaseCount += stat.stolenBaseCount
|
|
totalStat.caughtStealingCount += stat.caughtStealingCount
|
|
totalStat.passedBallCount += stat.passedBallCount
|
|
totalStat.winProbabilityAdded += stat.winProbabilityAdded
|
|
totalStat.weightedFieldingPercent = weightedFieldingPercentage({
|
|
xChecks: totalStat.xCheckCount,
|
|
hits: totalStat.hit,
|
|
errors: totalStat.error
|
|
})
|
|
totalStat.caughtStealingPercent = caughtStealingPercentage({
|
|
stolenBaseAttempts: totalStat.stolenBaseCheckCount,
|
|
caughtStealingCount: totalStat.caughtStealingCount
|
|
})
|
|
})
|
|
|
|
return totalStat
|
|
}
|
|
|
|
function makeModernFieldingStatFromLegacy(legacyStat: LegacyFieldingStat): FieldingStat | undefined {
|
|
// filter out "PH" fielding stats here which used to occur when position/xchecks were loosely checked/observed
|
|
if (legacyStat.pos === 'PH') return undefined
|
|
return {
|
|
game: 'TOT',
|
|
player: legacyStat.player,
|
|
team: legacyStat.team,
|
|
pos: legacyStat.pos,
|
|
xCheckCount: legacyStat.xch,
|
|
hit: legacyStat.xhit,
|
|
error: legacyStat.error,
|
|
stolenBaseCheckCount: legacyStat.sbc,
|
|
stolenBaseCount: legacyStat.sbc - legacyStat.csc,
|
|
caughtStealingCount: legacyStat.csc,
|
|
passedBallCount: legacyStat.pb,
|
|
winProbabilityAdded: 0,
|
|
weightedFieldingPercent: weightedFieldingPercentage({ xChecks: legacyStat.xch, hits: legacyStat.xhit, errors: legacyStat.error }),
|
|
caughtStealingPercent: caughtStealingPercentage({ stolenBaseAttempts: legacyStat.sbc, caughtStealingCount: legacyStat.csc })
|
|
}
|
|
}
|
|
|
|
function normalizeFieldingStat(rawStat: FieldingStatRaw): FieldingStat {
|
|
return {
|
|
game: rawStat.game,
|
|
player: rawStat.player,
|
|
team: rawStat.team,
|
|
pos: rawStat.pos,
|
|
xCheckCount: rawStat['x-ch'],
|
|
hit: rawStat.hit,
|
|
error: rawStat.error,
|
|
stolenBaseCheckCount: rawStat['sb-ch'],
|
|
stolenBaseCount: rawStat.sb,
|
|
caughtStealingCount: rawStat.cs,
|
|
passedBallCount: rawStat.pb,
|
|
winProbabilityAdded: rawStat.wpa,
|
|
weightedFieldingPercent: rawStat['wf%'],
|
|
caughtStealingPercent: rawStat['cs%'] === '' ? NaN : rawStat['cs%']
|
|
}
|
|
}
|
|
|
|
export function weightedFieldingPercentage(stat: { xChecks: number, hits: number, errors: number }): number {
|
|
if (stat.xChecks === 0) return NaN
|
|
return (stat.xChecks - stat.errors * 0.5 - stat.hits * 0.75) / stat.xChecks
|
|
}
|
|
|
|
export function caughtStealingPercentage(stat: { stolenBaseAttempts: number, caughtStealingCount: number }): number {
|
|
if (stat.stolenBaseAttempts === 0) return NaN
|
|
return stat.caughtStealingCount / stat.stolenBaseAttempts
|
|
}
|