15d955e65c
- Finished matches show the STARTING XIs on the pitch (red markers with the minute on everyone subbed off) with 'Subbed on' / 'Subbed off' lists below, minutes and ratings included; live matches keep the current-XI view that swaps substitutes in - Title-race chart gets a sort toggle, defaulting to 'Biggest movers' (the teams whose odds changed most at the latest update — what's relevant now) with 'Title favorites' as the alternative - Entering what-if on the bracket now pre-fills the model's most likely tournament: group slots seeded from win-group/advance odds (best- thirds assigned to their allowed slots via backtracking), every pairing picked by the model — a complete overview to edit by tapping; Reset returns to the projection - Team fixtures drop the US 'vs/@' convention: always 'vs' plus an H/A badge with a tooltip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
110 lines
4.2 KiB
TypeScript
110 lines
4.2 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts';
|
|
import { teamFlag } from '@/lib/teams';
|
|
import { useFormat, useT } from '@/lib/i18n';
|
|
import type { OddsHistoryPoint } from '@/lib/types';
|
|
|
|
const PALETTE = [
|
|
'var(--app-accent)',
|
|
'var(--app-gold)',
|
|
'var(--app-info)',
|
|
'var(--app-loss)',
|
|
'var(--app-success)',
|
|
'var(--app-warning)',
|
|
];
|
|
|
|
/** Championship odds over time — one line per top contender, a point per result
|
|
* that moved the model. */
|
|
type ChartSort = 'movers' | 'leaders';
|
|
|
|
export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
|
|
const { locale } = useFormat();
|
|
const t = useT();
|
|
// Default to the teams whose odds moved most at the latest update — what
|
|
// changed just now is more interesting than a static favorites list.
|
|
const [sort, setSort] = useState<ChartSort>('movers');
|
|
const { data, teams } = useMemo(() => {
|
|
const last = history[history.length - 1];
|
|
const prev = history[history.length - 2];
|
|
const latest = last?.top ?? [];
|
|
let picked = latest.slice(0, 6);
|
|
if (sort === 'movers' && prev) {
|
|
const before = new Map(prev.top.map((x) => [x.team, x.champion]));
|
|
const delta = (x: { team: string; champion: number }) => Math.abs(x.champion - (before.get(x.team) ?? x.champion));
|
|
picked = [...latest].sort((a, b) => delta(b) - delta(a) || b.champion - a.champion).slice(0, 6);
|
|
}
|
|
const teams = picked.map((x) => x.team);
|
|
const tag = locale === 'de' ? 'de-DE' : 'en-GB';
|
|
const data = history.map((h, i) => {
|
|
const day = new Date(h.t).toLocaleDateString(tag, { day: 'numeric', month: 'short' });
|
|
const row: Record<string, number | string> = { i: i + 1, label: h.label, day };
|
|
for (const t of h.top) if (teams.includes(t.team)) row[t.team] = Math.round(t.champion * 1000) / 10;
|
|
return row;
|
|
});
|
|
return { data, teams };
|
|
}, [history, locale, sort]);
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<div className="mb-2 flex justify-end">
|
|
<div className="flex overflow-hidden rounded-lg border border-line text-xs font-medium">
|
|
{([['movers', t.predict.titleRace.sortMovers], ['leaders', t.predict.titleRace.sortLeaders]] as const).map(([v, label]) => (
|
|
<button
|
|
key={v}
|
|
type="button"
|
|
onClick={() => setSort(v)}
|
|
className={v === sort ? 'bg-accent-glow px-3 py-1 font-bold text-accent' : 'px-3 py-1 text-muted hover:bg-elevated hover:text-ink'}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="h-72 w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: -8 }}>
|
|
<CartesianGrid stroke="var(--app-line)" strokeDasharray="3 3" vertical={false} />
|
|
<XAxis
|
|
dataKey="i"
|
|
tick={{ fill: 'var(--app-faint)', fontSize: 11 }}
|
|
stroke="var(--app-line)"
|
|
tickFormatter={(i) => String(data[Number(i) - 1]?.day ?? '')}
|
|
minTickGap={28}
|
|
/>
|
|
<YAxis
|
|
tick={{ fill: 'var(--app-faint)', fontSize: 11 }}
|
|
stroke="var(--app-line)"
|
|
unit="%"
|
|
width={42}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: 'var(--app-panel)',
|
|
border: '1px solid var(--app-line)',
|
|
borderRadius: 10,
|
|
fontSize: 12,
|
|
color: 'var(--app-ink)',
|
|
}}
|
|
labelFormatter={(i) => data[Number(i) - 1]?.label ?? ''}
|
|
formatter={(v: number, name: string) => [`${v}%`, `${teamFlag(name)} ${name}`]}
|
|
/>
|
|
<Legend formatter={(name: string) => `${teamFlag(name)} ${name}`} wrapperStyle={{ fontSize: 12 }} />
|
|
{teams.map((t, idx) => (
|
|
<Line
|
|
key={t}
|
|
type="monotone"
|
|
dataKey={t}
|
|
stroke={PALETTE[idx % PALETTE.length]}
|
|
strokeWidth={2}
|
|
dot={{ r: 2 }}
|
|
connectNulls
|
|
isAnimationActive={false}
|
|
/>
|
|
))}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|