247 lines
8.5 KiB
TypeScript
247 lines
8.5 KiB
TypeScript
|
|
import { cn } from "~/lib/utils";
|
||
|
|
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||
|
|
import {
|
||
|
|
TreeColumns,
|
||
|
|
BracketTreePaginated,
|
||
|
|
BracketMatchSlot,
|
||
|
|
type BracketMatch,
|
||
|
|
type BracketOwnership,
|
||
|
|
} from "./BracketTreeView";
|
||
|
|
|
||
|
|
interface TabbedBracketLayoutProps {
|
||
|
|
rounds: string[];
|
||
|
|
matchesByRound: Map<string, BracketMatch[]>;
|
||
|
|
ownershipMap: Map<string, BracketOwnership>;
|
||
|
|
userParticipantIds: Set<string>;
|
||
|
|
phases: BracketPhase[];
|
||
|
|
scoringRoundIdx: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
const CARD_H = 112;
|
||
|
|
const CARD_GAP = 14;
|
||
|
|
|
||
|
|
function groupMatches(
|
||
|
|
matchesByRound: Map<string, BracketMatch[]>,
|
||
|
|
group: ConferenceGroup
|
||
|
|
): Map<string, BracketMatch[]> {
|
||
|
|
const out = new Map<string, BracketMatch[]>();
|
||
|
|
for (const [round, allowed] of Object.entries(group.roundMatchNumbers)) {
|
||
|
|
const filtered = (matchesByRound.get(round) ?? []).filter((m) =>
|
||
|
|
allowed.includes(m.matchNumber)
|
||
|
|
);
|
||
|
|
if (filtered.length > 0) out.set(round, filtered);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
||
|
|
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||
|
|
return max * (CARD_H + CARD_GAP);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Play-In Layout ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
interface PlayInColumnProps {
|
||
|
|
label: string;
|
||
|
|
description: string;
|
||
|
|
match: BracketMatch | undefined;
|
||
|
|
ownershipMap: Map<string, BracketOwnership>;
|
||
|
|
userParticipantIds: Set<string>;
|
||
|
|
}
|
||
|
|
|
||
|
|
function PlayInColumn({
|
||
|
|
label,
|
||
|
|
description,
|
||
|
|
match,
|
||
|
|
ownershipMap,
|
||
|
|
userParticipantIds,
|
||
|
|
}: PlayInColumnProps) {
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center mb-2">
|
||
|
|
{label}
|
||
|
|
</p>
|
||
|
|
{match ? (
|
||
|
|
<BracketMatchSlot
|
||
|
|
match={match}
|
||
|
|
slotHeight={CARD_H}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<div style={{ height: CARD_H }} />
|
||
|
|
)}
|
||
|
|
<p className="text-[10px] text-muted-foreground text-center mt-2">{description}</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
interface PlayInLayoutProps {
|
||
|
|
matchesByRound: Map<string, BracketMatch[]>;
|
||
|
|
ownershipMap: Map<string, BracketOwnership>;
|
||
|
|
userParticipantIds: Set<string>;
|
||
|
|
}
|
||
|
|
|
||
|
|
function PlayInLayout({ matchesByRound, ownershipMap, userParticipantIds }: PlayInLayoutProps) {
|
||
|
|
const pir1 = matchesByRound.get("Play-In Round 1") ?? [];
|
||
|
|
const pir2 = matchesByRound.get("Play-In Round 2") ?? [];
|
||
|
|
|
||
|
|
const conferences = [
|
||
|
|
{
|
||
|
|
name: "Eastern Conference",
|
||
|
|
match78: pir1.find((m) => m.matchNumber === 1),
|
||
|
|
match910: pir1.find((m) => m.matchNumber === 2),
|
||
|
|
matchFor8: pir2.find((m) => m.matchNumber === 1),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "Western Conference",
|
||
|
|
match78: pir1.find((m) => m.matchNumber === 3),
|
||
|
|
match910: pir1.find((m) => m.matchNumber === 4),
|
||
|
|
matchFor8: pir2.find((m) => m.matchNumber === 2),
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{conferences.map((conf) => (
|
||
|
|
<div key={conf.name}>
|
||
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
||
|
|
{conf.name}
|
||
|
|
</p>
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
|
|
<PlayInColumn
|
||
|
|
label="7 VS 8"
|
||
|
|
description="Winner → 7 seed · Loser plays again"
|
||
|
|
match={conf.match78}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
/>
|
||
|
|
<PlayInColumn
|
||
|
|
label="9 VS 10"
|
||
|
|
description="Winner plays again · Loser eliminated"
|
||
|
|
match={conf.match910}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
/>
|
||
|
|
<PlayInColumn
|
||
|
|
label="For 8 Seed"
|
||
|
|
description="Winner → 8 seed · Loser eliminated"
|
||
|
|
match={conf.matchFor8}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export function TabbedBracketLayout({
|
||
|
|
rounds,
|
||
|
|
matchesByRound,
|
||
|
|
ownershipMap,
|
||
|
|
userParticipantIds,
|
||
|
|
phases,
|
||
|
|
scoringRoundIdx,
|
||
|
|
}: TabbedBracketLayoutProps) {
|
||
|
|
return (
|
||
|
|
<div className="space-y-10">
|
||
|
|
{phases.map((phase) => {
|
||
|
|
const groupRounds = phase.groups
|
||
|
|
? rounds.filter((r) => phase.groups?.some((g) => g.roundMatchNumbers[r] !== undefined))
|
||
|
|
: [];
|
||
|
|
const sharedRounds = (phase.sharedRounds ?? []).filter((r) => rounds.includes(r));
|
||
|
|
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
||
|
|
|
||
|
|
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
||
|
|
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||
|
|
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||
|
|
|
||
|
|
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div key={phase.name}>
|
||
|
|
<p className={cn(
|
||
|
|
"text-sm font-semibold uppercase tracking-wider mb-4",
|
||
|
|
phases.length > 1 ? "text-muted-foreground" : "hidden"
|
||
|
|
)}>
|
||
|
|
{phase.name}
|
||
|
|
</p>
|
||
|
|
|
||
|
|
{/* Desktop */}
|
||
|
|
<div className="hidden md:block">
|
||
|
|
{phase.layout === "play-in" ? (
|
||
|
|
<PlayInLayout
|
||
|
|
matchesByRound={phaseMatchesByRound}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
/>
|
||
|
|
) : phase.groups ? (
|
||
|
|
<div className="space-y-8">
|
||
|
|
{phase.groups.map((group) => {
|
||
|
|
const gMatches = groupMatches(matchesByRound, group);
|
||
|
|
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
||
|
|
return (
|
||
|
|
<div key={group.name}>
|
||
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||
|
|
{group.name}
|
||
|
|
</p>
|
||
|
|
<TreeColumns
|
||
|
|
visibleRounds={gRounds}
|
||
|
|
matchesByRound={gMatches}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
bracketHeight={phaseHeight(gMatches, gRounds)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
{sharedRounds.length > 0 && (
|
||
|
|
<TreeColumns
|
||
|
|
visibleRounds={sharedRounds}
|
||
|
|
matchesByRound={sharedMatchesByRound}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<TreeColumns
|
||
|
|
visibleRounds={phaseRounds}
|
||
|
|
matchesByRound={phaseMatchesByRound}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Mobile */}
|
||
|
|
<div className="md:hidden">
|
||
|
|
{phase.layout === "play-in" ? (
|
||
|
|
<PlayInLayout
|
||
|
|
matchesByRound={phaseMatchesByRound}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<BracketTreePaginated
|
||
|
|
rounds={phaseRounds}
|
||
|
|
matchesByRound={phaseMatchesByRound}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|