import SwiftUI struct StrikeZoneView: View { let pitches: [LiveFeedPlayEvent] var size: CGFloat = 200 // Visible range: enough to show balls just outside the zone // pX: feet from center of plate. Zone is -0.83 to 0.83. Show -1.5 to 1.5. // pZ: height in feet. Zone is ~1.5 to 3.5. Show 0.5 to 4.5. private let viewMinX: Double = -1.5 private let viewMaxX: Double = 1.5 private let viewMinZ: Double = 0.5 private let viewMaxZ: Double = 4.5 private let zoneHalfWidth: Double = 0.83 private var strikeZoneTop: Double { pitches.compactMap { $0.pitchData?.strikeZoneTop }.last ?? 3.4 } private var strikeZoneBottom: Double { pitches.compactMap { $0.pitchData?.strikeZoneBottom }.last ?? 1.6 } var body: some View { Canvas { context, canvasSize in let w = canvasSize.width let h = canvasSize.height let rangeX = viewMaxX - viewMinX let rangeZ = viewMaxZ - viewMinZ func mapX(_ pX: Double) -> CGFloat { CGFloat((pX - viewMinX) / rangeX) * w } func mapZ(_ pZ: Double) -> CGFloat { // Higher pZ = higher on screen = lower canvas Y h - CGFloat((pZ - viewMinZ) / rangeZ) * h } // Strike zone rectangle let zoneLeft = mapX(-zoneHalfWidth) let zoneRight = mapX(zoneHalfWidth) let zoneTop = mapZ(strikeZoneTop) let zoneBottom = mapZ(strikeZoneBottom) let zoneRect = CGRect(x: zoneLeft, y: zoneTop, width: zoneRight - zoneLeft, height: zoneBottom - zoneTop) context.fill(Path(zoneRect), with: .color(.white.opacity(0.06))) context.stroke(Path(zoneRect), with: .color(.white.opacity(0.3)), lineWidth: 1.5) // 3x3 grid let zoneW = zoneRight - zoneLeft let zoneH = zoneBottom - zoneTop for i in 1...2 { let xLine = zoneLeft + zoneW * CGFloat(i) / 3.0 var vPath = Path() vPath.move(to: CGPoint(x: xLine, y: zoneTop)) vPath.addLine(to: CGPoint(x: xLine, y: zoneBottom)) context.stroke(vPath, with: .color(.white.opacity(0.12)), lineWidth: 0.5) let yLine = zoneTop + zoneH * CGFloat(i) / 3.0 var hPath = Path() hPath.move(to: CGPoint(x: zoneLeft, y: yLine)) hPath.addLine(to: CGPoint(x: zoneRight, y: yLine)) context.stroke(hPath, with: .color(.white.opacity(0.12)), lineWidth: 0.5) } // Pitch dots let dotScale = min(size / 200.0, 1.0) for (index, pitch) in pitches.enumerated() { guard let coords = pitch.pitchData?.coordinates, let pX = coords.pX, let pZ = coords.pZ else { continue } let cx = mapX(pX) let cy = mapZ(pZ) let isLatest = index == pitches.count - 1 let dotRadius: CGFloat = (isLatest ? 9 : 7) * dotScale let color = pitchCallColor(pitch.callCode) let dotRect = CGRect( x: cx - dotRadius, y: cy - dotRadius, width: dotRadius * 2, height: dotRadius * 2 ) if isLatest { let glowRect = dotRect.insetBy(dx: -4 * dotScale, dy: -4 * dotScale) context.fill(Circle().path(in: glowRect), with: .color(color.opacity(0.3))) } context.fill(Circle().path(in: dotRect), with: .color(color)) context.stroke(Circle().path(in: dotRect), with: .color(.white.opacity(0.4)), lineWidth: 0.5) // Pitch number let fontSize: CGFloat = max(8 * dotScale, 7) let numText = Text("\(pitch.pitchNumber ?? (index + 1))") .font(.system(size: fontSize, weight: .bold, design: .rounded)) .foregroundColor(.white) context.draw(context.resolve(numText), at: CGPoint(x: cx, y: cy), anchor: .center) } } .frame(width: size, height: size * 1.33) } }