Added zoom to Bracket view
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
// frontend/src/components/Bracket/BracketView.tsx
|
// frontend/src/components/Bracket/BracketView.tsx
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
import { type MatchData } from '../../types';
|
import { type MatchData } from '../../types';
|
||||||
import Podium from "../Tournament/Podium";
|
import Podium from "../Tournament/Podium";
|
||||||
import MatchCard from "./MatchCard";
|
import MatchCard from "./MatchCard";
|
||||||
@@ -13,10 +14,19 @@ interface BracketViewProps {
|
|||||||
export default function BracketView({ matches, onMatchClick }: BracketViewProps) {
|
export default function BracketView({ matches, onMatchClick }: BracketViewProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [lines, setLines] = useState<React.ReactElement[]>([]);
|
const [lines, setLines] = useState<React.ReactElement[]>([]);
|
||||||
|
const [zoom, setZoom] = useState<number>(1);
|
||||||
|
const [contentSize, setContentSize] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
// Measure the unscaled bracket size to fix scrollbars later
|
||||||
|
setContentSize({
|
||||||
|
width: containerRef.current.scrollWidth,
|
||||||
|
height: containerRef.current.scrollHeight
|
||||||
|
});
|
||||||
|
|
||||||
const container = containerRef.current.getBoundingClientRect();
|
const container = containerRef.current.getBoundingClientRect();
|
||||||
const newLines: React.ReactElement[] = [];
|
const newLines: React.ReactElement[] = [];
|
||||||
|
|
||||||
@@ -26,9 +36,13 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
|
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
|
||||||
if (sEl && eEl) {
|
if (sEl && eEl) {
|
||||||
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
|
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 sx = (r1.right - container.left) / zoom;
|
||||||
|
const sy = (r1.top + r1.height / 2 - container.top) / zoom;
|
||||||
|
const ex = (r2.left - container.left) / zoom;
|
||||||
|
const ey = (r2.top + r2.height / 2 - container.top) / zoom;
|
||||||
const c1 = sx + (ex - sx) / 2;
|
const c1 = sx + (ex - sx) / 2;
|
||||||
|
|
||||||
newLines.push(
|
newLines.push(
|
||||||
<path key={`w-${m.id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-400/70 dark:stroke-zinc-700/70 print:stroke-zinc-400! fill-none stroke-[2px]" />
|
<path key={`w-${m.id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-400/70 dark:stroke-zinc-700/70 print:stroke-zinc-400! fill-none stroke-[2px]" />
|
||||||
);
|
);
|
||||||
@@ -42,9 +56,13 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
const eEl = document.getElementById(`match-${targetMatch.id}`);
|
const eEl = document.getElementById(`match-${targetMatch.id}`);
|
||||||
if (sEl && eEl) {
|
if (sEl && eEl) {
|
||||||
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
|
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 sx = (r1.right - container.left) / zoom;
|
||||||
|
const sy = (r1.top + r1.height / 2 - container.top) / zoom;
|
||||||
|
const ex = (r2.left - container.left) / zoom;
|
||||||
|
const ey = (r2.top + r2.height / 2 - container.top) / zoom;
|
||||||
const c1 = sx + (ex - sx) / 2;
|
const c1 = sx + (ex - sx) / 2;
|
||||||
|
|
||||||
newLines.push(
|
newLines.push(
|
||||||
<path key={`l-${m.id}`} strokeDasharray="6 6" d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 print:stroke-zinc-400! fill-none stroke-[1.5px]" />
|
<path key={`l-${m.id}`} strokeDasharray="6 6" d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 print:stroke-zinc-400! fill-none stroke-[1.5px]" />
|
||||||
);
|
);
|
||||||
@@ -55,7 +73,7 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
setLines(newLines);
|
setLines(newLines);
|
||||||
};
|
};
|
||||||
|
|
||||||
const t = setTimeout(draw, 100);
|
const t = setTimeout(draw, 50);
|
||||||
window.addEventListener('resize', draw);
|
window.addEventListener('resize', draw);
|
||||||
|
|
||||||
const handlePrint = () => { draw(); setTimeout(draw, 100); };
|
const handlePrint = () => { draw(); setTimeout(draw, 100); };
|
||||||
@@ -71,7 +89,7 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
window.removeEventListener('beforeprint', handlePrint);
|
window.removeEventListener('beforeprint', handlePrint);
|
||||||
window.removeEventListener('afterprint', draw);
|
window.removeEventListener('afterprint', draw);
|
||||||
};
|
};
|
||||||
}, [matches]);
|
}, [matches, zoom]);
|
||||||
|
|
||||||
const renderRound = (list: MatchData[]) => {
|
const renderRound = (list: MatchData[]) => {
|
||||||
const rounds: Record<number, MatchData[]> = {};
|
const rounds: Record<number, MatchData[]> = {};
|
||||||
@@ -109,7 +127,29 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="transition-colors w-full h-full overflow-auto print:overflow-visible print:h-auto print:w-auto bg-zinc-50 dark:bg-zinc-950 bg-[radial-gradient(var(--color-zinc-300)_1px,transparent_1px)] dark:bg-[radial-gradient(var(--color-zinc-800)_1px,transparent_1px)] bg-size-[20px_20px] print:bg-white! print:bg-none!">
|
<div className="relative w-full h-full flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-950 print:bg-white!">
|
||||||
|
|
||||||
|
{/* FLOATING ZOOM CONTROLS (Moved to top-6 to completely avoid theme button) */}
|
||||||
|
<div className="absolute top-6 right-6 flex flex-col gap-3 z-50 print:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom(z => Math.min(1, z + 0.1))}
|
||||||
|
disabled={zoom >= 1}
|
||||||
|
className="p-3 bg-white dark:bg-zinc-800 rounded-full shadow-lg shadow-black/5 border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-300 disabled:opacity-30 hover:text-orange-500 hover:border-orange-500 transition active:scale-90"
|
||||||
|
title="Zoom In"
|
||||||
|
>
|
||||||
|
<ZoomIn size={20} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom(z => Math.max(0.3, z - 0.1))}
|
||||||
|
disabled={zoom <= 0.3}
|
||||||
|
className="p-3 bg-white dark:bg-zinc-800 rounded-full shadow-lg shadow-black/5 border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-300 disabled:opacity-30 hover:text-orange-500 hover:border-orange-500 transition active:scale-90"
|
||||||
|
title="Zoom Out"
|
||||||
|
>
|
||||||
|
<ZoomOut size={20} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto bg-[radial-gradient(var(--color-zinc-300)_1px,transparent_1px)] dark:bg-[radial-gradient(var(--color-zinc-800)_1px,transparent_1px)] bg-size-[20px_20px] print:bg-none!">
|
||||||
<style>
|
<style>
|
||||||
{`@media print {
|
{`@media print {
|
||||||
@page { size: landscape; margin: 0.5cm; }
|
@page { size: landscape; margin: 0.5cm; }
|
||||||
@@ -117,7 +157,19 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
}`}
|
}`}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div ref={containerRef} className="relative min-w-max min-h-full p-12 flex gap-20 items-center">
|
{/* SIZER WRAPPER (Dynamically scales width/height to fix the scrollbar ghost space) */}
|
||||||
|
<div style={{
|
||||||
|
width: contentSize.width ? `${contentSize.width * zoom}px` : 'max-content',
|
||||||
|
height: contentSize.height ? `${contentSize.height * zoom}px` : 'max-content'
|
||||||
|
}}>
|
||||||
|
{/* SCALING WRAPPER */}
|
||||||
|
<div
|
||||||
|
className="origin-top-left print:transform-none! w-max h-max"
|
||||||
|
style={{ transform: `scale(${zoom})` }}
|
||||||
|
>
|
||||||
|
{/* Added pt-16 md:pt-20 to ensure absolute titles aren't clipped
|
||||||
|
*/}
|
||||||
|
<div ref={containerRef} className="relative min-w-max min-h-full pt-16 md:pt-20 px-6 md:px-12 pb-40 md:pb-32 flex gap-12 md:gap-20 items-center">
|
||||||
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0 print:overflow-visible">
|
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0 print:overflow-visible">
|
||||||
{lines}
|
{lines}
|
||||||
</svg>
|
</svg>
|
||||||
@@ -150,5 +202,8 @@ export default function BracketView({ matches, onMatchClick }: BracketViewProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user