From d00b61684eef49e55c95610ed793c937e6b18f15 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 17 Sep 2023 17:48:51 -0400 Subject: [PATCH] Add fielding stats to player page --- components.d.ts | 1 + src/components/LastFourGamesBattingTable.vue | 49 ++++- src/components/PlayerCareerFieldingTable.vue | 150 ++++++++++++++ src/services/battingStatsService.ts | 34 +--- src/services/fieldingStatsService.ts | 193 +++++++++++++++++++ src/services/pitchingStatsService.ts | 2 +- src/views/PlayerView.vue | 19 +- tsconfig.json | 5 + 8 files changed, 415 insertions(+), 38 deletions(-) create mode 100644 src/components/PlayerCareerFieldingTable.vue create mode 100644 src/services/fieldingStatsService.ts diff --git a/components.d.ts b/components.d.ts index fa26c59..092b832 100644 --- a/components.d.ts +++ b/components.d.ts @@ -20,6 +20,7 @@ declare module '@vue/runtime-core' { NewsPreview: typeof import('./src/components/NewsPreview.vue')['default'] PlayerBattingSummaryTable: typeof import('./src/components/PlayerBattingSummaryTable.vue')['default'] PlayerCareerBattingTable: typeof import('./src/components/PlayerCareerBattingTable.vue')['default'] + PlayerCareerFieldingTable: typeof import('./src/components/PlayerCareerFieldingTable.vue')['default'] PlayerCareerPitchingTable: typeof import('./src/components/PlayerCareerPitchingTable.vue')['default'] PlayerPitchingSummaryTable: typeof import('./src/components/PlayerPitchingSummaryTable.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] diff --git a/src/components/LastFourGamesBattingTable.vue b/src/components/LastFourGamesBattingTable.vue index 0b4e355..84d10e9 100644 --- a/src/components/LastFourGamesBattingTable.vue +++ b/src/components/LastFourGamesBattingTable.vue @@ -26,6 +26,12 @@ HBP SAC IBB + XCh + XH + E + PB + SBa + CSc @@ -51,6 +57,12 @@ {{ gameStat.hbp }} {{ gameStat.sac }} {{ gameStat.ibb }} + {{ fieldingStatForGame(gameStat).xch }} + {{ fieldingStatForGame(gameStat).xhit }} + {{ fieldingStatForGame(gameStat).error }} + {{ fieldingStatForGame(gameStat).pb }} + {{ fieldingStatForGame(gameStat).sba }} + {{ fieldingStatForGame(gameStat).cs }} @@ -61,17 +73,52 @@ diff --git a/src/components/PlayerCareerFieldingTable.vue b/src/components/PlayerCareerFieldingTable.vue new file mode 100644 index 0000000..ea27dd1 --- /dev/null +++ b/src/components/PlayerCareerFieldingTable.vue @@ -0,0 +1,150 @@ + + + diff --git a/src/services/battingStatsService.ts b/src/services/battingStatsService.ts index 2ef93fd..568e6d8 100644 --- a/src/services/battingStatsService.ts +++ b/src/services/battingStatsService.ts @@ -64,38 +64,6 @@ interface LegacyBattingStat { bplo: number } -export interface FieldingStat { - 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 | '' -} - -export interface FieldingStatRaw { - 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 | '' -} - export async function fetchBattingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise { // different endpoint for pre-modern stats (/plays) era if (seasonNumber < MODERN_STAT_ERA_START) { @@ -136,7 +104,7 @@ async function fetchLegacyBattingStatsBySeasonAndPlayerId(seasonNumber: number, } export async function fetchBattingStatsForLastFourGamesBySeasonAndPlayerId(seasonNumber: number, playerId: number): Promise { - const response = await fetch(`${SITE_URL}/api/v3/plays/batting?season=${seasonNumber}&player_id=${playerId}&group_by=playergame&limit=4&sort=newest`) + const response = await fetch(`${SITE_URL}/api/v3/plays/batting?season=${seasonNumber}&player_id=${playerId}&group_by=playergame&limit=4&s_type=regular&sort=newest`) const battingStatsResponse: { count: number diff --git a/src/services/fieldingStatsService.ts b/src/services/fieldingStatsService.ts new file mode 100644 index 0000000..2ec629a --- /dev/null +++ b/src/services/fieldingStatsService.ts @@ -0,0 +1,193 @@ +import type { Game, Team } from './apiResponseTypes' +import type { Player } from './playersService' +import { MODERN_STAT_ERA_START, SITE_URL } 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' + xch: number + xhit: number + error: number + pb: number + sbc: number + csc: number +} + +export async function fetchFieldingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise { + // 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) +} + +async function fetchLegacyFieldingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise { + 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) +} + +export async function fetchFieldingStatsForLastFourGamesBySeasonAndPlayerId(seasonNumber: number, playerId: number): Promise { + 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) +} + +function makeModernFieldingStatFromLegacy(legacyStat: LegacyFieldingStat): FieldingStat { + 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 +} diff --git a/src/services/pitchingStatsService.ts b/src/services/pitchingStatsService.ts index cd30b99..da7152a 100644 --- a/src/services/pitchingStatsService.ts +++ b/src/services/pitchingStatsService.ts @@ -170,7 +170,7 @@ async function fetchLegacyPitchingStatsBySeasonAndPlayerId(seasonNumber: number, } export async function fetchPitchingStatsForLastFourGamesBySeasonAndPlayerId(seasonNumber: number, playerId: number): Promise { - const response = await fetch(`${SITE_URL}/api/v3/plays/pitching?season=${seasonNumber}&player_id=${playerId}&group_by=playergame&limit=4&sort=newest`) + const response = await fetch(`${SITE_URL}/api/v3/plays/pitching?season=${seasonNumber}&player_id=${playerId}&group_by=playergame&limit=4&s_type=regular&sort=newest`) const pitchingStatsResponse: { count: number diff --git a/src/views/PlayerView.vue b/src/views/PlayerView.vue index 90e85a3..0699d7e 100644 --- a/src/views/PlayerView.vue +++ b/src/views/PlayerView.vue @@ -64,17 +64,20 @@ - + - + + @@ -89,9 +92,11 @@ import LastFourGamesBattingTable from '@/components/LastFourGamesBattingTable.vu import LastFourGamesPitchingTable from '@/components/LastFourGamesPitchingTable.vue' import PlayerCareerBattingTable from '@/components/PlayerCareerBattingTable.vue' import PlayerCareerPitchingTable from '@/components/PlayerCareerPitchingTable.vue' +import PlayerCareerFieldingTable from '@/components/PlayerCareerFieldingTable.vue' import PlayerBattingSummaryTable from '@/components/PlayerBattingSummaryTable.vue' import PlayerPitchingSummaryTable from '@/components/PlayerPitchingSummaryTable.vue' import { fetchPitchingStatsBySeasonAndPlayerId, fetchPitchingStatsForLastFourGamesBySeasonAndPlayerId, type PitchingStat } from '@/services/pitchingStatsService' +import { fetchFieldingStatsBySeasonAndPlayerId, fetchFieldingStatsForLastFourGamesBySeasonAndPlayerId, type FieldingStat } from '@/services/fieldingStatsService' export default { name: "PlayerView", @@ -108,6 +113,10 @@ export default { regularSeasonPitchingStats: [] as PitchingStat[], postSeasonPitchingStats: [] as PitchingStat[], last4GamesPitching: [] as PitchingStat[], + // Fielding stats + regularSeasonFieldingStats: [] as FieldingStat[], + postSeasonFieldingStats: [] as FieldingStat[], + last4GamesFielding: [] as FieldingStat[], } }, components: { @@ -116,7 +125,8 @@ export default { LastFourGamesBattingTable, PlayerPitchingSummaryTable, PlayerCareerPitchingTable, - LastFourGamesPitchingTable + LastFourGamesPitchingTable, + PlayerCareerFieldingTable }, props: { seasonNumber: { type: Number, required: true }, @@ -223,6 +233,7 @@ export default { this.last2Decisions = await fetchLast2DecisionsByPlayerId(this.seasonNumber, this.player.id) this.last4GamesBatting = await fetchBattingStatsForLastFourGamesBySeasonAndPlayerId(this.seasonNumber, this.player.id) this.last4GamesPitching = await fetchPitchingStatsForLastFourGamesBySeasonAndPlayerId(this.seasonNumber, this.player.id) + this.last4GamesFielding = await fetchFieldingStatsForLastFourGamesBySeasonAndPlayerId(this.seasonNumber, this.player.id) // TODO: this should change, either with an api that can take a player name for every season, a way // to get multiple seasons stats at once, or a players ids across all seasons at once @@ -231,6 +242,8 @@ export default { this.postSeasonBattingStats = (await Promise.all(playerSeasons.filter(isNotUndefined).map(player => fetchBattingStatsBySeasonAndPlayerId(player!.season, player!.id, false)))).filter(isNotUndefined) this.regularSeasonPitchingStats = (await Promise.all(playerSeasons.filter(isNotUndefined).map(player => fetchPitchingStatsBySeasonAndPlayerId(player!.season, player!.id, true)))).filter(isNotUndefined) this.postSeasonPitchingStats = (await Promise.all(playerSeasons.filter(isNotUndefined).map(player => fetchPitchingStatsBySeasonAndPlayerId(player!.season, player!.id, false)))).filter(isNotUndefined) + this.regularSeasonFieldingStats = (await Promise.all(playerSeasons.filter(isNotUndefined).map(player => fetchFieldingStatsBySeasonAndPlayerId(player!.season, player!.id, true)))).flatMap(stat => stat).filter(isNotUndefined) + this.postSeasonFieldingStats = (await Promise.all(playerSeasons.filter(isNotUndefined).map(player => fetchFieldingStatsBySeasonAndPlayerId(player!.season, player!.id, false)))).flatMap(stat => stat).filter(isNotUndefined) }, async tryFetchPlayerByNameForAnySeason(seasonNumber: number, playerName: string): Promise { do { diff --git a/tsconfig.json b/tsconfig.json index 0df21fa..c612c74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,12 @@ "compilerOptions": { "baseUrl": ".", "target": "ES2022", + "lib": [ + "ES2022", + "DOM" + ], "module": "NodeNext", + "moduleResolution": "NodeNext", "paths": { "@/*": [ "./src/*"