258 lines
9.0 KiB
TypeScript
258 lines
9.0 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { PlayerMatchupRadar } from '@/components/PlayerMatchupRadar';
|
||
import { PropValueCard } from '@/components/PropValueCard';
|
||
import {
|
||
estimatePlayerPropProbability,
|
||
type PlayerMetricProfile,
|
||
} from '@/lib/betting-utils';
|
||
import {
|
||
calculatePlayerProps,
|
||
type PlayerPropsResponse,
|
||
} from '@/lib/analytics-api';
|
||
|
||
type Metric = 'shots' | 'shots_on_target' | 'passes';
|
||
|
||
type RadarMetric = {
|
||
攻擊: number;
|
||
運球: number;
|
||
組織: number;
|
||
壓迫: number;
|
||
對位完成: number;
|
||
穩定度: number;
|
||
};
|
||
|
||
const metricLabelMap: Record<Metric, string> = {
|
||
shots: '射門',
|
||
shots_on_target: '射正',
|
||
passes: '傳球',
|
||
};
|
||
|
||
export default function PlayerPropsPage() {
|
||
const [playerName, setPlayerName] = useState('Kylian Mbappé');
|
||
const [opponentName, setOpponentName] = useState('墨西哥');
|
||
const [metric, setMetric] = useState<Metric>('shots');
|
||
const [baselineMean, setBaselineMean] = useState(3.2);
|
||
const [line, setLine] = useState(1.5);
|
||
const [matchMinutes, setMatchMinutes] = useState(90);
|
||
const [bookmakerOdds, setBookmakerOdds] = useState(2.15);
|
||
const [loading, setLoading] = useState(false);
|
||
const [errorMessage, setErrorMessage] = useState('');
|
||
const [analysis, setAnalysis] = useState<PlayerPropsResponse | null>(null);
|
||
const [radarMetric] = useState<RadarMetric>({
|
||
攻擊: 72,
|
||
運球: 68,
|
||
組織: 74,
|
||
壓迫: 61,
|
||
對位完成: 66,
|
||
穩定度: 70,
|
||
});
|
||
|
||
const profile = useMemo<PlayerMetricProfile>(() => ({
|
||
playerId: playerName,
|
||
metric,
|
||
baselineMean,
|
||
line,
|
||
matchMinutes,
|
||
teamAttackFactor: 1.15,
|
||
opponentDefenceFactor: 0.92,
|
||
weatherFatigueFactor: 1.02,
|
||
}), [playerName, metric, baselineMean, line, matchMinutes]);
|
||
|
||
const estimate = useMemo(() => estimatePlayerPropProbability(profile), [profile]);
|
||
|
||
const fallback = useMemo(
|
||
() =>
|
||
({
|
||
metric,
|
||
line,
|
||
over_probability: estimate.overProbability,
|
||
under_probability: estimate.underProbability,
|
||
expected_count: estimate.expectedCount,
|
||
p5: Number((estimate.expectedCount * 0.55).toFixed(2)),
|
||
p50: Number((estimate.expectedCount * 1.05).toFixed(2)),
|
||
p95: Number((estimate.expectedCount * 1.65).toFixed(2)),
|
||
simulation_runs: 12000,
|
||
edge: null,
|
||
top_edge: false,
|
||
bookmaker_over_odds: bookmakerOdds,
|
||
implied_prob: 1 / bookmakerOdds,
|
||
}) as PlayerPropsResponse,
|
||
[metric, line, estimate, bookmakerOdds],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const signal = new AbortController();
|
||
|
||
const runAnalysis = async () => {
|
||
setLoading(true);
|
||
setErrorMessage('');
|
||
try {
|
||
const result = await calculatePlayerProps({
|
||
player_id: playerName,
|
||
player_name: playerName,
|
||
metric,
|
||
baseline_mean: baselineMean,
|
||
line,
|
||
match_minutes: matchMinutes,
|
||
team_attack_factor: 1.15,
|
||
opponent_defence_factor: 0.92,
|
||
weather_fatigue_factor: 1.02,
|
||
bookmaker_over_odds: bookmakerOdds,
|
||
simulations: 12000,
|
||
});
|
||
|
||
if (signal.signal.aborted) {
|
||
return;
|
||
}
|
||
setAnalysis(result);
|
||
} catch (error) {
|
||
if (signal.signal.aborted) {
|
||
return;
|
||
}
|
||
setErrorMessage(error instanceof Error ? error.message : '道具盤分析暫時無法使用');
|
||
setAnalysis(fallback);
|
||
} finally {
|
||
if (!signal.signal.aborted) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
runAnalysis();
|
||
|
||
return () => {
|
||
signal.abort();
|
||
};
|
||
}, [fallback, line, metric, baselineMean, bookmakerOdds, matchMinutes, playerName]);
|
||
|
||
const current = analysis ?? fallback;
|
||
const edgePercent = current.edge === null ? (current.over_probability - (current.implied_prob ?? 0)) * 100 : current.edge * 100;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">球員道具盤(球員道具盤)專業模組</h2>
|
||
<section className="panel-glow rounded-2xl border-dashed p-4">
|
||
<p className="text-sm leading-6 text-[#7a5b46]">
|
||
目前此頁是手動參數模擬器,不是即時球員道具盤報價。只有當球員出賽時間、先發、盤口與授權道具盤賠率接入後,才會標示為正式推薦。
|
||
</p>
|
||
</section>
|
||
|
||
<section className="panel-glow rounded-2xl p-4">
|
||
<h3 className="dot-matrix text-lg text-[#7d2a15]">道具盤參數</h3>
|
||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||
<label className="text-sm text-[#7a5b46]">
|
||
球員名稱
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
value={playerName}
|
||
onChange={(event) => setPlayerName(event.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<label className="text-sm text-[#7a5b46]">
|
||
對手球隊
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
value={opponentName}
|
||
onChange={(event) => setOpponentName(event.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<label className="text-sm text-[#7a5b46]">
|
||
道具類型
|
||
<select
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
value={metric}
|
||
onChange={(event) => setMetric(event.target.value as Metric)}
|
||
>
|
||
<option value="shots">射門</option>
|
||
<option value="shots_on_target">射正</option>
|
||
<option value="passes">傳球數</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label className="text-sm text-[#7a5b46]">
|
||
基準基線(平均值)
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
step={0.1}
|
||
value={baselineMean}
|
||
onChange={(event) => setBaselineMean(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
|
||
<label className="text-sm text-[#7a5b46]">
|
||
盤口 O/U 線
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
step={0.5}
|
||
value={line}
|
||
onChange={(event) => setLine(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
|
||
<label className="text-sm text-[#7a5b46]">
|
||
莊家 Over 賠率
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
step={0.01}
|
||
value={bookmakerOdds}
|
||
onChange={(event) => setBookmakerOdds(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid gap-4 lg:grid-cols-2">
|
||
<PropValueCard
|
||
playerName={playerName}
|
||
metricLabel={metricLabelMap[metric]}
|
||
line={current.line}
|
||
overProbability={current.over_probability}
|
||
impliedProb={current.implied_prob ?? 1 / bookmakerOdds}
|
||
edgePercent={Number(edgePercent.toFixed(1))}
|
||
topEdge={current.top_edge}
|
||
/>
|
||
<div className="grid gap-3">
|
||
<article className="panel-glow rounded-2xl p-4">
|
||
<h3 className="dot-matrix text-lg text-[#7d2a15]">道具盤輸出</h3>
|
||
<p className="mt-2 text-sm text-[#7a5b46]">預期數:{current.expected_count}</p>
|
||
<p className="mt-1 text-sm text-[#7a5b46]">低位分位(P5):{current.p5}</p>
|
||
<p className="mt-1 text-sm text-[#7a5b46]">中位數(P50):{current.p50}</p>
|
||
<p className="mt-1 text-sm text-[#7a5b46]">高位分位(P95):{current.p95}</p>
|
||
<p className="mt-1 text-xs text-[#6f4f3c]">樣本模擬:{current.simulation_runs.toLocaleString()} 次</p>
|
||
{loading ? <p className="mt-2 text-xs text-[#a16b4f]">更新道具盤模型中…</p> : null}
|
||
{errorMessage ? <p className="mt-2 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
|
||
</article>
|
||
<article className="panel-glow rounded-2xl p-4">
|
||
<h3 className="dot-matrix text-lg text-[#7d2a15]">模擬控制</h3>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
參與分鐘
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="range"
|
||
min={60}
|
||
max={120}
|
||
value={matchMinutes}
|
||
onChange={(event) => setMatchMinutes(Number(event.target.value))}
|
||
/>
|
||
<p className="text-xs text-[#7a5b46]">{matchMinutes} 分鐘</p>
|
||
</label>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
|
||
<PlayerMatchupRadar
|
||
playerName={playerName}
|
||
opponentName={opponentName}
|
||
metrics={radarMetric}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|