202 lines
8.3 KiB
TypeScript
202 lines
8.3 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState, useCallback } from "react";
|
||
import { GroupTable, Match, Team, ThirdPlaceRow, teamName } from "@/lib/types";
|
||
import { ThirdAssignment, orderedRound } from "@/lib/bracket";
|
||
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
|
||
import { Dictionary } from "@/lib/i18n";
|
||
import { STADIUMS, MATCH_STADIUMS, MATCH_DATES } from "@/lib/stadiums";
|
||
import Flag from "./Flag";
|
||
|
||
interface TipState { x: number; y: number; text: string }
|
||
|
||
function Side({
|
||
s, prob, teams, locale, dict, onShowTip, onHideTip,
|
||
}: {
|
||
s: ResolvedSide; prob?: number | null; teams: Team[]; locale: string; dict: Dictionary;
|
||
onShowTip: (text: string, x: number, y: number) => void;
|
||
onHideTip: () => void;
|
||
}) {
|
||
const team = s.teamId ? teams.find((t) => t.id === s.teamId) : undefined;
|
||
const fix = s.teamId != null && !s.provisional;
|
||
const hasTip = !!s.tooltip;
|
||
|
||
return (
|
||
<div
|
||
className={`side ${s.isWinner ? "win" : ""} ${s.provisional ? "prov" : ""} ${fix ? "fix" : ""}`}
|
||
{...(hasTip ? {
|
||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => onShowTip(s.tooltip!, e.clientX, e.clientY),
|
||
onMouseMove: (e: React.MouseEvent<HTMLDivElement>) => onShowTip(s.tooltip!, e.clientX, e.clientY),
|
||
onMouseLeave: onHideTip,
|
||
} : {})}
|
||
>
|
||
<span className="nm">
|
||
{s.teamId ? (
|
||
<>
|
||
<Flag team={team} size={18} />
|
||
<span className="tn">{teamName(team, locale)}</span>
|
||
{s.provisional && <span className="prov-mark" title={dict.bracket.provisionalTooltip}>≈</span>}
|
||
</>
|
||
) : (
|
||
<span className="lbl">{s.label}</span>
|
||
)}
|
||
{prob != null && prob > 0 && (
|
||
<span className="prob">{Math.round(prob * 100)}%</span>
|
||
)}
|
||
</span>
|
||
<span className="sc">{s.score != null ? s.score : "–"}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function fmtMatchInfo(utcDate?: string, stadiumId?: string, locale: string = "de"): string {
|
||
if (!utcDate) return "";
|
||
const intlLocale = locale === "en" ? "en-US" : "de-DE";
|
||
const d = new Date(utcDate);
|
||
const dateStr = d.toLocaleDateString(intlLocale, { day: "numeric", month: "numeric" });
|
||
const timeStr = d.toLocaleTimeString(intlLocale, { hour: "2-digit", minute: "2-digit" });
|
||
const city = stadiumId ? STADIUMS[stadiumId]?.city : "";
|
||
const parts = [`${dateStr}`, `${timeStr}`];
|
||
if (city) parts.push(city);
|
||
return parts.join(" ");
|
||
}
|
||
|
||
function Tie({
|
||
tie, teams, isFinal, matchInfo, locale, dict, onShowTip, onHideTip,
|
||
}: {
|
||
tie: ResolvedTie; teams: Team[]; isFinal?: boolean;
|
||
matchInfo?: string;
|
||
locale: string; dict: Dictionary;
|
||
onShowTip: (text: string, x: number, y: number) => void;
|
||
onHideTip: () => void;
|
||
}) {
|
||
return (
|
||
<div className={`tie ${isFinal ? "final-tie" : ""}`}>
|
||
<div className="match-badge">{tie.matchNumber}</div>
|
||
<div className="tie-meta">
|
||
{matchInfo || dict.bracket.match(tie.matchNumber)}
|
||
</div>
|
||
<Side s={tie.home} prob={tie.prob?.home} teams={teams} locale={locale} dict={dict} onShowTip={onShowTip} onHideTip={onHideTip} />
|
||
<Side s={tie.away} prob={tie.prob?.away} teams={teams} locale={locale} dict={dict} onShowTip={onShowTip} onHideTip={onHideTip} />
|
||
{tie.homePenalty != null && tie.awayPenalty != null && (
|
||
<div className="tie-meta" style={{ padding: "2px 10px 4px", textAlign: "center" }}>
|
||
({tie.homePenalty}:{tie.awayPenalty} {dict.bracket.penaltyShootout})
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Bracket({
|
||
matches, teams, tables, thirds, assignment, annexResolved,
|
||
preResolved, dict, locale,
|
||
}: {
|
||
matches: Match[]; teams: Team[]; tables: GroupTable[];
|
||
thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean;
|
||
preResolved?: { r32: ResolvedTie[]; later: Record<number, ResolvedTie> };
|
||
dict: Dictionary; locale: string;
|
||
}) {
|
||
const { r32: r32Array, later } = preResolved ?? resolveBracket(
|
||
matches, teams, tables, thirds, assignment, annexResolved, undefined, dict,
|
||
);
|
||
|
||
const r32ByNum = new Map(r32Array.map((t) => [t.matchNumber, t]));
|
||
|
||
// Slot-Nummer → formatierte Anzeige (Datum/Uhrzeit aus MATCH_DATES, Stadt aus MATCH_STADIUMS)
|
||
const matchMeta = useMemo(() => {
|
||
const meta = new Map<number, string>();
|
||
for (let n = 73; n <= 104; n++) {
|
||
const utcDate = MATCH_DATES[n];
|
||
const stadiumId = MATCH_STADIUMS[n];
|
||
const info = fmtMatchInfo(utcDate, stadiumId, locale);
|
||
if (info) meta.set(n, info);
|
||
}
|
||
return meta;
|
||
}, [locale]);
|
||
|
||
const sfOrder = orderedRound([104]);
|
||
const qfOrder = orderedRound(sfOrder);
|
||
const r16Order = orderedRound(qfOrder);
|
||
const r32Order = orderedRound(r16Order);
|
||
|
||
const r32 = r32Order.map((n) => r32ByNum.get(n)).filter(Boolean) as ResolvedTie[];
|
||
const r16 = r16Order.map((n) => later[n]).filter(Boolean);
|
||
const qf = qfOrder.map((n) => later[n]).filter(Boolean);
|
||
const sf = sfOrder.map((n) => later[n]).filter(Boolean);
|
||
const fin = later[104];
|
||
const third = later[103];
|
||
|
||
const [tip, setTip] = useState<TipState | null>(null);
|
||
const showTip = useCallback((text: string, x: number, y: number) => setTip({ x, y, text }), []);
|
||
const hideTip = useCallback(() => setTip(null), []);
|
||
|
||
return (
|
||
<div>
|
||
<div className="bracket-banner">
|
||
<span className="k">{dict.bracket.annexHeader}</span>
|
||
<span className="v">
|
||
{annexResolved
|
||
? dict.bracket.annexResolved
|
||
: dict.bracket.annexPending}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="bracket-scroll">
|
||
<div className="bracket">
|
||
<div className="round">
|
||
<div className="round-label">{dict.round.r32}</div>
|
||
<div className="round-matches">
|
||
{r32.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} locale={locale} dict={dict} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
|
||
</div>
|
||
</div>
|
||
<div className="round">
|
||
<div className="round-label">{dict.round.r16}</div>
|
||
<div className="round-matches">
|
||
{r16.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} locale={locale} dict={dict} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
|
||
</div>
|
||
</div>
|
||
<div className="round">
|
||
<div className="round-label">{dict.round.qf}</div>
|
||
<div className="round-matches">
|
||
{qf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} locale={locale} dict={dict} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
|
||
</div>
|
||
</div>
|
||
<div className="round">
|
||
<div className="round-label">{dict.round.sf}</div>
|
||
<div className="round-matches">
|
||
{sf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} locale={locale} dict={dict} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
|
||
</div>
|
||
</div>
|
||
<div className="round">
|
||
<div className="round-label">{dict.round.final}</div>
|
||
<div className="round-matches">
|
||
{fin && <Tie tie={fin} teams={teams} isFinal locale={locale} dict={dict} matchInfo={matchMeta.get(fin.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />}
|
||
{third && (
|
||
<div style={{ marginTop: 20 }}>
|
||
<div className="round-label" style={{ marginBottom: 8 }}>{dict.round.thirdPlace}</div>
|
||
<Tie tie={third} teams={teams} locale={locale} dict={dict} matchInfo={matchMeta.get(third.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="legend">
|
||
<span><i style={{ background: "var(--turf)" }} />{dict.legend.winner}</span>
|
||
<span><i style={{ background: "var(--gold)" }} />{dict.legend.final}</span>
|
||
<span><i className="leg-fix" />{dict.legend.fixed}</span>
|
||
<span><i className="leg-prov" />{dict.legend.provisional}</span>
|
||
<span><i style={{ background: "var(--ink-faint)" }} />{dict.legend.placeholder}</span>
|
||
<span>{dict.legend.probExplanation}</span>
|
||
</div>
|
||
|
||
{tip && (
|
||
<div className="kobaum-tip" style={{ left: tip.x + 12, top: tip.y + 12 }}>
|
||
{tip.text}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|