670 lines
19 KiB
TypeScript
670 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Badge,
|
||
Box,
|
||
Card,
|
||
Grid,
|
||
HStack,
|
||
Icon,
|
||
IconButton,
|
||
SimpleGrid,
|
||
Text,
|
||
VStack,
|
||
} from "@chakra-ui/react";
|
||
import {
|
||
LuBadgeCheck,
|
||
LuCircleHelp,
|
||
LuRectangleVertical,
|
||
LuShieldAlert,
|
||
LuTarget,
|
||
LuTrendingUp,
|
||
} from "react-icons/lu";
|
||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
||
import type {
|
||
HtftComboKey,
|
||
OddsBandCardsDto,
|
||
OddsBandHtftComboDto,
|
||
TripleValueEntryDto,
|
||
V27EngineDto,
|
||
} from "@/lib/api/predictions/types";
|
||
|
||
// ──────────────────────────────────────
|
||
// Helpers
|
||
// ──────────────────────────────────────
|
||
|
||
function pct(v: number, d = 0): string {
|
||
if (!v && v !== 0) return "-";
|
||
return `${(v * 100).toFixed(d)}%`;
|
||
}
|
||
|
||
function edgeStr(edge: number): string {
|
||
const sign = edge > 0 ? "+" : "";
|
||
return `${sign}${(edge * 100).toFixed(1)}%`;
|
||
}
|
||
|
||
const TRIPLE_VALUE_LABELS: Record<string, string> = {
|
||
home: "MS Ev",
|
||
away: "MS Dep",
|
||
ou25_over: "ÜST 2.5",
|
||
btts_yes: "KG Var",
|
||
ou15_over: "ÜST 1.5",
|
||
ou35_over: "ÜST 3.5",
|
||
dc_1x: "ÇŞ 1X",
|
||
dc_x2: "ÇŞ X2",
|
||
dc_12: "ÇŞ 12",
|
||
ht_home: "İY Ev",
|
||
ht_away: "İY Dep",
|
||
ht_ou05_over: "İY ÜST 0.5",
|
||
ht_ou15_over: "İY ÜST 1.5",
|
||
oe_odd: "Tek",
|
||
cards_over: "Kart ÜST",
|
||
htft_11: "İY/MS 1/1",
|
||
htft_1x: "İY/MS 1/X",
|
||
htft_12: "İY/MS 1/2",
|
||
htft_x1: "İY/MS X/1",
|
||
htft_xx: "İY/MS X/X",
|
||
htft_x2: "İY/MS X/2",
|
||
htft_21: "İY/MS 2/1",
|
||
htft_2x: "İY/MS 2/X",
|
||
htft_22: "İY/MS 2/2",
|
||
};
|
||
|
||
const HTFT_DISPLAY: Record<HtftComboKey, string> = {
|
||
"11": "1/1",
|
||
"1x": "1/X",
|
||
"12": "1/2",
|
||
x1: "X/1",
|
||
xx: "X/X",
|
||
x2: "X/2",
|
||
"21": "2/1",
|
||
"2x": "2/X",
|
||
"22": "2/2",
|
||
};
|
||
|
||
const HTFT_ROWS: HtftComboKey[][] = [
|
||
["11", "1x", "12"],
|
||
["x1", "xx", "x2"],
|
||
["21", "2x", "22"],
|
||
];
|
||
|
||
function TooltipIcon({ content }: { content: string }) {
|
||
return (
|
||
<Tooltip
|
||
content={content}
|
||
showArrow
|
||
positioning={{ placement: "top" }}
|
||
contentProps={{ maxW: "260px", fontSize: "xs", px: 3, py: 2 }}
|
||
>
|
||
<IconButton
|
||
aria-label="Bilgi"
|
||
variant="ghost"
|
||
size="2xs"
|
||
colorPalette="gray"
|
||
>
|
||
<LuCircleHelp />
|
||
</IconButton>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
function SectionTitle({
|
||
icon,
|
||
title,
|
||
info,
|
||
}: {
|
||
icon: React.ElementType;
|
||
title: string;
|
||
info?: string;
|
||
}) {
|
||
return (
|
||
<HStack justify="space-between" w="full">
|
||
<HStack gap={2}>
|
||
<Icon as={icon} boxSize={4.5} color="fg.muted" />
|
||
<Text fontSize="lg" fontWeight="semibold">
|
||
{title}
|
||
</Text>
|
||
</HStack>
|
||
{info ? <TooltipIcon content={info} /> : null}
|
||
</HStack>
|
||
);
|
||
}
|
||
|
||
// ──────────────────────────────────────
|
||
// Triple Value Card
|
||
// ──────────────────────────────────────
|
||
|
||
function TripleValueCard({
|
||
label,
|
||
entry,
|
||
}: {
|
||
label: string;
|
||
entry: TripleValueEntryDto;
|
||
}) {
|
||
const isValue = entry.is_value;
|
||
const hasSample = entry.band_sample >= 5;
|
||
|
||
const cardBg = useColorModeValue(
|
||
isValue ? "green.50" : "gray.50",
|
||
isValue ? "green.950" : "whiteAlpha.50",
|
||
);
|
||
const borderCol = useColorModeValue(
|
||
isValue ? "green.300" : "gray.200",
|
||
isValue ? "green.700" : "gray.700",
|
||
);
|
||
const edgeColor = entry.edge > 0.03 ? "green.500" : entry.edge < -0.03 ? "red.400" : "fg.muted";
|
||
|
||
return (
|
||
<Box
|
||
p={3}
|
||
bg={cardBg}
|
||
borderWidth="1px"
|
||
borderColor={borderCol}
|
||
borderRadius="xl"
|
||
position="relative"
|
||
overflow="hidden"
|
||
>
|
||
{isValue && (
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left={0}
|
||
right={0}
|
||
h="3px"
|
||
bgGradient="to-r"
|
||
gradientFrom="green.400"
|
||
gradientTo="teal.400"
|
||
/>
|
||
)}
|
||
<VStack align="stretch" gap={1.5}>
|
||
<HStack justify="space-between">
|
||
<Text fontSize="xs" fontWeight="semibold" color="fg.muted">
|
||
{label}
|
||
</Text>
|
||
{isValue ? (
|
||
<Badge
|
||
colorPalette="green"
|
||
variant="solid"
|
||
fontSize="2xs"
|
||
borderRadius="full"
|
||
>
|
||
DEĞER
|
||
</Badge>
|
||
) : hasSample ? (
|
||
<Badge variant="outline" fontSize="2xs" borderRadius="full">
|
||
PAS
|
||
</Badge>
|
||
) : (
|
||
<Badge
|
||
colorPalette="gray"
|
||
variant="subtle"
|
||
fontSize="2xs"
|
||
borderRadius="full"
|
||
>
|
||
YETERSİZ
|
||
</Badge>
|
||
)}
|
||
</HStack>
|
||
|
||
<Text fontSize="xl" fontWeight="bold" color={edgeColor}>
|
||
{edgeStr(entry.edge)}
|
||
</Text>
|
||
|
||
<HStack gap={2} flexWrap="wrap">
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
Band: {pct(entry.band_rate, 1)}
|
||
</Text>
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
Oran: {pct(entry.implied_prob, 1)}
|
||
</Text>
|
||
{entry.confirmations !== undefined && (
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
Onay: {entry.confirmations}/2
|
||
</Text>
|
||
)}
|
||
</HStack>
|
||
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
{entry.band_sample} maç
|
||
</Text>
|
||
</VStack>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ──────────────────────────────────────
|
||
// Cards Section
|
||
// ──────────────────────────────────────
|
||
|
||
function ProgressBar({
|
||
value,
|
||
max,
|
||
color,
|
||
}: {
|
||
value: number;
|
||
max: number;
|
||
color: string;
|
||
}) {
|
||
const trackBg = useColorModeValue("gray.100", "gray.700");
|
||
const w = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
||
return (
|
||
<Box
|
||
h="10px"
|
||
w="full"
|
||
bg={trackBg}
|
||
borderRadius="full"
|
||
overflow="hidden"
|
||
>
|
||
<Box
|
||
h="full"
|
||
w={`${w}%`}
|
||
bg={color}
|
||
borderRadius="full"
|
||
transition="width 0.4s ease"
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function CardsSection({ cards }: { cards: OddsBandCardsDto }) {
|
||
const cardBg = useColorModeValue("white", "gray.800");
|
||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||
const hasData = cards.sample >= 3;
|
||
|
||
if (!hasData) {
|
||
return (
|
||
<Box
|
||
p={4}
|
||
bg={cardBg}
|
||
borderWidth="1px"
|
||
borderColor={borderColor}
|
||
borderRadius="xl"
|
||
>
|
||
<HStack gap={2} mb={2}>
|
||
<Icon as={LuRectangleVertical} boxSize={4} color="yellow.500" />
|
||
<Text fontSize="sm" fontWeight="semibold">
|
||
Kart Analizi
|
||
</Text>
|
||
</HStack>
|
||
<Text fontSize="sm" color="fg.muted">
|
||
Yetersiz veri — henüz yeterli maç örneği bulunamadı.
|
||
</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
const overPct = cards.combined_over_rate * 100;
|
||
const overColor =
|
||
overPct >= 65 ? "red.400" : overPct >= 50 ? "orange.400" : "green.400";
|
||
|
||
return (
|
||
<Box
|
||
p={4}
|
||
bg={cardBg}
|
||
borderWidth="1px"
|
||
borderColor={borderColor}
|
||
borderRadius="xl"
|
||
>
|
||
<HStack justify="space-between" mb={4}>
|
||
<HStack gap={2}>
|
||
<Icon as={LuRectangleVertical} boxSize={4} color="yellow.500" />
|
||
<Text fontSize="sm" fontWeight="semibold">
|
||
Kart Analizi
|
||
</Text>
|
||
</HStack>
|
||
<Badge variant="outline" fontSize="2xs" borderRadius="full">
|
||
{cards.sample} maç
|
||
</Badge>
|
||
</HStack>
|
||
|
||
<VStack align="stretch" gap={3}>
|
||
{/* Referee profile */}
|
||
<Box>
|
||
<HStack justify="space-between" mb={1}>
|
||
<Text fontSize="xs" color="fg.muted">
|
||
Hakem Profili
|
||
</Text>
|
||
<Text fontSize="xs" fontWeight="semibold">
|
||
Ort: {cards.referee_avg.toFixed(1)} kart
|
||
</Text>
|
||
</HStack>
|
||
<ProgressBar
|
||
value={cards.referee_over_rate * 100}
|
||
max={100}
|
||
color="purple.400"
|
||
/>
|
||
<HStack justify="space-between" mt={0.5}>
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
Üst oranı: {pct(cards.referee_over_rate, 0)}
|
||
</Text>
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
{cards.referee_sample} maç
|
||
</Text>
|
||
</HStack>
|
||
</Box>
|
||
|
||
{/* Team profile */}
|
||
<Box>
|
||
<HStack justify="space-between" mb={1}>
|
||
<Text fontSize="xs" color="fg.muted">
|
||
Takım Profili
|
||
</Text>
|
||
<Text fontSize="xs" fontWeight="semibold">
|
||
Ort: {cards.team_avg.toFixed(1)} kart
|
||
</Text>
|
||
</HStack>
|
||
<ProgressBar
|
||
value={cards.team_over_rate * 100}
|
||
max={100}
|
||
color="blue.400"
|
||
/>
|
||
<HStack justify="space-between" mt={0.5}>
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
Üst oranı: {pct(cards.team_over_rate, 0)}
|
||
</Text>
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
{cards.team_sample} maç
|
||
</Text>
|
||
</HStack>
|
||
</Box>
|
||
|
||
{/* Combined */}
|
||
<Box
|
||
p={3}
|
||
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
|
||
borderRadius="lg"
|
||
>
|
||
<HStack justify="space-between">
|
||
<VStack align="start" gap={0}>
|
||
<Text fontSize="xs" fontWeight="semibold">
|
||
Kombine ÜST Oranı
|
||
</Text>
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
%60 Hakem + %40 Takım ağırlıklı
|
||
</Text>
|
||
</VStack>
|
||
<Text fontSize="2xl" fontWeight="bold" color={overColor}>
|
||
{overPct.toFixed(0)}%
|
||
</Text>
|
||
</HStack>
|
||
</Box>
|
||
</VStack>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ──────────────────────────────────────
|
||
// HTFT 3x3 Grid
|
||
// ──────────────────────────────────────
|
||
|
||
function HtftGrid({
|
||
htft,
|
||
}: {
|
||
htft: Record<HtftComboKey, OddsBandHtftComboDto>;
|
||
}) {
|
||
const cardBg = useColorModeValue("white", "gray.800");
|
||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||
|
||
// Find the max rate for highlighting
|
||
let maxRate = 0;
|
||
let maxKey: HtftComboKey = "11";
|
||
let totalSample = 0;
|
||
for (const [key, val] of Object.entries(htft) as [
|
||
HtftComboKey,
|
||
OddsBandHtftComboDto,
|
||
][]) {
|
||
if (val.rate > maxRate) {
|
||
maxRate = val.rate;
|
||
maxKey = key;
|
||
}
|
||
totalSample = Math.max(totalSample, val.sample);
|
||
}
|
||
|
||
const getCellColor = (rate: number, isMax: boolean) => {
|
||
if (isMax) return { bg: "green.500", text: "white" };
|
||
if (rate >= 0.2) return { bg: "green.100", text: "green.800" };
|
||
if (rate >= 0.12) return { bg: "yellow.100", text: "yellow.800" };
|
||
if (rate >= 0.06) return { bg: "orange.50", text: "orange.700" };
|
||
return { bg: "gray.50", text: "gray.500" };
|
||
};
|
||
|
||
const getCellColorDark = (rate: number, isMax: boolean) => {
|
||
if (isMax) return { bg: "green.600", text: "white" };
|
||
if (rate >= 0.2) return { bg: "green.900", text: "green.200" };
|
||
if (rate >= 0.12) return { bg: "yellow.900", text: "yellow.200" };
|
||
if (rate >= 0.06) return { bg: "orange.900", text: "orange.200" };
|
||
return { bg: "whiteAlpha.50", text: "gray.500" };
|
||
};
|
||
|
||
const lightMode = useColorModeValue(true, false);
|
||
|
||
return (
|
||
<Box
|
||
p={4}
|
||
bg={cardBg}
|
||
borderWidth="1px"
|
||
borderColor={borderColor}
|
||
borderRadius="xl"
|
||
>
|
||
<HStack justify="space-between" mb={4}>
|
||
<HStack gap={2}>
|
||
<Icon as={LuTarget} boxSize={4} color="teal.500" />
|
||
<Text fontSize="sm" fontWeight="semibold">
|
||
İY/MS Kombinasyonları
|
||
</Text>
|
||
</HStack>
|
||
<TooltipIcon content="İlk yarı sonucu ve maç sonucu kombinasyonlarının tarihsel oran bandındaki gerçekleşme oranları." />
|
||
</HStack>
|
||
|
||
{/* Column headers */}
|
||
<Grid templateColumns="50px repeat(3, 1fr)" gap={1.5} mb={1.5}>
|
||
<Box />
|
||
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||
MS 1
|
||
</Text>
|
||
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||
MS X
|
||
</Text>
|
||
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
|
||
MS 2
|
||
</Text>
|
||
</Grid>
|
||
|
||
{/* Grid rows */}
|
||
{HTFT_ROWS.map((row, rowIdx) => {
|
||
const rowLabels = ["İY 1", "İY X", "İY 2"];
|
||
return (
|
||
<Grid
|
||
key={rowIdx}
|
||
templateColumns="50px repeat(3, 1fr)"
|
||
gap={1.5}
|
||
mb={1.5}
|
||
>
|
||
<Box display="flex" alignItems="center">
|
||
<Text fontSize="2xs" fontWeight="bold" color="fg.muted">
|
||
{rowLabels[rowIdx]}
|
||
</Text>
|
||
</Box>
|
||
{row.map((comboKey) => {
|
||
const data = htft[comboKey] || { rate: 0, sample: 0 };
|
||
const isMax = comboKey === maxKey && maxRate > 0.05;
|
||
const colors = lightMode
|
||
? getCellColor(data.rate, isMax)
|
||
: getCellColorDark(data.rate, isMax);
|
||
|
||
return (
|
||
<Box
|
||
key={comboKey}
|
||
py={2.5}
|
||
px={2}
|
||
bg={colors.bg}
|
||
borderRadius="lg"
|
||
textAlign="center"
|
||
position="relative"
|
||
transition="all 0.2s"
|
||
_hover={{ transform: "scale(1.04)" }}
|
||
>
|
||
<Text
|
||
fontSize="xs"
|
||
fontWeight="bold"
|
||
color={colors.text}
|
||
mb={0.5}
|
||
>
|
||
{HTFT_DISPLAY[comboKey]}
|
||
</Text>
|
||
<Text
|
||
fontSize="lg"
|
||
fontWeight="extrabold"
|
||
color={colors.text}
|
||
>
|
||
{pct(data.rate, 0)}
|
||
</Text>
|
||
<Text
|
||
fontSize="2xs"
|
||
color={isMax ? "whiteAlpha.800" : "fg.muted"}
|
||
>
|
||
{data.sample} maç
|
||
</Text>
|
||
{isMax && (
|
||
<Icon
|
||
as={LuBadgeCheck}
|
||
position="absolute"
|
||
top={1}
|
||
right={1}
|
||
boxSize={3.5}
|
||
color="white"
|
||
/>
|
||
)}
|
||
</Box>
|
||
);
|
||
})}
|
||
</Grid>
|
||
);
|
||
})}
|
||
|
||
{/* Best combo callout */}
|
||
{maxRate > 0.05 && (
|
||
<Box
|
||
mt={2}
|
||
p={2.5}
|
||
bg={useColorModeValue("green.50", "green.950")}
|
||
borderRadius="lg"
|
||
>
|
||
<HStack gap={2}>
|
||
<Icon as={LuTrendingUp} boxSize={4} color="green.500" />
|
||
<Text fontSize="xs" fontWeight="semibold">
|
||
En güçlü:{" "}
|
||
<Text as="span" color="green.500">
|
||
{HTFT_DISPLAY[maxKey]} ({pct(maxRate, 0)})
|
||
</Text>
|
||
</Text>
|
||
</HStack>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ──────────────────────────────────────
|
||
// Main Panel Export
|
||
// ──────────────────────────────────────
|
||
|
||
interface V28OddsBandPanelProps {
|
||
engine: V27EngineDto;
|
||
}
|
||
|
||
export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
|
||
const cardBg = useColorModeValue("white", "gray.800");
|
||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||
|
||
const tripleValue = engine.triple_value;
|
||
const cards = engine.odds_band?.cards as OddsBandCardsDto | undefined;
|
||
const htft = engine.odds_band?.htft as
|
||
| Record<HtftComboKey, OddsBandHtftComboDto>
|
||
| undefined;
|
||
|
||
// Filter out HTFT triple-value entries from the main grid (shown in HTFT section)
|
||
const mainValueEntries = Object.entries(tripleValue || {}).filter(
|
||
([key]) => !key.startsWith("htft_"),
|
||
);
|
||
|
||
// Separate value hits from non-hits for priority ordering
|
||
const valueHits = mainValueEntries.filter(([, e]) => e.is_value);
|
||
const valueNon = mainValueEntries.filter(([, e]) => !e.is_value);
|
||
const orderedEntries = [...valueHits, ...valueNon];
|
||
|
||
const hasTriple = orderedEntries.length > 0;
|
||
const hasCards = cards && cards.sample >= 1;
|
||
const hasHtft = htft && Object.values(htft).some((v) => v.sample > 0);
|
||
|
||
if (!hasTriple && !hasCards && !hasHtft) return null;
|
||
|
||
return (
|
||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||
<Card.Body gap={5}>
|
||
<SectionTitle
|
||
icon={LuShieldAlert}
|
||
title="V28 Oran Bandı Analizi"
|
||
info="Geçmiş maçlarda benzer oranlarda gerçekleşen sonuçların istatistiksel analizi. Triple Value, Kart Profili ve İY/MS kombinasyonlarını içerir."
|
||
/>
|
||
|
||
{/* Engine version badge */}
|
||
<HStack>
|
||
<Badge colorPalette="purple" variant="subtle" borderRadius="full" fontSize="2xs">
|
||
{engine.version}
|
||
</Badge>
|
||
{engine.consensus && (
|
||
<Badge
|
||
colorPalette={engine.consensus === "AGREE" ? "green" : "orange"}
|
||
variant="solid"
|
||
borderRadius="full"
|
||
fontSize="2xs"
|
||
>
|
||
{engine.consensus === "AGREE" ? "Motorlar Uyumlu" : "Motorlar Farklı"}
|
||
</Badge>
|
||
)}
|
||
{valueHits.length > 0 && (
|
||
<Badge colorPalette="green" variant="outline" borderRadius="full" fontSize="2xs">
|
||
{valueHits.length} Değer Sinyali
|
||
</Badge>
|
||
)}
|
||
</HStack>
|
||
|
||
{/* Triple Value Grid */}
|
||
{hasTriple && (
|
||
<Box>
|
||
<HStack mb={3} gap={2}>
|
||
<Icon as={LuTarget} boxSize={4} color="blue.500" />
|
||
<Text fontSize="sm" fontWeight="semibold">
|
||
Değer Tespiti (Triple Value)
|
||
</Text>
|
||
<TooltipIcon content="Model olasılığı, oran bandı istatistiği ve piyasa oranı karşılaştırması. Edge pozitifse model avantaj görüyor demektir." />
|
||
</HStack>
|
||
<SimpleGrid columns={{ base: 2, md: 3, xl: 4 }} gap={2.5}>
|
||
{orderedEntries.map(([key, entry]) => (
|
||
<TripleValueCard
|
||
key={key}
|
||
label={TRIPLE_VALUE_LABELS[key] || key}
|
||
entry={entry}
|
||
/>
|
||
))}
|
||
</SimpleGrid>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Cards + HTFT side by side on large screens */}
|
||
{(hasCards || hasHtft) && (
|
||
<Grid
|
||
templateColumns={{ base: "1fr", xl: hasCards && hasHtft ? "1fr 1fr" : "1fr" }}
|
||
gap={4}
|
||
>
|
||
{hasCards && <CardsSection cards={cards} />}
|
||
{hasHtft && <HtftGrid htft={htft} />}
|
||
</Grid>
|
||
)}
|
||
</Card.Body>
|
||
</Card.Root>
|
||
);
|
||
}
|