sba-website/src/views/PlayerView.vue

464 lines
19 KiB
Vue

<template>
<div class="player-view">
<div class="centerDiv">
<!-- Heading -->
<div class="row">
<div class="col-sm">
<h1 id="player-name">{{ playerName }}{{ injuryReturnDate }}</h1>
<h2 v-if="isCurrentPlayer" id="player-wara">{{ player?.wara }} sWAR</h2>
</div>
<div v-if="isCurrentPlayer" class="col-sm-1">
<RouterLink v-if="teamAbbreviation && teamThumbnail"
:to="{ name: 'team', params: { seasonNumber: seasonNumber, teamAbbreviation: teamAbbreviation } }">
<img id="thumbnail" height="125" style="float:right; vertical-align:middle; max-height:100%;"
:src="teamThumbnail">
</RouterLink>
</div>
</div>
<div v-if="isCurrentPlayer" class="row">
<div class="col-sm-auto">
<img style="max-height:485px; max-width: 100%;" id="team-image" :src="playerImageUrl" :alt="playerName">
<p><a id="bbref-link" target="_blank" :href="baseballReferenceUrl">Baseball Reference Page</a></p>
</div>
<div class="col-sm-auto">
<img v-if="playerCardImage1Url" style="max-height:485px; max-width: 100%;" id="card-image"
:src="playerCardImage1Url">
<img v-if="playerCardImage2Url" style="max-height:485px; max-width: 100%;" id="card-image"
:src="playerCardImage2Url">
</div>
</div>
<!-- Batter Summary -->
<div v-if="currentSeasonBatting" class="row" id="batter-summary">
<div class="col-sm-12">
<h3>Summary</h3>
</div>
<div class="col-sm-8">
<div class="table-responsive-xl" style="max-width:45rem">
<table class="table table-sm table-striped">
<thead class="thead-dark">
<tr>
<th>Summary</th>
<th>AB</th>
<th>H</th>
<th>HR</th>
<th>BA</th>
<th>R</th>
<th>RBI</th>
<th>SB</th>
<th>OBP</th>
<th>SLG</th>
<th>OPS</th>
<th>wOBA</th>
</tr>
</thead>
<tbody id="batter-summary-table">
<tr>
<td>Season {{ currentSeasonBatting.player.season }}</td>
<td>{{ currentSeasonBatting.ab }}</td>
<td>{{ currentSeasonBatting.hit }}</td>
<td>{{ currentSeasonBatting.hr }}</td>
<td>{{ currentSeasonBatting.avg.toFixed(3) }}</td>
<td>{{ currentSeasonBatting.run }}</td>
<td>{{ currentSeasonBatting.rbi }}</td>
<td>{{ currentSeasonBatting.sb }}</td>
<td>{{ currentSeasonBatting.obp.toFixed(3) }}</td>
<td>{{ currentSeasonBatting.slg.toFixed(3) }}</td>
<td>{{ currentSeasonBatting.ops.toFixed(3) }}</td>
<td>{{ currentSeasonBatting.woba.toFixed(3) }}</td>
</tr>
<tr v-if="currentPostSeasonBatting">
<td>S{{ currentPostSeasonBatting.player.season }} / Playoffs</td>
<td>{{ currentPostSeasonBatting.ab }}</td>
<td>{{ currentPostSeasonBatting.hit }}</td>
<td>{{ currentPostSeasonBatting.hr }}</td>
<td>{{ currentPostSeasonBatting.avg.toFixed(3) }}</td>
<td>{{ currentPostSeasonBatting.run }}</td>
<td>{{ currentPostSeasonBatting.rbi }}</td>
<td>{{ currentPostSeasonBatting.sb }}</td>
<td>{{ currentPostSeasonBatting.obp.toFixed(3) }}</td>
<td>{{ currentPostSeasonBatting.slg.toFixed(3) }}</td>
<td>{{ currentPostSeasonBatting.ops.toFixed(3) }}</td>
<td>{{ currentPostSeasonBatting.woba.toFixed(3) }}</td>
</tr>
</tbody>
<tfoot v-if="careerBattingStat" id="batter-summary-footer">
<tr>
<th>Career</th>
<th>{{ careerBattingStat.ab }}</th>
<th>{{ careerBattingStat.hit }}</th>
<th>{{ careerBattingStat.hr }}</th>
<th>{{ careerBattingStat.avg.toFixed(3) }}</th>
<th>{{ careerBattingStat.run }}</th>
<th>{{ careerBattingStat.rbi }}</th>
<th>{{ careerBattingStat.sb }}</th>
<th>{{ careerBattingStat.obp.toFixed(3) }}</th>
<th>{{ careerBattingStat.slg.toFixed(3) }}</th>
<th>{{ careerBattingStat.ops.toFixed(3) }}</th>
<th>{{ careerBattingStat.woba.toFixed(3) }}</th>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="col-sm-4">
<div class="table-responsive-xl" style="max-width:20rem">
<table class="table table-sm table-striped">
<thead class="thead-dark">
<tr>
<th>Inj</th>
<th v-if="lastAppearance">Last App</th>
<th v-if="secondLastAppearance">2nd Last App</th>
</tr>
</thead>
<tbody id="batter-summary-helper">
<tr>
<td>{{ injuryRating }}</td>
<td v-if="lastAppearance">{{ lastAppearance }}</td>
<td v-if="secondLastAppearance">{{ secondLastAppearance }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Last 4 Games -->
<div class="col-small-12">
<h3>Last 4 Games</h3>
<div class="table-responsive-xl">
<table class="table table-sm table-striped">
<thead class="thead-dark">
<tr>
<th>Game</th>
<th>PA</th>
<th>AB</th>
<th>R</th>
<th>H</th>
<th>2B</th>
<th>3B</th>
<th>HR</th>
<th>RBI</th>
<th>SB</th>
<th>CS</th>
<th>BB</th>
<th>SO</th>
<th>BPHR</th>
<th>BPFO</th>
<th>BP1B</th>
<th>BPLO</th>
<th>GIDP</th>
<th>HBP</th>
<th>SAC</th>
<th>IBB</th>
</tr>
</thead>
<tbody id="last4-batting">
<tr v-for="gameStat in last4Games">
<td>{{ makeWxGyFromGame(gameStat.game) }}</td>
<td>{{ gameStat.pa }}</td>
<td>{{ gameStat.ab }}</td>
<td>{{ gameStat.run }}</td>
<td>{{ gameStat.hit }}</td>
<td>{{ gameStat.double }}</td>
<td>{{ gameStat.triple }}</td>
<td>{{ gameStat.hr }}</td>
<td>{{ gameStat.rbi }}</td>
<td>{{ gameStat.sb }}</td>
<td>{{ gameStat.cs }}</td>
<td>{{ gameStat.bb }}</td>
<td>{{ gameStat.so }}</td>
<td>{{ gameStat.bphr }}</td>
<td>{{ gameStat.bpfo }}</td>
<td>{{ gameStat.bp1b }}</td>
<td>{{ gameStat.bplo }}</td>
<td>{{ gameStat.gidp }}</td>
<td>{{ gameStat.hbp }}</td>
<td>{{ gameStat.sac }}</td>
<td>{{ gameStat.ibb }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Career Batting -->
<div class="row" id="career-batting-row">
<div class="col-sm-12">
<h3>Batting Stats</h3>
<div class="table-responsive-xl">
<table class="table table-sm table-striped" id="career-batting">
<thead class="thead-dark">
<tr>
<th>Season</th>
<th>PA</th>
<th>AB</th>
<th>R</th>
<th>H</th>
<th>2B</th>
<th>3B</th>
<th>HR</th>
<th>RBI</th>
<th>SB</th>
<th>CS</th>
<th>BB</th>
<th>SO</th>
<th>BA</th>
<th>OBP</th>
<th>SLG</th>
<th>OPS</th>
<th>wOBA</th>
<th>K%</th>
<th>BPHR</th>
<th>BPFO</th>
<th>BP1B</th>
<th>BPLO</th>
<th>GIDP</th>
<th>HBP</th>
<th>SAC</th>
<th>IBB</th>
</tr>
</thead>
<tbody id="career-batting-table">
<tr v-for="stat in sortedRegularAndPostSeasonBatting">
<td>S{{ stat.seasonNumber }}{{ stat.isRegularSeason ? '' : ' / Playoffs' }}</td>
<td>{{ stat.pa }}</td>
<td>{{ stat.ab }}</td>
<td>{{ stat.run }}</td>
<td>{{ stat.hit }}</td>
<td>{{ stat.double }}</td>
<td>{{ stat.triple }}</td>
<td>{{ stat.hr }}</td>
<td>{{ stat.rbi }}</td>
<td>{{ stat.sb }}</td>
<td>{{ stat.cs }}</td>
<td>{{ stat.bb }}</td>
<td>{{ stat.so }}</td>
<td>{{ stat.avg.toFixed(3) }}</td>
<td>{{ stat.obp.toFixed(3) }}</td>
<td>{{ stat.slg.toFixed(3) }}</td>
<td>{{ stat.ops.toFixed(3) }}</td>
<td>{{ stat.woba.toFixed(3) }}</td>
<td>{{ calculateStrikeoutPercent(stat) }}</td>
<td>{{ stat.bphr }}</td>
<td>{{ stat.bpfo }}</td>
<td>{{ stat.bp1b }}</td>
<td>{{ stat.bplo }}</td>
<td>{{ stat.gidp }}</td>
<td>{{ stat.hbp }}</td>
<td>{{ stat.sac }}</td>
<td>{{ stat.ibb }}</td>
</tr>
</tbody>
<tfoot>
<tr v-if="careerBattingStat" id="career-batting-footer">
<th>Career</th>
<th>{{ careerBattingStat.pa }}</th>
<th>{{ careerBattingStat.ab }}</th>
<th>{{ careerBattingStat.run }}</th>
<th>{{ careerBattingStat.hit }}</th>
<th>{{ careerBattingStat.double }}</th>
<th>{{ careerBattingStat.triple }}</th>
<th>{{ careerBattingStat.hr }}</th>
<th>{{ careerBattingStat.rbi }}</th>
<th>{{ careerBattingStat.sb }}</th>
<th>{{ careerBattingStat.cs }}</th>
<th>{{ careerBattingStat.bb }}</th>
<th>{{ careerBattingStat.so }}</th>
<th>{{ careerBattingStat.avg.toFixed(3) }}</th>
<th>{{ careerBattingStat.obp.toFixed(3) }}</th>
<th>{{ careerBattingStat.slg.toFixed(3) }}</th>
<th>{{ careerBattingStat.ops.toFixed(3) }}</th>
<th>{{ careerBattingStat.woba.toFixed(3) }}</th>
<th>{{ calculateStrikeoutPercent(careerBattingStat) }}</th>
<th>{{ careerBattingStat.bphr }}</th>
<th>{{ careerBattingStat.bpfo }}</th>
<th>{{ careerBattingStat.bp1b }}</th>
<th>{{ careerBattingStat.bplo }}</th>
<th>{{ careerBattingStat.gidp }}</th>
<th>{{ careerBattingStat.hbp }}</th>
<th>{{ careerBattingStat.sac }}</th>
<th>{{ careerBattingStat.ibb }}</th>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import type { Game } from '@/services/apiResponseTypes'
import { isDiscordAuthenticated } from '@/services/authenticationService'
import { fetchLast2DecisionsByPlayerId, type Decision } from '@/services/decisionsService'
import { type Player, fetchPlayerByName } from '@/services/playersService'
import { fetchBattingStatsBySeasonAndPlayerId, fetchBattingStatsForLastFourGamesBySeasonAndPlayerId, aggregateBattingStats, type BattingStat } from '@/services/statsService'
import { CURRENT_SEASON, isNotUndefined } from '@/services/utilities'
interface BattingStatWithSeason extends BattingStat {
seasonNumber: number
isRegularSeason: boolean
}
export default {
name: "PlayerView",
data() {
return {
isAuthenticated: false as Boolean,
player: undefined as Player | undefined,
last2Decisions: [] as Decision[],
regularSeasonBattingStats: [] as BattingStat[],
postSeasonBattingStats: [] as BattingStat[],
last4Games: [] as BattingStat[]
}
},
props: {
seasonNumber: { type: Number, required: true },
playerName: { type: String, required: true }
},
computed: {
playerSeasonNumber(): number | undefined {
return this.player?.season
},
isCurrentPlayer(): boolean {
return this.seasonNumber === this.playerSeasonNumber
},
// TODO use to determine order of stats to display
isBatter(): boolean {
return !this.player?.pos_1.includes('P')
},
teamAbbreviation(): string | undefined {
return this.player?.team?.abbrev
},
teamThumbnail(): string | undefined {
return this.player?.team?.thumbnail
},
baseballReferenceUrl(): string | undefined {
if (!this.player?.bbref_id) return undefined
const firstChar = this.player.bbref_id.slice(0, 1)
return `https://www.baseball-reference.com/players/${firstChar}/${this.player.bbref_id}.shtml`
},
playerImageUrl(): string | undefined {
return this.player?.vanity_card ?? this.teamThumbnail
},
playerCardImage1Url(): string | undefined {
if (!this.isAuthenticated) return undefined
return this.player?.image
},
playerCardImage2Url(): string | undefined {
if (!this.isAuthenticated) return undefined
return this.player?.image2
},
injuryReturnDate(): string | undefined {
if (!this.player?.il_return) return undefined
return ` 🏥 (${this.player.il_return})`
},
injuryRating(): string | undefined {
return this.player?.injury_rating
},
currentSeasonBatting(): BattingStat | undefined {
if (!this.regularSeasonBattingStats.length) return undefined
return this.regularSeasonBattingStats.find(stat => stat.player.season === CURRENT_SEASON)
},
currentPostSeasonBatting(): BattingStat | undefined {
if (!this.postSeasonBattingStats.length) return undefined
return this.postSeasonBattingStats.find(stat => stat.player.season === CURRENT_SEASON)
},
careerBattingStat(): BattingStat | undefined {
if (this.regularSeasonBattingStats.length > 0) {
// old site behavior just summed regular season stats for the career line total
return aggregateBattingStats(this.regularSeasonBattingStats)
}
return undefined
},
sortedRegularAndPostSeasonBatting(): BattingStatWithSeason[] {
let seasonStats: BattingStatWithSeason[] = this.regularSeasonBattingStats.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)
seasonStats = seasonStats.concat(this.postSeasonBattingStats.map(stat => {
return {
...stat,
seasonNumber: stat.player.season,
isRegularSeason: false
}
}))
return seasonStats.sort((s1, s2) => {
return s1.seasonNumber - s2.seasonNumber === 0
? s1.isRegularSeason
? -1
: 1
: s1.seasonNumber - s2.seasonNumber
})
},
lastAppearance(): string | undefined {
if (!this.last2Decisions?.length) return undefined
if (this.last2Decisions.length <= 0) return undefined
return this.formatDecisionToAppearance(this.last2Decisions[0])
},
secondLastAppearance(): string | undefined {
if (!this.last2Decisions?.length) return undefined
if (this.last2Decisions.length <= 1) return '-'
return this.formatDecisionToAppearance(this.last2Decisions[1])
}
},
created() {
this.fetchData()
},
watch: {
seasonNumber(newValue, oldValue) {
if (newValue !== oldValue) this.fetchData()
},
playerName(newName, oldName) {
if (newName !== oldName) this.fetchData()
}
},
methods: {
async fetchData(): Promise<void> {
this.isAuthenticated = await isDiscordAuthenticated()
this.player = await this.tryFetchPlayerByNameForAnySeason(this.seasonNumber, this.playerName)
if (!this.player) return
this.last2Decisions = await fetchLast2DecisionsByPlayerId(this.seasonNumber, this.player.id)
this.last4Games = await fetchBattingStatsForLastFourGamesBySeasonAndPlayerId(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
const playerSeasons = await Promise.all(Array.from(Array(CURRENT_SEASON), (element, index) => index + 1).map(seasonNumber => fetchPlayerByName(seasonNumber, this.player!.name)))
this.regularSeasonBattingStats = (await Promise.all(playerSeasons.filter(isNotUndefined).map(player => fetchBattingStatsBySeasonAndPlayerId(player!.season, player!.id, true)))).filter(isNotUndefined)
this.postSeasonBattingStats = (await Promise.all(playerSeasons.filter(isNotUndefined).map(player => fetchBattingStatsBySeasonAndPlayerId(player!.season, player!.id, false)))).filter(isNotUndefined)
},
async tryFetchPlayerByNameForAnySeason(seasonNumber: number, playerName: string): Promise<Player | undefined> {
do {
const player: Player | undefined = await fetchPlayerByName(seasonNumber, playerName)
if (player) return player
seasonNumber--
} while (seasonNumber > 0)
},
formatDecisionToAppearance(decision: Decision): string {
return `${decision.rest_ip.toFixed(1)}IP w${decision.week}g${decision.game_num}`
},
makeWxGyFromGame(game: Game | 'TOT'): string {
if (game === 'TOT') return 'TOT'
return `w${game.week}g${game.game_num}`
},
calculateStrikeoutPercent(stat: BattingStat): string {
if (!stat.pa) return 'N/A'
return (stat.so * 100 / stat.pa).toFixed(1)
}
}
}
</script>