409 lines
10 KiB
TypeScript
409 lines
10 KiB
TypeScript
import { ThemedText } from "@/components/themed-text";
|
|
import { ThemedView } from "@/components/themed-view";
|
|
import { LiveScoreMatch } from "@/types/api";
|
|
import React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { StyleSheet, View } from "react-native";
|
|
import Svg, { Circle, G } from "react-native-svg";
|
|
|
|
interface TennisStatsCardProps {
|
|
match: LiveScoreMatch;
|
|
isDark: boolean;
|
|
}
|
|
|
|
// Helper for Circular Progress
|
|
// Updated to match screenshot: Labels on top, circle in middle, values on sides.
|
|
const CircularStat = ({
|
|
value1,
|
|
value2,
|
|
label,
|
|
color1 = "#2196F3", // Blue
|
|
color2 = "#FFC107", // Gold
|
|
size = 50,
|
|
strokeWidth = 5,
|
|
isDark,
|
|
}: {
|
|
value1: string; // "67%" or "67"
|
|
value2: string;
|
|
label: string;
|
|
color1?: string;
|
|
color2?: string;
|
|
size?: number;
|
|
strokeWidth?: number;
|
|
isDark: boolean;
|
|
}) => {
|
|
const parse = (v: string) => parseFloat(v.replace("%", "")) || 0;
|
|
const v1 = parse(value1);
|
|
const v2 = parse(value2);
|
|
|
|
// Radius
|
|
const r = (size - strokeWidth) / 2;
|
|
const c = size / 2;
|
|
const circumference = 2 * Math.PI * r;
|
|
|
|
// Inner radius
|
|
const innerR = r - strokeWidth - 2; // small gap
|
|
const innerCircumference = 2 * Math.PI * innerR;
|
|
|
|
// Colors for text need to be readable on white background if card is always white
|
|
// Screenshot shows white card on dark background.
|
|
// BUT the text "First Serve %" is dark/grey.
|
|
// The values 67.4 are Blue/Gold.
|
|
// The circle tracks are light grey maybe.
|
|
|
|
const trackColor = isDark ? "#333" : "#E0E0E0";
|
|
const labelColor = isDark ? "#AAA" : "#666";
|
|
|
|
return (
|
|
<View style={styles.circularStatContainer}>
|
|
{/* Label centered */}
|
|
<ThemedText
|
|
style={[styles.circularLabel, { color: labelColor }]}
|
|
numberOfLines={2}
|
|
>
|
|
{label}
|
|
</ThemedText>
|
|
|
|
<View style={styles.circularRow}>
|
|
{/* Left Value P1 */}
|
|
<ThemedText style={[styles.statValueSide, { color: color1 }]}>
|
|
{value1}
|
|
</ThemedText>
|
|
|
|
{/* Circle Graphic */}
|
|
<View style={{ width: size, height: size, marginHorizontal: 6 }}>
|
|
<Svg width={size} height={size}>
|
|
<G rotation="-90" origin={`${c}, ${c}`}>
|
|
{/* Outer Track */}
|
|
<Circle
|
|
cx={c}
|
|
cy={c}
|
|
r={r}
|
|
stroke={trackColor}
|
|
strokeWidth={strokeWidth}
|
|
fill="transparent"
|
|
/>
|
|
|
|
{/* P1 Progress (Outer) */}
|
|
<Circle
|
|
cx={c}
|
|
cy={c}
|
|
r={r}
|
|
stroke={color1}
|
|
strokeWidth={strokeWidth}
|
|
fill="transparent"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={circumference - (v1 / 100) * circumference}
|
|
strokeLinecap="round"
|
|
/>
|
|
|
|
{/* Inner Track */}
|
|
<Circle
|
|
cx={c}
|
|
cy={c}
|
|
r={innerR}
|
|
stroke={trackColor}
|
|
strokeWidth={strokeWidth}
|
|
fill="transparent"
|
|
/>
|
|
|
|
{/* P2 Progress (Inner) */}
|
|
<Circle
|
|
cx={c}
|
|
cy={c}
|
|
r={innerR}
|
|
stroke={color2}
|
|
strokeWidth={strokeWidth}
|
|
fill="transparent"
|
|
strokeDasharray={innerCircumference}
|
|
strokeDashoffset={
|
|
innerCircumference - (v2 / 100) * innerCircumference
|
|
}
|
|
strokeLinecap="round"
|
|
/>
|
|
</G>
|
|
</Svg>
|
|
</View>
|
|
|
|
{/* Right Value P2 */}
|
|
<ThemedText style={[styles.statValueSide, { color: color2 }]}>
|
|
{value2}
|
|
</ThemedText>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// Helper for Bar Stat
|
|
const BarStat = ({
|
|
label,
|
|
value1,
|
|
value2,
|
|
color1 = "#2196F3",
|
|
color2 = "#FFC107",
|
|
isDark,
|
|
}: {
|
|
label: string;
|
|
value1: string;
|
|
value2: string;
|
|
color1?: string;
|
|
color2?: string;
|
|
isDark: boolean;
|
|
}) => {
|
|
// Parse to number for bars
|
|
const v1 = parseFloat(value1) || 0;
|
|
const v2 = parseFloat(value2) || 0;
|
|
const total = v1 + v2 || 1;
|
|
const p1 = (v1 / total) * 100;
|
|
const p2 = (v2 / total) * 100;
|
|
|
|
return (
|
|
<View style={styles.barStatContainer}>
|
|
<View style={styles.barHeader}>
|
|
<View style={[styles.pill, { backgroundColor: color1 }]}>
|
|
<ThemedText style={styles.pillText}>{value1}</ThemedText>
|
|
</View>
|
|
<ThemedText style={styles.barLabel}>{label}</ThemedText>
|
|
<View
|
|
style={[
|
|
styles.pill,
|
|
{ backgroundColor: color2, borderColor: color2, borderWidth: 1 },
|
|
]}
|
|
>
|
|
<ThemedText style={[styles.pillText, { color: "#000" }]}>
|
|
{value2}
|
|
</ThemedText>
|
|
</View>
|
|
</View>
|
|
<View style={styles.barTrack}>
|
|
<View
|
|
style={[
|
|
styles.barFill,
|
|
{
|
|
flex: v1,
|
|
backgroundColor: color1,
|
|
borderTopLeftRadius: 4,
|
|
borderBottomLeftRadius: 4,
|
|
},
|
|
]}
|
|
/>
|
|
<View style={{ width: 4 }} />
|
|
<View
|
|
style={[
|
|
styles.barFill,
|
|
{
|
|
flex: v2,
|
|
backgroundColor: color2,
|
|
borderTopRightRadius: 4,
|
|
borderBottomRightRadius: 4,
|
|
},
|
|
]}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export function TennisStatsCard({ match, isDark }: TennisStatsCardProps) {
|
|
const { t } = useTranslation();
|
|
const statistics = match.statistics;
|
|
|
|
if (!statistics || !Array.isArray(statistics) || statistics.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Check structure. If it's the Football type (home/away keys), we shouldn't be here (LiveDetail logic checks sport).
|
|
// But safely handle:
|
|
const isTennisFormat =
|
|
"player_key" in statistics[0] || "name" in statistics[0];
|
|
|
|
if (!isTennisFormat) return null;
|
|
|
|
// Group by name
|
|
// Map Name -> { p1Value: string, p2Value: string }
|
|
const statsMap: Record<string, { p1: string; p2: string; type?: string }> =
|
|
{};
|
|
|
|
// Assume P1 is first_player_key
|
|
// Using home_team_key as fallback/primary since first_player_key isn't in type definition
|
|
// In many APIs, for tennis, home_team_key === first_player_key
|
|
const p1Key = match.home_team_key;
|
|
|
|
// In generic response, keys might be strings vs numbers.
|
|
// Let's rely on finding two entries for same "name" and "period".
|
|
// Or just iterate.
|
|
|
|
(statistics as any[]).forEach((stat) => {
|
|
// Filter for 'match' period (total)
|
|
if (stat.period !== "match") return;
|
|
|
|
const name = stat.name;
|
|
if (!statsMap[name]) statsMap[name] = { p1: "0", p2: "0", type: stat.type };
|
|
|
|
// Assign to P1 or P2
|
|
// If we know p1Key
|
|
if (p1Key && stat.player_key == p1Key) {
|
|
statsMap[name].p1 = stat.value;
|
|
} else {
|
|
statsMap[name].p2 = stat.value;
|
|
}
|
|
});
|
|
|
|
// Define intended stats to show
|
|
// Top (Circles):
|
|
// "1st serve percentage" (1st Serve %)
|
|
// "1st serve points won" (1st Serve Win %)
|
|
// "Break Points Converted" -> "Break Success %" (Screenshot: 破发成功率)
|
|
// Or match keys exactly: "1st serve percentage", "1st serve points won", "Break Points Converted"
|
|
|
|
// Middle (Bars):
|
|
// "Aces" (Screenshot: 发球得分? Or implies Aces).
|
|
// "Double Faults" (Screenshot: 双误)
|
|
// "Service Points Won"
|
|
|
|
const circleStats = [
|
|
{ key: "1st serve percentage", label: t("detail.stats.first_serve") },
|
|
{
|
|
key: "1st serve points won",
|
|
label: t("detail.stats.first_serve_points"),
|
|
},
|
|
{ key: "Break Points Converted", label: t("detail.stats.break_points") },
|
|
];
|
|
|
|
const barStats = [
|
|
{ key: "Aces", label: t("detail.stats.aces") },
|
|
{ key: "Double Faults", label: t("detail.stats.double_faults") },
|
|
];
|
|
|
|
// Check if any of the target stats actually exist in the map
|
|
const hasAnyStat = [...circleStats, ...barStats].some((s) => statsMap[s.key]);
|
|
if (!hasAnyStat) return null;
|
|
|
|
return (
|
|
<ThemedView
|
|
style={[
|
|
styles.container,
|
|
{
|
|
backgroundColor: isDark ? "#1E1E20" : "#FFF",
|
|
borderRadius: 12,
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 4,
|
|
elevation: 3,
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText style={[styles.title, { color: isDark ? "#FFF" : "#000" }]}>
|
|
{t("detail.statistics")}
|
|
</ThemedText>
|
|
|
|
<View style={styles.circlesRow}>
|
|
{circleStats.map((s) =>
|
|
statsMap[s.key] ? (
|
|
<CircularStat
|
|
key={s.key}
|
|
label={s.label}
|
|
value1={statsMap[s.key].p1}
|
|
value2={statsMap[s.key].p2}
|
|
size={50}
|
|
isDark={false}
|
|
/>
|
|
) : null,
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.barsColumn}>
|
|
{barStats.map((s) =>
|
|
statsMap[s.key] ? (
|
|
<BarStat
|
|
key={s.key}
|
|
label={s.label}
|
|
value1={statsMap[s.key].p1}
|
|
value2={statsMap[s.key].p2}
|
|
isDark={isDark}
|
|
/>
|
|
) : null,
|
|
)}
|
|
</View>
|
|
</ThemedView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
margin: 16,
|
|
padding: 16,
|
|
borderRadius: 12,
|
|
},
|
|
title: {
|
|
fontSize: 16,
|
|
fontWeight: "bold",
|
|
marginBottom: 20,
|
|
},
|
|
circlesRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-around",
|
|
marginBottom: 24,
|
|
},
|
|
circularStatContainer: {
|
|
alignItems: "center",
|
|
width: "30%",
|
|
},
|
|
circularLabel: {
|
|
fontSize: 12,
|
|
textAlign: "center",
|
|
opacity: 0.7,
|
|
height: 32, // Fixed height for alignment
|
|
},
|
|
circularRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
statValueSide: {
|
|
fontSize: 12,
|
|
fontWeight: "bold",
|
|
},
|
|
statValue: {
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
},
|
|
barsColumn: {
|
|
gap: 16,
|
|
},
|
|
barStatContainer: {
|
|
marginBottom: 8,
|
|
},
|
|
barHeader: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: 8,
|
|
},
|
|
pill: {
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 4,
|
|
borderRadius: 12,
|
|
minWidth: 40,
|
|
alignItems: "center",
|
|
},
|
|
pillText: {
|
|
color: "white",
|
|
fontWeight: "bold",
|
|
fontSize: 12,
|
|
},
|
|
barLabel: {
|
|
opacity: 0.8,
|
|
},
|
|
barTrack: {
|
|
flexDirection: "row",
|
|
height: 6,
|
|
backgroundColor: "#333",
|
|
borderRadius: 3,
|
|
overflow: "hidden",
|
|
},
|
|
barFill: {
|
|
height: "100%",
|
|
},
|
|
});
|