diff --git a/src/containers/LeagueStage/Matrix.tsx b/src/containers/LeagueStage/Matrix.tsx index 83b5d6d3..9b71fd05 100644 --- a/src/containers/LeagueStage/Matrix.tsx +++ b/src/containers/LeagueStage/Matrix.tsx @@ -146,7 +146,7 @@ interface Props { allTeams: readonly Team[]; numMatchdays: number; pairings: (readonly [Team, Team])[]; - schedule: readonly (readonly (readonly [Team, Team])[])[]; + schedule: readonly (readonly (readonly (readonly [Team, Team])[])[])[]; potSize: number; noCellAnimation?: boolean; } @@ -172,8 +172,10 @@ function Matrix({ const scheduleMap = useMemo(() => { const o: Record<`${string}:${string}`, number> = {}; for (const [mdIndex, md] of schedule.entries()) { - for (const m of md) { - o[`${m[0].id}:${m[1].id}`] = mdIndex; + for (const day of md) { + for (const m of day) { + o[`${m[0].id}:${m[1].id}`] = mdIndex; + } } } return o; diff --git a/src/containers/LeagueStage/Schedule.tsx b/src/containers/LeagueStage/Schedule.tsx index d8c535a5..77e138ba 100644 --- a/src/containers/LeagueStage/Schedule.tsx +++ b/src/containers/LeagueStage/Schedule.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components'; import ContentWithFlag from '#ui/table/ContentWithFlag'; import { type Country } from '#model/types'; +import type Tournament from '#model/Tournament'; const Root = styled.div` width: 100%; @@ -13,6 +14,7 @@ const CalendarContainer = styled.div` display: grid; gap: 16px; grid-template-columns: repeat(4, 1fr); + align-items: self-start; width: fit-content; @container (max-width: 1000px) { @@ -25,7 +27,7 @@ const CalendarContainer = styled.div` `; const MatchdayRoot = styled.div` - border: 1px double rgb(128 128 128); + border: 1px solid rgb(192 192 192); font-size: 12px; `; @@ -34,6 +36,18 @@ const MatchdayHeader = styled.div` justify-content: center; align-items: center; height: 20px; + background-color: black; + color: white; + font-weight: 600; +`; + +const DayHeader = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 20px; + border-top: 1px solid rgb(192 192 192); + background-color: rgb(240 240 240); `; const MatchPair = styled.div` @@ -64,10 +78,11 @@ interface Team { } interface Props { - schedule: readonly (readonly (readonly [Team, Team])[])[]; + tournament: Tournament; + schedule: readonly (readonly (readonly (readonly [Team, Team])[])[])[]; } -function Schedule({ schedule }: Props) { +function Schedule({ tournament, schedule }: Props) { useLayoutEffect(() => { if (schedule.some(md => md.length > 0)) { const elements = document.getElementsByClassName( @@ -89,20 +104,35 @@ function Schedule({ schedule }: Props) { {schedule.map((md, i) => ( MATCHDAY {i + 1} - {md.map(m => ( - - - - {m[0].name} - - - - - - - {m[1].name} - - - + {md.map((day, dayIndex) => ( + <> + + {tournament === 'cl' + ? dayIndex === 2 + ? 'Thursday' + : dayIndex === 1 || md.length === 1 + ? 'Wednesday' + : 'Tuesday' + : dayIndex === 1 || md.length === 1 + ? 'Night' + : 'Evening'} + + {day.map(m => ( + + + + {m[0].name} + + + - + + + {m[1].name} + + + + ))} + ))} ))} diff --git a/src/containers/LeagueStage/index.tsx b/src/containers/LeagueStage/index.tsx index 5a72e707..2b072986 100644 --- a/src/containers/LeagueStage/index.tsx +++ b/src/containers/LeagueStage/index.tsx @@ -1,8 +1,8 @@ import { memo, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { shuffle } from 'lodash'; import usePopup from '#store/usePopup'; +import type Tournament from '#model/Tournament'; import type Team from '#model/team/GsTeam'; import generatePairings from '#engine/dfs/ls/generatePairings/index'; import generateSchedule from '#engine/dfs/ls/generateSchedule/index'; @@ -31,11 +31,18 @@ const MatrixWrapper = styled.div` `; interface Props { + tournament: Tournament; season: number; pots: readonly (readonly Team[])[]; + tvPairings: readonly (readonly [Team, Team])[]; } -function LeagueStage({ season, pots: initialPots }: Props) { +function LeagueStage({ + tournament, + season, + pots: initialPots, + tvPairings, +}: Props) { const numMatchdays = initialPots.length * 2; const numMatches = useMemo(() => { @@ -48,7 +55,7 @@ function LeagueStage({ season, pots: initialPots }: Props) { const [isMatchdayMode, setIsMatchdayMode] = useState(false); const [pairings, setPairings] = useState<(readonly [Team, Team])[]>([]); - const [schedule, setSchedule] = useState<(readonly [Team, Team])[][]>( + const [schedule, setSchedule] = useState<(readonly [Team, Team])[][][]>( Array.from( { length: numMatchdays, @@ -64,7 +71,6 @@ function LeagueStage({ season, pots: initialPots }: Props) { initialPots.map(pot => pot.map(team => ({ ...team, - id: `${team.country}|${team.name}`, })), ), [initialPots], @@ -101,13 +107,14 @@ function LeagueStage({ season, pots: initialPots }: Props) { if (isFixturesDone) { const formSchedule = async () => { const it = await generateSchedule({ + tournament, matchdaySize, + tvPairings, allGames: pairings, currentSchedule: schedule, signal: abortSignal, }); - const newSchedule = it.solutionSchedule.map(md => shuffle(md)); - setSchedule(newSchedule); + setSchedule(it.solutionSchedule); setIsMatchdayMode(true); }; @@ -137,7 +144,10 @@ function LeagueStage({ season, pots: initialPots }: Props) { {isMatchdayMode ? ( - + ) : ( { const numGames = allGames.length; @@ -205,9 +211,16 @@ export default ({ arr[matchdayIndex].push(m); } arr[solution.pickedMatchday].push(allGames[solution.matchIndex]); + const matchdays = splitMatchdaysIntoDays({ + matchdays: arr, + tournament, + matchdaySize, + teams, + tvPairings, + }); return { pickedMatchday, - matchdays: arr, + matchdays, }; } } diff --git a/src/engine/dfs/ls/generateSchedule/getFirstSuitableMatchday.wrapper.ts b/src/engine/dfs/ls/generateSchedule/getFirstSuitableMatchday.wrapper.ts index 1a578d60..bc11c038 100644 --- a/src/engine/dfs/ls/generateSchedule/getFirstSuitableMatchday.wrapper.ts +++ b/src/engine/dfs/ls/generateSchedule/getFirstSuitableMatchday.wrapper.ts @@ -2,6 +2,7 @@ import { remove, sample, shuffle } from 'lodash'; import raceWorkers from '#utils/raceWorkers'; import { type UefaCountry } from '#model/types'; +import type Tournament from '#model/Tournament'; import { type Func } from './getFirstSuitableMatchday.worker'; import teamsSharingStadium from './teamsSharingStadium'; @@ -14,12 +15,16 @@ interface Team { } export default ({ + tournament, teams, matchdaySize, + tvPairings, allGames, signal, }: { + tournament: Tournament; teams: readonly Team[]; + tvPairings: readonly (readonly [number, number])[]; matchdaySize: number; allGames: readonly (readonly [number, number])[]; signal?: AbortSignal; @@ -79,7 +84,9 @@ export default ({ } return { + tournament, teams, + tvPairings, matchdaySize, allGames: orderedGames, }; diff --git a/src/engine/dfs/ls/generateSchedule/index.ts b/src/engine/dfs/ls/generateSchedule/index.ts index d6c27108..b68a3d9f 100644 --- a/src/engine/dfs/ls/generateSchedule/index.ts +++ b/src/engine/dfs/ls/generateSchedule/index.ts @@ -1,6 +1,7 @@ import { keyBy, uniq } from 'lodash'; import { type UefaCountry } from '#model/types'; +import type Tournament from '#model/Tournament'; import getFirstSuitableMatchday from './getFirstSuitableMatchday.wrapper'; @@ -11,13 +12,17 @@ interface Team { } export default async function generateSchedule({ + tournament, matchdaySize, + tvPairings, allGames: allGamesWithIds, signal, }: { + tournament: Tournament; matchdaySize: number; + tvPairings: readonly (readonly [T, T])[]; allGames: readonly (readonly [T, T])[]; - currentSchedule: readonly (readonly (readonly [T, T])[])[]; + currentSchedule: readonly (readonly (readonly (readonly [T, T])[])[])[]; signal?: AbortSignal; }) { const allNonUniqueTeams = allGamesWithIds.flat(); @@ -40,21 +45,29 @@ export default async function generateSchedule({ // m => Math.max(...m), // ]); + const tvPairingsNumbers = tvPairings.map( + p => [indexByTeamId.get(p[0].id)!, indexByTeamId.get(p[1].id)!] as const, + ); + const result = await getFirstSuitableMatchday({ + tournament, teams: allTeams, + tvPairings: tvPairingsNumbers, matchdaySize, allGames: allGamesUnordered, signal, }); const solutionSchedule = result.matchdays.map(md => - md.map(([h, a]) => { - const ht = teamById[allTeamIds[h]]; - const at = teamById[allTeamIds[a]]; - return allGamesWithIds.find( - mi => mi[0].id === ht.id && mi[1].id === at.id, - )!; - }), + md.map(day => + day.map(([h, a]) => { + const ht = teamById[allTeamIds[h]]; + const at = teamById[allTeamIds[a]]; + return allGamesWithIds.find( + mi => mi[0].id === ht.id && mi[1].id === at.id, + )!; + }), + ), ); return { diff --git a/src/engine/dfs/ls/generateSchedule/splitMatchdaysIntoDays.ts b/src/engine/dfs/ls/generateSchedule/splitMatchdaysIntoDays.ts new file mode 100644 index 00000000..5704d63b --- /dev/null +++ b/src/engine/dfs/ls/generateSchedule/splitMatchdaysIntoDays.ts @@ -0,0 +1,107 @@ +import { countBy, minBy, orderBy, pull, shuffle, sumBy } from 'lodash'; + +import { type UefaCountry } from '#model/types'; +import type Tournament from '#model/Tournament'; + +interface Team { + readonly name: string; + readonly country: UefaCountry; +} + +export default ({ + matchdays, + tournament, + matchdaySize, + teams, + tvPairings, +}: { + matchdays: readonly (readonly [number, number])[][]; + tournament: Tournament; + matchdaySize: number; + teams: readonly Team[]; + tvPairings: readonly (readonly [number, number])[]; +}) => { + const numMatchdays = matchdays.length; + + const allPairedTeams = new Set(tvPairings.flat()); + + const numTeamsByCountry = countBy(teams, team => team.country); + const countriesWithMultipleTeams = Object.keys(numTeamsByCountry).filter( + c => numTeamsByCountry[c] > 1, + ) as UefaCountry[]; + const countriesWithMultipleTeamsSet = new Set(countriesWithMultipleTeams); + + const newMatchdays: (readonly [number, number])[][][] = []; + for (const [matchdayIndex, md] of matchdays.entries()) { + const days = Array.from( + { + // TODO: remove this hardcode + length: + tournament === 'cl' && matchdayIndex === 0 + ? 3 + : matchdayIndex === numMatchdays - 1 + ? 1 + : 2, + }, + () => [] as (readonly [number, number])[], + ); + const numGamesPerDay = matchdaySize / days.length; + + // TODO: build a graph of paired teams + + for (const pair of tvPairings) { + for (const t of shuffle(pair)) { + const pairedT = t === pair[0] ? pair[1] : pair[0]; + const team = teams[t]; + const teamCountry = team.country; + const match = md.find(m => m[0] === t || m[1] === t); + if (!match) { + // Already allocated + continue; + } + const nonFullDays = days.filter(day => day.length < numGamesPerDay); + const minDay = minBy(shuffle(nonFullDays), day => + sumBy(day, m => + m[0] === pairedT || m[1] === pairedT + ? 1000000 + : (teams[m[0]].country === teamCountry ? 1 : 0) + + (teams[m[1]].country === teamCountry ? 1 : 0), + ), + )!; + minDay.push(match); + pull(md, match); + } + } + + const remainingTeams = md.flat(); + const orderedRemainingTeams = orderBy( + shuffle(remainingTeams), + t => countriesWithMultipleTeamsSet.has(teams[t].country), + 'desc', + ); + for (const t of orderedRemainingTeams) { + const team = teams[t]; + const teamCountry = team.country; + const match = md.find(p => p[0] === t || p[1] === t); + if (!match) { + // Already allocated + continue; + } + const nonFullDays = days.filter(day => day.length < numGamesPerDay); + const minDay = minBy(shuffle(nonFullDays), day => + sumBy( + day, + m => + (teams[m[0]].country === teamCountry ? 1 : 0) + + (teams[m[1]].country === teamCountry ? 1 : 0), + ), + )!; + minDay.push(match); + pull(md, match); + } + + newMatchdays.push(shuffle(days.map(day => shuffle(day)))); + } + + return newMatchdays; +}; diff --git a/src/model/getPairings.ts b/src/model/getPairings.ts index 93df7c70..4cdee433 100644 --- a/src/model/getPairings.ts +++ b/src/model/getPairings.ts @@ -5,7 +5,7 @@ export default async (season: number, tournament: Tournament) => { try { const pairings = await import( /* webpackChunkName: "pairings/[request]" */ - `../data/${tournament}/gs/${season}/pairings.txt` + `../data/${tournament}/${season < 2024 ? 'gs' : 'ls'}/${season}/pairings.txt` ); return (pairings.default as string) .trim() diff --git a/src/pages/cl/ls/index.tsx b/src/pages/cl/ls/index.tsx index 3a316e21..6f70f77d 100644 --- a/src/pages/cl/ls/index.tsx +++ b/src/pages/cl/ls/index.tsx @@ -6,7 +6,12 @@ import LeagueStage from '#containers/LeagueStage/index'; type Props = React.ComponentProps; function CLLS(props: Props) { - return ; + return ( + + ); } export default memo(CLLS); diff --git a/src/pages/el/ls/index.tsx b/src/pages/el/ls/index.tsx index 87bfc9b1..4d546e78 100644 --- a/src/pages/el/ls/index.tsx +++ b/src/pages/el/ls/index.tsx @@ -6,7 +6,12 @@ import LeagueStage from '#containers/LeagueStage/index'; type Props = React.ComponentProps; function ELLS(props: Props) { - return ; + return ( + + ); } export default memo(ELLS); diff --git a/src/routes/Pages/getPotsFromBert.ts b/src/routes/Pages/getPotsFromBert.ts index 9ea85fa4..84ba6aca 100644 --- a/src/routes/Pages/getPotsFromBert.ts +++ b/src/routes/Pages/getPotsFromBert.ts @@ -19,7 +19,25 @@ async function getPotsFromBert( getPairings(season, tournament), ]); - return stage === 'ko' ? parseKo(data) : parseGS(data, pairings); + const parsedPots = stage === 'ko' ? parseKo(data) : parseGS(data, pairings); + + const flatTeams = parsedPots.flat(); + const parsedPairings = pairings.map(([a, b]) => { + const firstTeam = flatTeams.find(t => t.name === a); + if (!firstTeam) { + throw new Error(`Team not found: ${a}`); + } + const secondTeam = flatTeams.find(t => t.name === b); + if (!secondTeam) { + throw new Error(`Team not found: ${b}`); + } + return [firstTeam, secondTeam] as const; + }); + + return { + pots: parsedPots, + pairings: parsedPairings, + }; } export default memoize(getPotsFromBert, (...args) => args.join(':')); diff --git a/src/routes/Pages/getWcPots.ts b/src/routes/Pages/getWcPots.ts index 69b09518..cef7fa9d 100644 --- a/src/routes/Pages/getWcPots.ts +++ b/src/routes/Pages/getWcPots.ts @@ -10,7 +10,10 @@ async function getWcPots(season: number) { .trim() .split('\n\n') .map(line => line.trim().split('\n')); - return parseWc(ths, rest); // TODO: only works with 'default' right now + const parsedPots = parseWc(ths, rest); // TODO: only works with 'default' right now + return { + pots: parsedPots, + }; } export default memoize(getWcPots); diff --git a/src/routes/Pages/index.tsx b/src/routes/Pages/index.tsx index 8a29f26e..f8b04c8b 100644 --- a/src/routes/Pages/index.tsx +++ b/src/routes/Pages/index.tsx @@ -43,6 +43,7 @@ interface Props { interface State { Page: React.ComponentType | null; pots: readonly (readonly Team[])[] | null; + pairings?: readonly (readonly [Team, Team])[]; // tournament: Tournament, // stage: Stage, season: number; // for error handling (so that we know the previous season) @@ -52,7 +53,7 @@ function Pages({ drawId, tournament, stage, season, onSeasonChange }: Props) { const params = useParams(); const [, setPopup] = usePopup(); - const [{ Page, pots }, setState] = useState(initialState); + const [{ Page, pots, pairings }, setState] = useState(initialState); const fetchData = async () => { setPopup({ @@ -67,7 +68,8 @@ function Pages({ drawId, tournament, stage, season, onSeasonChange }: Props) { const newPage = await getPage(tournament, stage); - const newPots = await potsPromise; + const data = await potsPromise; + const { pots: newPots } = data; if (!isFirefox) { const teamsWithFlags = [ @@ -83,6 +85,7 @@ function Pages({ drawId, tournament, stage, season, onSeasonChange }: Props) { setState({ Page: newPage, pots: newPots, + pairings: 'pairings' in data ? data.pairings : undefined, // tournament, // stage, season, @@ -130,6 +133,7 @@ function Pages({ drawId, tournament, stage, season, onSeasonChange }: Props) { stage={params.stage} season={season} pots={pots} + tvPairings={pairings} isFirstPotShortDraw={isUefaClubTournament && season >= 2021} /> )