Files
wm-projekt/app/components/Bracket.tsx
2026-06-27 15:48:48 -05:00

206 lines
8.2 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 { useMemo, useState, useCallback } from "react";
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { ThirdAssignment, orderedRound } from "@/lib/bracket";
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
import { STADIUMS, MATCH_STADIUMS } from "@/lib/stadiums";
import Flag from "./Flag";
interface TipState { x: number; y: number; text: string }
function Side({
s, prob, teams, onShowTip, onHideTip,
}: {
s: ResolvedSide; prob?: number | null; teams: Team[];
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">{s.label}</span>
{s.provisional && <span className="prov-mark" title="vorläufig Gruppe/Zuordnung noch nicht fix"></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): string {
const city = stadiumId ? STADIUMS[stadiumId]?.city : "";
if (!utcDate) return city; // Stadt auch ohne Datum anzeigen
const d = new Date(utcDate);
const dateStr = d.toLocaleDateString("de-DE", { day: "numeric", month: "numeric" });
const timeStr = d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const parts = [`${dateStr}`, `${timeStr}`];
if (city) parts.push(city);
return parts.join(" · ");
}
function Tie({
tie, teams, isFinal, matchInfo, onShowTip, onHideTip,
}: {
tie: ResolvedTie; teams: Team[]; isFinal?: boolean;
matchInfo?: string;
onShowTip: (text: string, x: number, y: number) => void;
onHideTip: () => void;
}) {
return (
<div className={`tie ${isFinal ? "final-tie" : ""}`}>
<div className="tie-head">Spiel {tie.matchNumber}</div>
{matchInfo && <div className="tie-meta">{matchInfo}</div>}
<Side s={tie.home} prob={tie.prob?.home} teams={teams} onShowTip={onShowTip} onHideTip={onHideTip} />
<Side s={tie.away} prob={tie.prob?.away} teams={teams} onShowTip={onShowTip} onHideTip={onHideTip} />
</div>
);
}
export default function Bracket({
matches, teams, tables, thirds, assignment, annexResolved,
preResolved,
}: {
matches: Match[]; teams: Team[]; tables: GroupTable[];
thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean;
preResolved?: { r32: ResolvedTie[]; later: Record<number, ResolvedTie> };
}) {
const { r32: r32Array, later } = preResolved ?? resolveBracket(
matches, teams, tables, thirds, assignment, annexResolved,
);
const r32ByNum = new Map(r32Array.map((t) => [t.matchNumber, t]));
// Baue slotNumber → formatted info (Stadt aus MATCH_STADIUMS, Datum/Uhrzeit aus Feed-Match)
const feedByNum = useMemo(() => {
const m = new Map<number, Match>();
for (const fm of matches) {
if (fm.group == null) m.set(fm.matchNumber, fm);
}
return m;
}, [matches]);
const matchMeta = useMemo(() => {
const meta = new Map<number, string>();
for (let n = 73; n <= 104; n++) {
const stadiumId = MATCH_STADIUMS[n];
const feedMatch = feedByNum.get(n);
const info = fmtMatchInfo(feedMatch?.utcDate, stadiumId);
if (info) meta.set(n, info);
if (feedMatch?.matchNumber === 0 || !feedMatch) {
console.log("[KO-SLOT-DATE]",
"slot:", n,
"| match.matchNumber:", feedMatch?.matchNumber ?? "—",
"| match.utcDate:", feedMatch?.utcDate ?? "—",
"| stadt:", STADIUMS[MATCH_STADIUMS[n]]?.city ?? "—");
}
}
return meta;
}, [feedByNum]);
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">Annex-C-Zuordnung der Drittplatzierten:</span>
<span className="v">
{annexResolved
? "aufgelöst — die acht Dritten sind den Gruppensiegern fest zugeteilt"
: "noch offen — sobald die 8 besten Dritten feststehen, verbindet sich der Baum automatisch"}
</span>
</div>
<div className="bracket-scroll">
<div className="bracket">
<div className="round">
<div className="round-label">Letzte 32</div>
<div className="round-matches">
{r32.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Achtelfinale</div>
<div className="round-matches">
{r16.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Viertelfinale</div>
<div className="round-matches">
{qf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Halbfinale</div>
<div className="round-matches">
{sf.map((t) => <Tie key={t.matchNumber} tie={t} teams={teams} matchInfo={matchMeta.get(t.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />)}
</div>
</div>
<div className="round">
<div className="round-label">Finale</div>
<div className="round-matches">
{fin && <Tie tie={fin} teams={teams} isFinal matchInfo={matchMeta.get(fin.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />}
{third && (
<div style={{ marginTop: 20 }}>
<div className="round-label" style={{ marginBottom: 8 }}>Spiel um Platz 3</div>
<Tie tie={third} teams={teams} matchInfo={matchMeta.get(third.matchNumber)} onShowTip={showTip} onHideTip={hideTip} />
</div>
)}
</div>
</div>
</div>
</div>
<div className="legend">
<span><i style={{ background: "var(--turf)" }} />Sieger / weiter</span>
<span><i style={{ background: "var(--gold)" }} />Finale</span>
<span><i className="leg-fix" />fix qualifiziert</span>
<span><i className="leg-prov" />vorläufig (, nach aktueller Tabelle)</span>
<span><i style={{ background: "var(--ink-faint)" }} />Platzhalter offen</span>
<span>%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)</span>
</div>
{tip && (
<div className="kobaum-tip" style={{ left: tip.x + 12, top: tip.y + 12 }}>
{tip.text}
</div>
)}
</div>
);
}