@opndev/tennis-match-center 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Changes +5 -0
- package/LICENSE +235 -0
- package/README.md +218 -0
- package/lib/auto-planner.mjs +1297 -0
- package/lib/bracket-autofill.mjs +174 -0
- package/lib/bracket-single-elimination.mjs +496 -0
- package/lib/busy-button.mjs +58 -0
- package/lib/components/bracket-board.mjs +586 -0
- package/lib/components/planning-board.mjs +1179 -0
- package/lib/components/roster-board.mjs +273 -0
- package/lib/components/roster-team.mjs +92 -0
- package/lib/components/schedule-board.mjs +1193 -0
- package/lib/court-bands.mjs +185 -0
- package/lib/court-runs.mjs +76 -0
- package/lib/day-rows.mjs +116 -0
- package/lib/division-hues.mjs +52 -0
- package/lib/five-team-planner.mjs +21 -0
- package/lib/flags.mjs +60 -0
- package/lib/four-team-planner.mjs +216 -0
- package/lib/group-schedule.mjs +175 -0
- package/lib/index.mjs +9 -0
- package/lib/knockout-draw.mjs +209 -0
- package/lib/knockout-planner.mjs +573 -0
- package/lib/knockout-waves.mjs +156 -0
- package/lib/lighting.mjs +50 -0
- package/lib/match-time.mjs +13 -0
- package/lib/occupancy.mjs +74 -0
- package/lib/paired-court-planner.mjs +236 -0
- package/lib/plan-placement.mjs +219 -0
- package/lib/plan.mjs +133 -0
- package/lib/planning-day.mjs +177 -0
- package/lib/roster-seeding.mjs +65 -0
- package/lib/run-queue-planner.mjs +603 -0
- package/lib/sessions.mjs +48 -0
- package/lib/three-team-planner.mjs +122 -0
- package/lib/webcomponents.mjs +25 -0
- package/opndev-tennis-match-center-0.0.1.tgz +0 -0
- package/package.json +39 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildUnits as buildPairedUnits,
|
|
7
|
+
placeLayout,
|
|
8
|
+
unitLayout,
|
|
9
|
+
unitSizes,
|
|
10
|
+
} from './paired-court-planner.mjs'
|
|
11
|
+
|
|
12
|
+
export { placeLayout }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Plans 4-team groups.
|
|
16
|
+
*
|
|
17
|
+
* A 4-team group does not take a court to itself. It plays its round
|
|
18
|
+
* robin as three rounds of two matches, a round takes both courts at
|
|
19
|
+
* once, and groups take turns: every group's first round, then every
|
|
20
|
+
* group's second, then its third.
|
|
21
|
+
*
|
|
22
|
+
* 1: A v B C v D two groups, six slots
|
|
23
|
+
* 2: A v B C v D three groups, nine slots
|
|
24
|
+
* 1: A v C B v D
|
|
25
|
+
* 2: A v C B v D no slot idle, and a group waits out the
|
|
26
|
+
* 1: A v D B v C others between its own rounds - which is
|
|
27
|
+
* 2: A v D B v C what the bundling is for
|
|
28
|
+
*
|
|
29
|
+
* The bundling and layout mechanics themselves live in
|
|
30
|
+
* paired-court-planner.mjs, shared with three-team-planner.mjs (its
|
|
31
|
+
* own round robin needs only one court a round, not two, but the same
|
|
32
|
+
* "groups take turns" bundling gives it the same rest). This module
|
|
33
|
+
* owns only what is genuinely 4-team's own: its template, and the
|
|
34
|
+
* public names callers already depend on.
|
|
35
|
+
*
|
|
36
|
+
* Groups are pooled across divisions on purpose: a division with a
|
|
37
|
+
* single 4-team group has nobody of its own to share with.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {Object} params
|
|
42
|
+
* @param {Array<Object>} params.groups
|
|
43
|
+
* {id, competitionId, divisionId, divisionName, divisionGender, groupName, teamIds, poolSize}
|
|
44
|
+
* @param {Array<Object>} params.courts in the order they should be used
|
|
45
|
+
* @param {Array<Object>} params.slots the day's slots, with start/end minutes
|
|
46
|
+
* @param {string} params.date
|
|
47
|
+
* @param {Object} params.occupancy shared with the other planner
|
|
48
|
+
* @param {?number} params.darkFrom minutes past midnight
|
|
49
|
+
* @param {boolean} params.dense pack into fewer pairs when courts are scarce
|
|
50
|
+
* @returns {{blocks: Array<Object>, unplaced: Array<Object>}}
|
|
51
|
+
*/
|
|
52
|
+
export function planFourTeamGroups({
|
|
53
|
+
groups,
|
|
54
|
+
courts,
|
|
55
|
+
slots,
|
|
56
|
+
date,
|
|
57
|
+
occupancy,
|
|
58
|
+
darkFrom = null,
|
|
59
|
+
dense = false,
|
|
60
|
+
}) {
|
|
61
|
+
const blocks = []
|
|
62
|
+
const unplaced = []
|
|
63
|
+
|
|
64
|
+
for (const unit of buildUnits(groups, { dense })) {
|
|
65
|
+
const placed = placeUnit({ unit, courts, slots, date, occupancy, darkFrom, blocks })
|
|
66
|
+
|
|
67
|
+
if (!placed) {
|
|
68
|
+
unplaced.push(...unit)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { blocks, unplaced }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Splits the groups into units sharing a pair.
|
|
77
|
+
*
|
|
78
|
+
* What decides the split is parity, not greed: a group left over on
|
|
79
|
+
* its own has nobody to share with, so an odd count takes exactly one
|
|
80
|
+
* unit of three and pairs off the rest.
|
|
81
|
+
*/
|
|
82
|
+
export function buildUnits(groups, { dense = false } = {}) {
|
|
83
|
+
return buildPairedUnits(groups, { dense })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The first pair of adjacent courts that can host the whole unit, and
|
|
88
|
+
* the earliest start on it. See placeLayout - this just computes the
|
|
89
|
+
* unit's layout and looks its groups up by id.
|
|
90
|
+
*/
|
|
91
|
+
function placeUnit({ unit, courts, slots, date, occupancy, darkFrom, blocks }) {
|
|
92
|
+
const layout = fourTeamUnitLayout(unit.map(group => ({
|
|
93
|
+
id: group.id,
|
|
94
|
+
teamIds: group.teamIds,
|
|
95
|
+
groupSize: group.poolSize,
|
|
96
|
+
})))
|
|
97
|
+
|
|
98
|
+
return placeLayout({
|
|
99
|
+
layout,
|
|
100
|
+
lookup: groupId => unit.find(entry => entry.id === groupId),
|
|
101
|
+
courts,
|
|
102
|
+
slots,
|
|
103
|
+
date,
|
|
104
|
+
occupancy,
|
|
105
|
+
darkFrom,
|
|
106
|
+
blocks,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A 4-team group's round robin: three rounds of two matches, a round
|
|
112
|
+
* taking both courts of the pair at once.
|
|
113
|
+
*/
|
|
114
|
+
export const FOUR_TEAM_TEMPLATE = [
|
|
115
|
+
{ slot: 1, day: 1, pairs: [[1, 2], [3, 4]] },
|
|
116
|
+
{ slot: 2, day: 1, pairs: [[1, 3], [2, 4]] },
|
|
117
|
+
{ slot: 3, day: 1, pairs: [[1, 4], [2, 3]] },
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The template with every seat above `teams` left vacant.
|
|
122
|
+
*
|
|
123
|
+
* Three teams in a division drawn for four is this same group with one
|
|
124
|
+
* bye, not a three-team round robin. It keeps its turn on the pair and
|
|
125
|
+
* its rest still comes from waiting that turn out; the vacant seat has
|
|
126
|
+
* no match, so the partner court simply stands empty while it plays.
|
|
127
|
+
*
|
|
128
|
+
* @param {number} teams
|
|
129
|
+
*/
|
|
130
|
+
export function fourTeamTemplate(teams) {
|
|
131
|
+
if (teams >= 4) {
|
|
132
|
+
return FOUR_TEAM_TEMPLATE
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return FOUR_TEAM_TEMPLATE
|
|
136
|
+
.map(entry => ({
|
|
137
|
+
...entry,
|
|
138
|
+
pairs: entry.pairs.filter(([home, visitor]) => home <= teams && visitor <= teams),
|
|
139
|
+
}))
|
|
140
|
+
.filter(entry => entry.pairs.length > 0)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A group's matches in play order.
|
|
145
|
+
*
|
|
146
|
+
* A slot holds two pairs playing at once on two courts, so the flat
|
|
147
|
+
* index - not the template slot number - is what a planned cell
|
|
148
|
+
* records as its match_slot, and what Scheduling reads back to know
|
|
149
|
+
* which pair belongs in the cell.
|
|
150
|
+
*
|
|
151
|
+
* @param {{teamIds: number[]}} group
|
|
152
|
+
*/
|
|
153
|
+
export function fourTeamMatches(group) {
|
|
154
|
+
const matches = []
|
|
155
|
+
const seenInDay = new Map()
|
|
156
|
+
|
|
157
|
+
for (const entry of fourTeamTemplate(group.teamIds.length)) {
|
|
158
|
+
const position = (seenInDay.get(entry.day) ?? 0) + 1
|
|
159
|
+
seenInDay.set(entry.day, position)
|
|
160
|
+
|
|
161
|
+
entry.pairs.forEach((pair, pairIndex) => {
|
|
162
|
+
matches.push({
|
|
163
|
+
index: matches.length,
|
|
164
|
+
slot: entry.slot,
|
|
165
|
+
runSlot: entry.runSlot ?? position,
|
|
166
|
+
day: entry.day,
|
|
167
|
+
pairIndex,
|
|
168
|
+
pair,
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return matches
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* How a set of 4-team groups is split into units sharing a court pair.
|
|
178
|
+
*
|
|
179
|
+
* See paired-court-planner.mjs's unitSizes - this is a thin, shape-
|
|
180
|
+
* named entry point onto that shared math.
|
|
181
|
+
*
|
|
182
|
+
* @param {number} count
|
|
183
|
+
* @param {{dense?: boolean}} options
|
|
184
|
+
* @returns {number[]} unit sizes, largest first
|
|
185
|
+
*/
|
|
186
|
+
export function fourTeamUnitSizes(count, { dense = false } = {}) {
|
|
187
|
+
return unitSizes(count, { dense })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Lays out a set of 4-team groups sharing one pair of courts.
|
|
192
|
+
*
|
|
193
|
+
* A 4-team group plays its round robin as three rounds of two matches,
|
|
194
|
+
* and a round takes both courts at once. Groups take turns: every
|
|
195
|
+
* group plays its first round, then every group its second, then its
|
|
196
|
+
* third.
|
|
197
|
+
*
|
|
198
|
+
* Nothing sits idle, and a group waits out as many slots between its
|
|
199
|
+
* own rounds as there are other groups in the unit. That is what the
|
|
200
|
+
* bundling is for - two groups alone on a pair cannot fill the gap
|
|
201
|
+
* their own rest requires.
|
|
202
|
+
*
|
|
203
|
+
* Two groups, six slots Three groups, nine slots
|
|
204
|
+
* 1: A v B C v D 1: A v B C v D
|
|
205
|
+
* 2: A v B C v D 2: A v B C v D
|
|
206
|
+
* 1: A v C B v D 3: A v B C v D
|
|
207
|
+
* 2: A v C B v D 1: A v C B v D
|
|
208
|
+
* 1: A v D B v C ...
|
|
209
|
+
* 2: A v D B v C
|
|
210
|
+
*
|
|
211
|
+
* @param {Array<{id: number, teamIds: number[]}>} groups
|
|
212
|
+
* @returns {Array<Array<{groupId: number, index: number}>>} slot by court
|
|
213
|
+
*/
|
|
214
|
+
export function fourTeamUnitLayout(groups) {
|
|
215
|
+
return unitLayout(groups, { template: FOUR_TEAM_TEMPLATE, matchesFor: fourTeamMatches })
|
|
216
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { fourTeamTemplate } from './four-team-planner.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The fixed group-stage match templates - "Group Stage Scheduling" in
|
|
9
|
+
* spec.md. Each entry is one "slot": a set of matches that share no
|
|
10
|
+
* team and so can run at the same real hour. Different slots for the
|
|
11
|
+
* same group always share at least one team with a neighbouring slot,
|
|
12
|
+
* so they must run at strictly later real hours - see
|
|
13
|
+
* scheduleGroupMatches's readyAtHourIndex.
|
|
14
|
+
*
|
|
15
|
+
* Pairs are 1-based positions into a group's team_ids (seed order),
|
|
16
|
+
* matching spec.md's tables directly.
|
|
17
|
+
*
|
|
18
|
+
* The 4-team template is played on two courts at once (spec.md: "6
|
|
19
|
+
* matches per group, 1 day, 2 courts"), with two or three groups
|
|
20
|
+
* rotating through the same pair, one group's whole round at a time.
|
|
21
|
+
*
|
|
22
|
+
* A group that ends up with only 4 real teams in a division otherwise
|
|
23
|
+
* configured for 5 uses that same 4-team template. It cannot play
|
|
24
|
+
* three matches a day on a court of its own - a round robin of four
|
|
25
|
+
* has six pairs, and after any two with no shared team every
|
|
26
|
+
* remaining pair reuses one - so it is bundled with the other short
|
|
27
|
+
* groups, and its rest comes from waiting its turn on the pair.
|
|
28
|
+
*/
|
|
29
|
+
export { fourTeamUnitSizes, fourTeamUnitLayout } from './four-team-planner.mjs'
|
|
30
|
+
|
|
31
|
+
export const GROUP_TEMPLATES = {
|
|
32
|
+
// Team Cup: one court per group, its three encounters in a cycle.
|
|
33
|
+
// Every pair of encounters in a group of three shares a team, so
|
|
34
|
+
// there is no ordering that rests everyone - but an encounter is
|
|
35
|
+
// itself three matches, so a team following one with another has
|
|
36
|
+
// had a long stretch off court either way.
|
|
37
|
+
3: [
|
|
38
|
+
{ slot: 1, day: 1, pairs: [[1, 2]] },
|
|
39
|
+
{ slot: 2, day: 1, pairs: [[2, 3]] },
|
|
40
|
+
{ slot: 3, day: 1, pairs: [[3, 1]] },
|
|
41
|
+
],
|
|
42
|
+
5: [
|
|
43
|
+
{ slot: 1, day: 1, pairs: [[1, 2]] },
|
|
44
|
+
{ slot: 2, day: 1, pairs: [[3, 4]] },
|
|
45
|
+
{ slot: 3, day: 1, pairs: [[1, 5]] },
|
|
46
|
+
{ slot: 4, day: 1, pairs: [[2, 3]] },
|
|
47
|
+
{ slot: 5, day: 1, pairs: [[4, 5]] },
|
|
48
|
+
{ slot: 6, day: 2, pairs: [[1, 3]] },
|
|
49
|
+
{ slot: 7, day: 2, pairs: [[2, 5]] },
|
|
50
|
+
{ slot: 8, day: 2, pairs: [[1, 4]] },
|
|
51
|
+
{ slot: 9, day: 2, pairs: [[3, 5]] },
|
|
52
|
+
{ slot: 10, day: 2, pairs: [[2, 4]] },
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which template a group uses - its real team count picks it, not the
|
|
59
|
+
* pool size its division is configured for.
|
|
60
|
+
*
|
|
61
|
+
* @param {{teamIds: number[]}} group
|
|
62
|
+
*/
|
|
63
|
+
function templateFor(group) {
|
|
64
|
+
const teams = group.teamIds.length
|
|
65
|
+
const size = group.groupSize ?? teams
|
|
66
|
+
|
|
67
|
+
// Four seats, however many of them are filled, is the 4-team
|
|
68
|
+
// planner's shape and it owns the bye that goes with a vacant one.
|
|
69
|
+
if (teams === 4 || (teams === 3 && size >= 4)) {
|
|
70
|
+
return fourTeamTemplate(teams)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return GROUP_TEMPLATES[teams] ?? null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A group's template flattened into individual matches, in play order.
|
|
78
|
+
* A 4-team slot holds two pairs playing at once on two courts, so the
|
|
79
|
+
* flat index - not the template slot number - is what identifies one
|
|
80
|
+
* match of a group.
|
|
81
|
+
*
|
|
82
|
+
* @param {{teamIds: number[], groupSize: 4|5}} group
|
|
83
|
+
*/
|
|
84
|
+
export function templateMatches(group) {
|
|
85
|
+
const template = templateFor(group)
|
|
86
|
+
|
|
87
|
+
if (!template) {
|
|
88
|
+
return []
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const matches = []
|
|
92
|
+
const seenInDay = new Map()
|
|
93
|
+
|
|
94
|
+
for (const entry of template) {
|
|
95
|
+
// Where in the day's court run this match sits. Usually that is
|
|
96
|
+
// just its order within the day, but a template may leave a slot
|
|
97
|
+
// empty to keep a team off court two matches running.
|
|
98
|
+
const position = (seenInDay.get(entry.day) ?? 0) + 1
|
|
99
|
+
seenInDay.set(entry.day, position)
|
|
100
|
+
|
|
101
|
+
entry.pairs.forEach((pair, pairIndex) => {
|
|
102
|
+
matches.push({
|
|
103
|
+
index: matches.length,
|
|
104
|
+
slot: entry.slot,
|
|
105
|
+
runSlot: entry.runSlot ?? position,
|
|
106
|
+
day: entry.day,
|
|
107
|
+
pairIndex,
|
|
108
|
+
pair,
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return matches
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The court runs a group needs: one run per day per parallel court.
|
|
118
|
+
* A 5-team group takes one court for five consecutive matches on each
|
|
119
|
+
* of two days; a 4-team group takes two courts for three each on one
|
|
120
|
+
* day. Planning reserves capacity in exactly these units, so this is
|
|
121
|
+
* what decides which cells a group is booked into.
|
|
122
|
+
*
|
|
123
|
+
* @returns {Array<{day: number, pairIndex: number, indices: number[]}>}
|
|
124
|
+
*/
|
|
125
|
+
export function groupRuns(group) {
|
|
126
|
+
const runs = new Map()
|
|
127
|
+
|
|
128
|
+
for (const match of templateMatches(group)) {
|
|
129
|
+
const key = `${match.day}:${match.pairIndex}`
|
|
130
|
+
|
|
131
|
+
if (!runs.has(key)) {
|
|
132
|
+
runs.set(key, { day: match.day, pairIndex: match.pairIndex, indices: [], positions: [] })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const run = runs.get(key)
|
|
136
|
+
|
|
137
|
+
run.indices.push(match.index)
|
|
138
|
+
|
|
139
|
+
// positions is the run laid out slot by slot, so a template that
|
|
140
|
+
// skips a slot leaves a hole here rather than closing the gap up.
|
|
141
|
+
run.positions[match.runSlot - 1] = match.index
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const run of runs.values()) {
|
|
145
|
+
for (let i = 0; i < run.positions.length; i++) {
|
|
146
|
+
if (run.positions[i] === undefined) {
|
|
147
|
+
run.positions[i] = null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return [...runs.values()]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* How many matches a group's template actually has, using its real
|
|
157
|
+
* team count (see GROUP_TEMPLATES) rather than GAMES_PER_GROUP's
|
|
158
|
+
* configured-pool-size assumption - so a scheduling legend's "N/M
|
|
159
|
+
* scheduled" for an uneven last group compares against what it can
|
|
160
|
+
* really play (6 for an actual 4-team group), not the bigger template
|
|
161
|
+
* it was nominally configured for.
|
|
162
|
+
*
|
|
163
|
+
* @param {{teamIds: number[], groupSize: 4|5}} group
|
|
164
|
+
* @returns {number}
|
|
165
|
+
*/
|
|
166
|
+
export function reachableMatchCount(group) {
|
|
167
|
+
const template = templateFor(group)
|
|
168
|
+
|
|
169
|
+
if (!template) {
|
|
170
|
+
return 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return template.reduce((count, entry) => count + entry.pairs.length, 0)
|
|
174
|
+
}
|
|
175
|
+
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How a knockout round may be cut when it cannot go on whole: in half,
|
|
7
|
+
* in quarters, in eighths, and nothing else - a split is a cut of the
|
|
8
|
+
* bracket, so the parts are the bracket's own halves. Ordered fewest
|
|
9
|
+
* cuts first, which is the order they are preferred in.
|
|
10
|
+
*/
|
|
11
|
+
export const SPLIT_SIZES = [1, 2, 4, 8]
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The smallest a part may be, below a straight halving.
|
|
15
|
+
*
|
|
16
|
+
* A round can always be halved, however narrow that leaves it: a semi
|
|
17
|
+
* playing 1/1 is two halves of the bracket, and the Final is after
|
|
18
|
+
* both. Cutting deeper than that is only a bracket cut while the parts
|
|
19
|
+
* still hold matches of their own - a QF goes 2/2 and no further,
|
|
20
|
+
* where quarters of one would have it dribbling out a match at a time.
|
|
21
|
+
*/
|
|
22
|
+
const MIN_ROUND_PART = 2
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One division's draw as the planner works through it: which round it
|
|
26
|
+
* is on, how much of that round is already booked, the courts it is
|
|
27
|
+
* committed to, and when it is next free.
|
|
28
|
+
*/
|
|
29
|
+
export class KnockoutDraw {
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {Object} params
|
|
33
|
+
* @param {Object} params.competition The competition it belongs to.
|
|
34
|
+
* @param {Array<{round: string, count: number}>} params.waves Its
|
|
35
|
+
* rounds in play order, widest first.
|
|
36
|
+
* @param {?number} [params.divisionId]
|
|
37
|
+
* @param {?string} [params.divisionName]
|
|
38
|
+
* @param {?string} [params.divisionGender]
|
|
39
|
+
* @param {number} [params.level] 0 is the strongest field.
|
|
40
|
+
* @param {boolean} [params.isPro] Pro draws use the pro courts only.
|
|
41
|
+
*/
|
|
42
|
+
constructor({
|
|
43
|
+
competition, waves, divisionId = null, divisionName = null,
|
|
44
|
+
divisionGender = null, level = 0, isPro = false,
|
|
45
|
+
}) {
|
|
46
|
+
this.competition = competition
|
|
47
|
+
this.waves = waves
|
|
48
|
+
this.divisionId = divisionId
|
|
49
|
+
this.divisionName = divisionName
|
|
50
|
+
this.divisionGender = divisionGender
|
|
51
|
+
this.level = level
|
|
52
|
+
this.isPro = isPro
|
|
53
|
+
|
|
54
|
+
this.roundIndex = 0
|
|
55
|
+
this.placedInRound = 0
|
|
56
|
+
this.placed = 0
|
|
57
|
+
this.roundCourts = null
|
|
58
|
+
this.lastParts = 1
|
|
59
|
+
this.freeFrom = -Infinity
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether every round of the draw has been planned. */
|
|
63
|
+
get done() {
|
|
64
|
+
return this.roundIndex >= this.waves.length
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The round being planned, or null once the draw is done. */
|
|
68
|
+
get round() {
|
|
69
|
+
return this.waves[this.roundIndex] ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** How many matches the current round holds in total. */
|
|
73
|
+
get width() {
|
|
74
|
+
return this.round?.count ?? 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** How many of them are still to be booked. */
|
|
78
|
+
get remaining() {
|
|
79
|
+
return this.width - this.placedInRound
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether the current round is part way through. */
|
|
83
|
+
get underway() {
|
|
84
|
+
return this.placedInRound > 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How many rounds are left after this one - 0 is the Final, 1 the
|
|
89
|
+
* semi. Doubles as "how important is this match" wherever the lower
|
|
90
|
+
* courts are handed out.
|
|
91
|
+
*/
|
|
92
|
+
get roundsRemaining() {
|
|
93
|
+
return this.waves.length - 1 - this.roundIndex
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Matches of the whole draw that never found a court - what it is
|
|
98
|
+
* short by, for the plan to report.
|
|
99
|
+
*/
|
|
100
|
+
get unplanned() {
|
|
101
|
+
const total = this.waves.reduce((sum, wave) => sum + wave.count, 0)
|
|
102
|
+
|
|
103
|
+
return Math.max(0, total - this.placed)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether this draw may go on in this slot: it has a round left and
|
|
108
|
+
* has had whatever rest it is owed.
|
|
109
|
+
*
|
|
110
|
+
* @param {number} slotIndex
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
readyAt(slotIndex) {
|
|
114
|
+
return !this.done && slotIndex >= this.freeFrom
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The cuts the current round could be played in, fewest first.
|
|
119
|
+
*
|
|
120
|
+
* A round goes on whole where the room is there and is otherwise cut
|
|
121
|
+
* the way the bracket cuts. Halving is always available, however
|
|
122
|
+
* narrow it leaves the parts; quarters and eighths need parts that
|
|
123
|
+
* still hold matches of their own.
|
|
124
|
+
*
|
|
125
|
+
* @param {boolean} split Whether cutting is allowed at all.
|
|
126
|
+
* @returns {Array<{parts: number, count: number}>} Courts needed
|
|
127
|
+
* (count) for each number of parts.
|
|
128
|
+
*/
|
|
129
|
+
cuts(split) {
|
|
130
|
+
const options = []
|
|
131
|
+
|
|
132
|
+
for (const parts of SPLIT_SIZES) {
|
|
133
|
+
if (parts > 1 && (!split || this.width % parts !== 0)) {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const count = parts === 1 ? this.width : this.width / parts
|
|
138
|
+
|
|
139
|
+
if (parts > 2 && count < MIN_ROUND_PART) {
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
options.push({ parts, count })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return options
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether taking this cut would abandon a split the draw is already
|
|
151
|
+
* in. Its second half is a round behind its first all the way down,
|
|
152
|
+
* and going back to whole rounds would put the two halves on court
|
|
153
|
+
* together. The Final ends it: a semi split 1/1 still leads to one
|
|
154
|
+
* Final.
|
|
155
|
+
*
|
|
156
|
+
* @param {number} parts
|
|
157
|
+
* @returns {boolean}
|
|
158
|
+
*/
|
|
159
|
+
breaksStride(parts) {
|
|
160
|
+
return this.lastParts > 1
|
|
161
|
+
&& this.round?.round !== 'F'
|
|
162
|
+
&& parts !== this.lastParts
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Commits the round to these courts for as long as it runs. Whatever
|
|
167
|
+
* it starts on it finishes on - re-picking each slot is what had a
|
|
168
|
+
* round open on C10/C11 and close on C14/C15 an hour later.
|
|
169
|
+
*
|
|
170
|
+
* A round too wide for the day to finish pins nothing: there is no
|
|
171
|
+
* set of courts it could finish on, and pinning would only keep it
|
|
172
|
+
* off the ones that free up later.
|
|
173
|
+
*
|
|
174
|
+
* @param {Array} courts
|
|
175
|
+
*/
|
|
176
|
+
pin(courts) {
|
|
177
|
+
this.roundCourts = this.roundCourts ?? courts.map(court => court.id)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Records matches booked, and says when the draw is next free.
|
|
182
|
+
*
|
|
183
|
+
* A split is its own rest: the half that played first has sat out
|
|
184
|
+
* every band the second half took, so no rest slot is charged on top
|
|
185
|
+
* of it.
|
|
186
|
+
*
|
|
187
|
+
* @param {Object} params
|
|
188
|
+
* @param {number} params.count How many matches were just booked.
|
|
189
|
+
* @param {number} params.parts The cut they were booked in.
|
|
190
|
+
* @param {number} params.slotIndex Where the cut started.
|
|
191
|
+
* @param {number} params.restSlots Slots owed after a whole round.
|
|
192
|
+
*/
|
|
193
|
+
book({ count, parts, slotIndex, restSlots }) {
|
|
194
|
+
this.placed += count
|
|
195
|
+
|
|
196
|
+
if (this.placedInRound + count < this.width) {
|
|
197
|
+
this.placedInRound += count
|
|
198
|
+
this.freeFrom = slotIndex + parts
|
|
199
|
+
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.lastParts = parts
|
|
204
|
+
this.roundIndex += 1
|
|
205
|
+
this.placedInRound = 0
|
|
206
|
+
this.roundCourts = null
|
|
207
|
+
this.freeFrom = slotIndex + parts + (parts > 1 ? 0 : restSlots)
|
|
208
|
+
}
|
|
209
|
+
}
|