添加网球详情
This commit is contained in:
408
components/live-detail/tennis-stats-card.tsx
Normal file
408
components/live-detail/tennis-stats-card.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
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%",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user