Files
iddaai-fe/src/components/matches/v28-odds-band-panel.tsx
T
2026-04-23 22:23:35 +03:00

670 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}