Add fielding stats to player page

This commit is contained in:
Peter 2023-09-17 17:48:51 -04:00
parent bd0bafe705
commit d00b61684e
8 changed files with 415 additions and 38 deletions

1
components.d.ts vendored
View File

@ -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']

View File

@ -26,6 +26,12 @@
<th>HBP</th>
<th>SAC</th>
<th>IBB</th>
<th>XCh</th>
<th>XH</th>
<th>E</th>
<th>PB</th>
<th>SBa</th>
<th>CSc</th>
</tr>
</thead>
<tbody id="last4-batting">
@ -51,6 +57,12 @@
<td>{{ gameStat.hbp }}</td>
<td>{{ gameStat.sac }}</td>
<td>{{ gameStat.ibb }}</td>
<td>{{ fieldingStatForGame(gameStat).xch }}</td>
<td>{{ fieldingStatForGame(gameStat).xhit }}</td>
<td>{{ fieldingStatForGame(gameStat).error }}</td>
<td>{{ fieldingStatForGame(gameStat).pb }}</td>
<td>{{ fieldingStatForGame(gameStat).sba }}</td>
<td>{{ fieldingStatForGame(gameStat).cs }}</td>
</tr>
</tbody>
</table>
@ -61,17 +73,52 @@
<script lang="ts">
import type { Game } from '@/services/apiResponseTypes'
import type { BattingStat } from '@/services/battingStatsService'
import type { FieldingStat } from '@/services/fieldingStatsService'
interface MinimalAggregateFieldingStat {
xch: number,
xhit: number,
error: number,
pb: number,
sba: number,
cs: number
}
export default {
name: "LastFourGamesBattingTable",
props: {
last4GamesBatting: { type: Array<BattingStat>, required: true }
last4GamesBatting: { type: Array<BattingStat>, required: true },
last4GamesFielding: { type: Array<FieldingStat>, required: true }
},
methods: {
makeWxGyFromGame(game: Game | 'TOT'): string {
if (game === 'TOT') return 'TOT'
return `w${game.week}g${game.game_num}`
},
fieldingStatForGame(battingStat: BattingStat): MinimalAggregateFieldingStat {
const fieldingStat: MinimalAggregateFieldingStat = {
xch: 0,
xhit: 0,
error: 0,
pb: 0,
sba: 0,
cs: 0
}
this.last4GamesFielding.forEach(stat => {
if (stat.game === 'TOT' || battingStat.game === 'TOT') return
if (stat.game.id !== battingStat.game.id) return
fieldingStat.xch += stat.xCheckCount
fieldingStat.xhit += stat.hit
fieldingStat.error += stat.error
fieldingStat.pb += stat.passedBallCount
fieldingStat.sba += stat.stolenBaseCheckCount
fieldingStat.cs += stat.caughtStealingCount
})
return fieldingStat
}
}
}
</script>

View File

