Files
MLBApp/mlbTVOS/Views/Components/PitchArsenalView.swift
Trey t b5daddefd3 Add UI redesign: design system, dashboard density, game intelligence, feed tab, league leaders
Phase 1 - Design System: DesignSystem.swift (typography, colors, spacing
constants) and DataPanel.swift (reusable panel container with 3 densities
and optional team accent bar).

Phase 2 - Dashboard Density: LiveSituationBar (compact strip of all live
games with scores/innings/outs), MiniLinescoreView (R-H-E footer for game
cards), DiamondView (visual baseball diamond with runners and count).
Dashboard shows live situation bar when games are active. Game cards now
display mini linescore for live/final games.

Phase 3 - Game Center Intelligence: WinProbabilityChartView (full-game
line chart using Swift Charts with area fills), PitchArsenalView (pitch
type distribution with velocity bars). GameCenterViewModel now stores
full WP history array instead of just latest values.

Phase 4 - Feed Tab: MLBWebDataService (fetches league leaders from Stats
API, news headlines, transactions), FeedViewModel, FeedView with
reverse-chronological feed items. FeedItemView with colored edge bars
by category. Added 5th "Feed" tab to both tvOS and iOS.

Phase 5 - Intel Tab: LeaderboardView (top-5 stat cards with headshots),
integrated into LeagueCenterView. Renamed tabs: Games->Today,
League->Intel. LeagueCenterViewModel now fetches league leaders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:12:09 -05:00

132 lines
4.5 KiB
Swift

import SwiftUI
/// Pitch type breakdown for a pitcher showing distribution and average velocity
struct PitchArsenalView: View {
let allPlays: [LiveFeedPlay]
let pitcherName: String
private var pitchSummary: [PitchTypeSummary] {
var grouped: [String: (count: Int, totalSpeed: Double, speedCount: Int)] = [:]
for play in allPlays {
guard let events = play.playEvents else { continue }
for event in events where event.isPitch == true {
let type = event.pitchTypeDescription
var entry = grouped[type, default: (0, 0, 0)]
entry.count += 1
if let speed = event.speedMPH {
entry.totalSpeed += speed
entry.speedCount += 1
}
grouped[type] = entry
}
}
let total = grouped.values.reduce(0) { $0 + $1.count }
guard total > 0 else { return [] }
return grouped.map { type, data in
PitchTypeSummary(
name: type,
count: data.count,
percentage: Double(data.count) / Double(total) * 100,
avgSpeed: data.speedCount > 0 ? data.totalSpeed / Double(data.speedCount) : nil
)
}
.sorted { $0.count > $1.count }
}
var body: some View {
let summary = pitchSummary
if !summary.isEmpty {
DataPanel(.standard) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("PITCH ARSENAL")
.dataLabelStyle()
Spacer()
Text(pitcherName)
.font(DS.Fonts.bodySmall)
.foregroundStyle(DS.Colors.textTertiary)
}
ForEach(summary, id: \.name) { pitch in
pitchRow(pitch, maxCount: summary.first?.count ?? 1)
}
}
}
}
}
@ViewBuilder
private func pitchRow(_ pitch: PitchTypeSummary, maxCount: Int) -> some View {
HStack(spacing: 10) {
Text(pitch.name)
.font(pitchNameFont)
.foregroundStyle(DS.Colors.textSecondary)
.frame(width: nameWidth, alignment: .leading)
.lineLimit(1)
GeometryReader { geo in
let fraction = maxCount > 0 ? CGFloat(pitch.count) / CGFloat(maxCount) : 0
RoundedRectangle(cornerRadius: 3)
.fill(barColor(for: pitch.name))
.frame(width: geo.size.width * fraction)
}
.frame(height: barHeight)
Text("\(Int(pitch.percentage))%")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: pctWidth, alignment: .trailing)
if let speed = pitch.avgSpeed {
Text("\(Int(speed))")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textTertiary)
.frame(width: speedWidth, alignment: .trailing)
Text("mph")
.dataLabelStyle()
}
}
}
private func barColor(for pitchType: String) -> Color {
let lowered = pitchType.lowercased()
if lowered.contains("fastball") || lowered.contains("sinker") || lowered.contains("cutter") {
return DS.Colors.live
}
if lowered.contains("slider") || lowered.contains("sweep") {
return DS.Colors.interactive
}
if lowered.contains("curve") || lowered.contains("knuckle") {
return DS.Colors.media
}
if lowered.contains("change") || lowered.contains("split") {
return DS.Colors.positive
}
return DS.Colors.warning
}
#if os(tvOS)
private var nameWidth: CGFloat { 140 }
private var pctWidth: CGFloat { 44 }
private var speedWidth: CGFloat { 36 }
private var barHeight: CGFloat { 14 }
private var pitchNameFont: Font { DS.Fonts.bodySmall }
#else
private var nameWidth: CGFloat { 100 }
private var pctWidth: CGFloat { 36 }
private var speedWidth: CGFloat { 30 }
private var barHeight: CGFloat { 10 }
private var pitchNameFont: Font { .system(size: 12, weight: .medium) }
#endif
}
private struct PitchTypeSummary {
let name: String
let count: Int
let percentage: Double
let avgSpeed: Double?
}