Better bracket logic
This commit is contained in:
@@ -1,142 +1,139 @@
|
||||
// frontend/src/components/Bracket/BracketView.jsx
|
||||
|
||||
import { Check, Trophy } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { stringToColor } from '../../utils/helpers';
|
||||
|
||||
// --- EXACT COPY OF YOUR OLD MATCHCARD (Adapted data props) ---
|
||||
const MatchCard = ({ node, onClick }) => {
|
||||
// GHOST NODE LOGIC: If no display number, it's a structural/hidden node
|
||||
if (!node.display_number) return <div id={`node-${node.id}`} className="hidden" />;
|
||||
const MatchCard = ({ match, onClick }) => {
|
||||
const isFinished = match.status === "Finished";
|
||||
const badgeColor = match.time ? stringToColor(match.court) : null;
|
||||
|
||||
const match = node.match;
|
||||
const p1 = node.p1_team;
|
||||
const p2 = node.p2_team;
|
||||
let borderClass = 'border-zinc-300 dark:border-zinc-700';
|
||||
if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10';
|
||||
|
||||
// Old logic: "isPending" means we don't have two players yet
|
||||
const isPending = !p1 || !p2;
|
||||
const badgeColor = match?.court ? stringToColor(match.court.name) : null;
|
||||
const timeDisplay = match?.start_time || node.planned_start_time;
|
||||
const canInteract = match.hasTeams;
|
||||
|
||||
// Calculate wins for display
|
||||
const getWins = (teamId) => {
|
||||
if (!match?.sets) return 0;
|
||||
return match.sets.reduce((acc, s) => acc + (s.p1 > s.p2 && match.p1_team_id === teamId ? 1 : (s.p2 > s.p1 && match.p2_team_id === teamId ? 1 : 0)), 0);
|
||||
}
|
||||
|
||||
const p1Wins = p1 ? getWins(p1.id) : 0;
|
||||
const p2Wins = p2 ? getWins(p2.id) : 0;
|
||||
const winnerId = match?.winner_team_id;
|
||||
const cursorClass = canInteract
|
||||
? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
|
||||
: 'cursor-default opacity-100';
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`node-${node.id}`}
|
||||
onClick={() => !isPending && onClick(node)}
|
||||
className={`w-64 bg-white dark:bg-zinc-900 rounded-xl border-2 ${winnerId ? 'border-orange-500 ring-4 ring-orange-500/10' : 'border-zinc-300 dark:border-zinc-800'} shadow-sm ${!isPending ? 'cursor-pointer hover:-translate-y-1 transition duration-200 group' : 'opacity-80 cursor-default'} overflow-hidden transition-all`}
|
||||
id={`match-${match.id}`}
|
||||
onClick={() => canInteract && onClick(match)}
|
||||
className={`w-64 bg-white dark:bg-zinc-900 rounded-lg border ${borderClass} shadow-sm transition-all duration-200 relative z-10 flex flex-col ${cursorClass}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-zinc-50 dark:bg-zinc-950/50 px-3 py-2 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900/50 px-3 py-2 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800 rounded-t-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] font-black text-zinc-500 dark:text-zinc-500 uppercase"># {node.display_number}</span>
|
||||
{match?.court && <span className="text-[9px] font-black text-white px-1.5 py-0.5 rounded uppercase" style={{ background: badgeColor }}>{match.court.name}</span>}
|
||||
<span className="font-mono text-[10px] font-bold text-zinc-400"># {match.number}</span>
|
||||
{match.time && <span className="text-[9px] font-black text-white px-1.5 py-0.5 rounded-sm uppercase" style={{ background: badgeColor }}>{match.court}</span>}
|
||||
</div>
|
||||
{winnerId ? <Check className="text-orange-500" size={14} strokeWidth={4} /> : <span className="text-[10px] font-black text-zinc-800 dark:text-zinc-300 font-mono">{timeDisplay ? new Date(timeDisplay).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'TBD'}</span>}
|
||||
{isFinished ? <Check className="text-orange-500" size={14} strokeWidth={3} /> : <span className="text-[10px] font-bold text-zinc-500 font-mono">{match.time || 'TBD'}</span>}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 space-y-1.5">
|
||||
{/* P1 */}
|
||||
<div className={`flex justify-between items-center ${winnerId === p1?.id ? 'text-orange-600 dark:text-orange-500 font-black' : 'text-zinc-900 dark:text-zinc-400 font-bold'}`}>
|
||||
<span className="truncate text-xs uppercase tracking-tight font-bold">{p1 ? p1.name : (node.source_p1_type ? 'TBD' : 'Bye')}</span>
|
||||
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded text-[10px] font-black">{p1Wins}</span>
|
||||
</div>
|
||||
{/* P2 */}
|
||||
<div className={`flex justify-between items-center ${winnerId === p2?.id ? 'text-orange-600 dark:text-orange-500 font-black' : 'text-zinc-900 dark:text-zinc-400 font-bold'}`}>
|
||||
<span className="truncate text-xs uppercase tracking-tight font-bold">{p2 ? p2.name : (node.source_p2_type ? 'TBD' : 'Bye')}</span>
|
||||
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded text-[10px] font-black">{p2Wins}</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
{[{ n: match.p1, s: match.p1_sets, win: match.winner === match.p1, real: match.p1_is_real },
|
||||
{ n: match.p2, s: match.p2_sets, win: match.winner === match.p2, real: match.p2_is_real }].map((p, i) => (
|
||||
<div key={i} className={`flex justify-between items-center ${p.win ? 'text-zinc-900 dark:text-white font-black' : p.real ? 'text-zinc-600 dark:text-zinc-300' : 'text-zinc-400 italic'}`}>
|
||||
<span className="truncate text-xs uppercase tracking-tight">{p.n}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${p.win ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{p.s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function BracketView({ nodes, onMatchClick }) {
|
||||
export default function BracketView({ matches, onMatchClick }) {
|
||||
const containerRef = useRef(null);
|
||||
const svgRef = useRef(null);
|
||||
const [lines, setLines] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// --- EXACT COPY OF YOUR SVG LOGIC ---
|
||||
if (!containerRef.current || !svgRef.current) return;
|
||||
const container = containerRef.current.getBoundingClientRect();
|
||||
const svg = svgRef.current;
|
||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
const draw = () => {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current.getBoundingClientRect();
|
||||
const newLines = [];
|
||||
matches.forEach(m => {
|
||||
if (!m.winner_next_match_id) return;
|
||||
const sEl = document.getElementById(`match-${m.id}`);
|
||||
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
|
||||
if (sEl && eEl) {
|
||||
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
|
||||
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
|
||||
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
|
||||
const c1 = sx + (ex - sx) / 2;
|
||||
newLines.push(<path key={`${m.id}-${m.winner_next_match_id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 fill-none stroke-[1.5px]" />);
|
||||
}
|
||||
});
|
||||
setLines(newLines);
|
||||
};
|
||||
const t = setTimeout(draw, 100);
|
||||
window.addEventListener('resize', draw);
|
||||
return () => { clearTimeout(t); window.removeEventListener('resize', draw); };
|
||||
}, [matches]);
|
||||
|
||||
nodes.forEach(node => {
|
||||
// Logic: Find DOM elements by ID
|
||||
const sEl = document.getElementById(`node-${node.id}`);
|
||||
const eEl = document.getElementById(`node-${node.winner_next_node_id}`);
|
||||
|
||||
// Only draw if both exist and are visible
|
||||
if (sEl && eEl && sEl.offsetParent !== null && eEl.offsetParent !== null) {
|
||||
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
|
||||
// Calculate connection points
|
||||
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
|
||||
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
|
||||
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const c1 = sx + (ex - sx) / 2; // Control point X
|
||||
path.setAttribute("d", `M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`);
|
||||
path.setAttribute("class", "stroke-zinc-300 dark:stroke-zinc-800 fill-none stroke-[2px] opacity-40");
|
||||
svg.appendChild(path);
|
||||
}
|
||||
});
|
||||
}, [nodes]); // Re-run when nodes change
|
||||
|
||||
const renderTree = (list, align = 'justify-center') => {
|
||||
const renderRound = (list) => {
|
||||
const rounds = {};
|
||||
list.forEach(n => { if (!rounds[n.round_number]) rounds[n.round_number] = []; rounds[n.round_number].push(n); });
|
||||
list.forEach(m => (rounds[m.round] = rounds[m.round] || []).push(m));
|
||||
|
||||
return Object.keys(rounds)
|
||||
.sort((a, b) => a - b)
|
||||
.filter(r => rounds[r].some(n => n.display_number)) // Filter empty rounds
|
||||
.map(r => (
|
||||
<div key={r} className={`flex flex-col ${align} gap-12 min-w-[280px] z-10`}>
|
||||
{rounds[r].sort((a, b) => a.display_number - b.display_number).map(n => (
|
||||
<MatchCard key={n.id} node={n} onClick={onMatchClick} />
|
||||
))}
|
||||
const roundKeys = Object.keys(rounds).sort((a, b) => Number(a) - Number(b));
|
||||
let prevRoundMap = new Map();
|
||||
|
||||
return roundKeys.map((r, rIdx) => {
|
||||
let matchesInRound = rounds[r];
|
||||
if (rIdx === 0) {
|
||||
matchesInRound.sort((a, b) => a.number - b.number);
|
||||
} else {
|
||||
matchesInRound.sort((a, b) => {
|
||||
const getSourceAvg = (match) => {
|
||||
const sources = list.filter(x => x.next_win === match.id);
|
||||
if (sources.length === 0) return 9999;
|
||||
const indices = sources.map(s => prevRoundMap.get(s.id)).filter(i => i !== undefined);
|
||||
if (indices.length === 0) return 9999;
|
||||
return indices.reduce((sum, val) => sum + val, 0) / indices.length;
|
||||
};
|
||||
return getSourceAvg(a) - getSourceAvg(b);
|
||||
});
|
||||
}
|
||||
matchesInRound.forEach((m, idx) => prevRoundMap.set(m.id, idx));
|
||||
|
||||
return (
|
||||
<div key={r} className="flex flex-col gap-10 z-10 w-64 shrink-0 justify-around">
|
||||
{matchesInRound.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const wb = nodes.filter(n => n.bracket_type === 'Winners');
|
||||
const lb = nodes.filter(n => n.bracket_type === 'Losers');
|
||||
const finals = nodes.filter(n => n.bracket_type === 'Finals');
|
||||
const wb = matches.filter(m => m.bracket === 'Winner');
|
||||
const lb = matches.filter(m => m.bracket === 'Loser');
|
||||
const finals = matches.filter(m => m.bracket === 'Finals');
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-auto p-8 bg-[radial-gradient(#d1d5db_1px,transparent_1px)] dark:bg-[radial-gradient(#18181b_1px,transparent_1px)] [background-size:20px_20px]">
|
||||
<div ref={containerRef} className="relative min-w-max p-4 flex gap-24">
|
||||
<svg ref={svgRef} className="absolute inset-0 w-full h-full pointer-events-none z-0" />
|
||||
<div className="w-full h-full overflow-auto bg-[#f8f9fa] dark:bg-zinc-950 bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#27272a_1px,transparent_1px)] [background-size:24px_24px]">
|
||||
{/* UPDATED: items-center ensures Finals (right col) are centered vertically
|
||||
relative to the Winners/Losers block (left col).
|
||||
*/}
|
||||
<div ref={containerRef} className="relative min-w-max min-h-full p-12 flex gap-20 items-center">
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0">
|
||||
{lines}
|
||||
</svg>
|
||||
|
||||
<div className="flex flex-col gap-24">
|
||||
<div className="relative flex gap-16">
|
||||
<div className="absolute -top-10 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 dark:text-zinc-600">Winners Bracket</div>
|
||||
{renderTree(wb)}
|
||||
<div className="relative">
|
||||
<div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Winners Bracket</div>
|
||||
<div className="flex gap-20">{renderRound(wb)}</div>
|
||||
</div>
|
||||
|
||||
{lb.length > 0 && (
|
||||
<div className="relative flex flex-col gap-12 pt-16 border-t border-zinc-300 dark:border-zinc-800 w-full">
|
||||
<div className="absolute top-6 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 dark:text-zinc-600">Losers Bracket</div>
|
||||
<div className="flex gap-16 justify-start">{renderTree(lb, 'justify-start')}</div>
|
||||
{matches.some(m => m.bracket === 'Loser') && (
|
||||
<div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800">
|
||||
<div className="absolute top-0 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Losers Bracket</div>
|
||||
<div className="flex gap-20 mt-8">{renderRound(lb)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{finals.length > 0 && (
|
||||
<div className="flex flex-col justify-center items-center gap-4 relative z-10 min-w-[280px]">
|
||||
<div className="absolute top-1/2 -translate-y-[calc(50%+140px)] flex items-center gap-2 bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border border-orange-200 dark:border-orange-800 shadow-sm">
|
||||
<Trophy size={14} /> Championship
|
||||
</div>
|
||||
{finals.map(n => <MatchCard key={n.id} node={n} onClick={onMatchClick} />)}
|
||||
{matches.some(m => m.bracket === 'Finals') && (
|
||||
<div className="flex flex-col justify-center gap-6">
|
||||
<div className="text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm mx-auto">Championship</div>
|
||||
{finals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user