@ -0,0 +1,150 @@
<template>
<div v-if="hasFieldingStats" class="row" id="career-fielding-row">
<div class="col-sm-8">
<h3>Fielding Stats</h3>
<div class="table-responsive-xl">
<table class="table table-sm table-striped" id="career-fielding">
<thead class="thead-dark">
<tr>
<th>Season</th>
<th>Pos</th>
<th>XCh</th>
<th>XH</th>
<th>E</th>
<th>PB</th>
<th>SBa</th>
<th>CSc</th>
<th>CS%</th>
<th>wF%</th>
</tr>
</thead>
<tbody id="career-fielding-table">
<tr v-for="stat in sortedRegularAndPostSeasonFielding">
<td>S{{ stat.seasonNumber }}{{ stat.isRegularSeason ? '' : ' / Playoffs' }}</td>
<td>{{ stat.pos }}</td>
<td>{{ stat.xCheckCount }}</td>
<td>{{ stat.hit }}</td>
<td>{{ stat.error }}</td>
<td>{{ stat.passedBallCount }}</td>
<td>{{ stat.stolenBaseCheckCount }}</td>
<td>{{ stat.caughtStealingCount }}</td>
<td>{{ formatCaughtStealingPercent(stat) }}</td>
<td>{{ formatWeightedFieldingPercent(stat) }}</td>
</tr>
</tbody>
<tfoot>
<tr v-for="stat in sortedCareerFieldingStats" id="career-fielding-footer">
<th>Career</th>
<th>{{ stat.pos }}</th>
<th>{{ stat.xCheckCount }}</th>
<th>{{ stat.hit }}</th>
<th>{{ stat.error }}</th>
<th>{{ stat.passedBallCount }}</th>
<th>{{ stat.stolenBaseCheckCount }}</th>
<th>{{ stat.caughtStealingCount }}</th>
<th>{{ formatCaughtStealingPercent(stat) }}</th>
<th>{{ formatWeightedFieldingPercent(stat) }}</th>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</template>
<script lang="ts">
import { aggregateFieldingStats, type FieldingStat } from '@/services/fieldingStatsService'
interface FieldingStatWithSeason extends FieldingStat {
seasonNumber: number
isRegularSeason: boolean
}
const POS_MAP = {
'P': 1,
'C': 2,
'1B': 3,
'2B': 4,
'3B': 5,
'SS': 6,
'LF': 7,
'CF': 8,
'RF': 9,
'TOT': 10
}
function compareFieldingStats(s1: FieldingStatWithSeason, s2: FieldingStatWithSeason): number {
if (s1.seasonNumber - s2.seasonNumber !== 0) {
return s1.seasonNumber - s2.seasonNumber
}
if (s1.isRegularSeason !== s2.isRegularSeason) {
return s1.isRegularSeason
? -1
: 1
}
// at this point stats are same season and also both regular or post season stats
// and must be sorted on position order
return POS_MAP[s1.pos] - POS_MAP[s2.pos]
}
export default {
name: "PlayerCareerFieldingTable",
props: {
regularSeasonFieldingStats: { type: Array<FieldingStat>, required: true },
postSeasonFieldingStats: { type: Array<FieldingStat>, required: true }
},
computed: {
hasFieldingStats(): boolean {
return !!(this.regularSeasonFieldingStats.length + this.postSeasonFieldingStats.length)
},
sortedCareerFieldingStats(): FieldingStat[] {
if (this.regularSeasonFieldingStats.length > 0) {
// old site behavior just summed regular season stats for the career line total
return aggregateFieldingStats(this.regularSeasonFieldingStats)
.sort((a, b) => POS_MAP[a.pos] - POS_MAP[b.pos]) //only need to sort careers totals by position
}
return []
},
sortedRegularAndPostSeasonFielding(): FieldingStatWithSeason[] {
let seasonStats: FieldingStatWithSeason[] = []
if (this.regularSeasonFieldingStats?.length) {
seasonStats = seasonStats.concat(this.regularSeasonFieldingStats.map(stat => {
return {
...stat,
seasonNumber: stat.player.season,
isRegularSeason: true
}
}))
}
// TODO: here would be where you could filter out postseason stats if desired (like Josef requested)
if (this.postSeasonFieldingStats?.length) {
seasonStats = seasonStats.concat(this.postSeasonFieldingStats.map(stat => {
return {
...stat,
seasonNumber: stat.player.season,
isRegularSeason: false
}
}))
}
// TODO: additionally, fielding stats should sort on position P, C, 1B, ..., CF, RF
return seasonStats.sort(compareFieldingStats)
},
},
methods: {
formatCaughtStealingPercent(stat: FieldingStat): string {
if (stat.stolenBaseCheckCount === 0 || Number.isNaN(stat.caughtStealingPercent)) {
return '-'
}
return `${(stat.caughtStealingPercent * 100).toFixed(1)}%`
},
formatWeightedFieldingPercent(stat: FieldingStat): string {
return stat.weightedFieldingPercent.toFixed(3)
}
}
}
</script>

View File

@ -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<BattingStat | undefined> {
// 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<BattingStat[]> {
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

View File

@ -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<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)
}
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)
}
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)
}
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
}

View File

@ -170,7 +170,7 @@ async function fetchLegacyPitchingStatsBySeasonAndPlayerId(seasonNumber: number,
}
export async function fetchPitchingStatsForLastFourGamesBySeasonAndPlayerId(seasonNumber: number, playerId: number): Promise<PitchingStat[]> {
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

View File

@ -64,17 +64,20 @@
</div>
</div>
<!-- Last 4 Games -->
<LastFourGamesBattingTable v-if="isBatter" :last4-games-batting="last4GamesBatting" />
<LastFourGamesBattingTable v-if="isBatter" :last4-games-batting="last4GamesBatting"
:last4-games-fielding="last4GamesFielding" />
<LastFourGamesPitchingTable v-else :last4-games-pitching="last4GamesPitching" />
</div>
<!-- Career Batting -->
<!-- Career Stats -->
<PlayerCareerBattingTable v-if="isBatter" :regular-season-batting-stats="regularSeasonBattingStats"
:post-season-batting-stats="postSeasonBattingStats" />
<PlayerCareerPitchingTable :regular-season-pitching-stats="regularSeasonPitchingStats"
:post-season-pitching-stats="postSeasonPitchingStats" />
<PlayerCareerBattingTable v-if="!isBatter" :regular-season-batting-stats="regularSeasonBattingStats"
:post-season-batting-stats="postSeasonBattingStats" />
<PlayerCareerFieldingTable :regular-season-fielding-stats="regularSeasonFieldingStats"
:post-season-fielding-stats="postSeasonFieldingStats" />
</div>
</div>
</template>
@ -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<Player | undefined> {
do {

View File

@ -8,7 +8,12 @@
"compilerOptions": {
"baseUrl": ".",
"target": "ES2022",
"lib": [
"ES2022",
"DOM"
],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"paths": {
"@/*": [
"./src/*"