@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,156 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How much court time a knockout draw needs, round by round, worked
|
|
7
|
+
* out from the draw itself.
|
|
8
|
+
*
|
|
9
|
+
* Planning used to be handed the answer as a list of counts by the
|
|
10
|
+
* server. It is worked out here instead, for the same reason group
|
|
11
|
+
* capacity is (see auto-planner.mjs's own note on it): the backend
|
|
12
|
+
* returns and stores the draw, and how much court time that draw
|
|
13
|
+
* needs is a question about the draw. One component owns the answer
|
|
14
|
+
* and the two can no longer disagree.
|
|
15
|
+
*
|
|
16
|
+
* A bye is the whole reason this is not just "half the field, then
|
|
17
|
+
* half again": a row with one side filled and the other empty
|
|
18
|
+
* advances a team without a game being played, so it needs no court
|
|
19
|
+
* time at all.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Bracket rounds in play order, biggest field first - the same labels
|
|
24
|
+
* a saved bracket's rows carry.
|
|
25
|
+
*/
|
|
26
|
+
export const ROUND_ORDER = ['R256', 'R128', 'R64', 'R32', 'R16', 'QF', 'SF', 'F']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The rounds of a bracket that has actually been drawn and saved.
|
|
30
|
+
*
|
|
31
|
+
* A round's name comes off its own saved rows, never from how many
|
|
32
|
+
* matches it holds: a bye-shrunk R16 (4 real matches once half its
|
|
33
|
+
* field has a bye) lands on the exact same count as a genuine QF, so
|
|
34
|
+
* a count can never say which round it is.
|
|
35
|
+
*
|
|
36
|
+
* @param {Array<{round: string, home: boolean, visitor: boolean}>} matches
|
|
37
|
+
* @returns {Array<{round: string, count: number}>}
|
|
38
|
+
*/
|
|
39
|
+
export function wavesFromSavedMatches(matches) {
|
|
40
|
+
const rounds = new Set()
|
|
41
|
+
const real = new Map()
|
|
42
|
+
|
|
43
|
+
for (const match of matches) {
|
|
44
|
+
rounds.add(match.round)
|
|
45
|
+
|
|
46
|
+
// Both sides filled: a real match. Both still empty: a TBD row
|
|
47
|
+
// waiting on winners, which will need its slot once they are
|
|
48
|
+
// known. Exactly one side filled: a bye, and no court time.
|
|
49
|
+
if (match.home === match.visitor) {
|
|
50
|
+
real.set(match.round, (real.get(match.round) ?? 0) + 1)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return ROUND_ORDER
|
|
55
|
+
.filter(round => rounds.has(round))
|
|
56
|
+
.map(round => ({ round, count: real.get(round) ?? 0 }))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The rounds a draw of this many entrants will need, before one has
|
|
61
|
+
* been drawn.
|
|
62
|
+
*
|
|
63
|
+
* The draw is the next power of two at or above the entrant count, and
|
|
64
|
+
* the gap is byes. Only the first round is short of half the draw -
|
|
65
|
+
* every team with a bye is through to round two without playing, so
|
|
66
|
+
* round two onward is the plain halving sequence.
|
|
67
|
+
*
|
|
68
|
+
* @param {number} entrants
|
|
69
|
+
* @returns {Array<{round: string, count: number}>}
|
|
70
|
+
*/
|
|
71
|
+
export function wavesForEntrants(entrants) {
|
|
72
|
+
if (entrants <= 1) {
|
|
73
|
+
return []
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const half = nextPowerOfTwo(entrants) / 2
|
|
77
|
+
const counts = []
|
|
78
|
+
|
|
79
|
+
for (let matches = half; matches >= 1; matches /= 2) {
|
|
80
|
+
counts.push(matches)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Round one, minus the byes.
|
|
84
|
+
counts[0] = entrants - half
|
|
85
|
+
|
|
86
|
+
// A draw that has not been drawn yet is exactly the canonical
|
|
87
|
+
// halving sequence by construction, so here - unlike a saved
|
|
88
|
+
// bracket's possibly bye-shrunk rounds - a round's name can be read
|
|
89
|
+
// off its position. Both lists end at the Final, so a count's name
|
|
90
|
+
// is whichever ROUND_ORDER entry sits the same distance from the end.
|
|
91
|
+
const offset = ROUND_ORDER.length - counts.length
|
|
92
|
+
|
|
93
|
+
return counts.map((count, index) => ({ round: ROUND_ORDER[offset + index], count }))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A division's knockout rounds: off its saved bracket where it has
|
|
98
|
+
* one, and off its roster where it does not.
|
|
99
|
+
*
|
|
100
|
+
* Once a real bracket exists, the estimate gets out of the way rather
|
|
101
|
+
* than keep reserving capacity for a shape that is not what was saved.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} division
|
|
104
|
+
* @param {Array<{round: string, home: boolean, visitor: boolean}>} [division.knockout_matches]
|
|
105
|
+
* @param {number} [division.team_count] Eligible teams on its roster.
|
|
106
|
+
* @param {number} [division.group_size] Teams to a group.
|
|
107
|
+
* @param {number} [division.advance_per_group] How many each group sends on.
|
|
108
|
+
* @param {Object} [options]
|
|
109
|
+
* @param {boolean} [options.itf] An ITF division plays no group stage:
|
|
110
|
+
* every eligible team enters the draw directly.
|
|
111
|
+
* @returns {Array<{round: string, count: number}>}
|
|
112
|
+
*/
|
|
113
|
+
export function wavesForDivision(division, { itf = false } = {}) {
|
|
114
|
+
const saved = division.knockout_matches ?? []
|
|
115
|
+
|
|
116
|
+
if (saved.length > 0) {
|
|
117
|
+
return wavesFromSavedMatches(saved)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return wavesForEntrants(entrantsFor(division, { itf }))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* How many teams reach the draw: every eligible team for an ITF
|
|
125
|
+
* division, and however many its groups send on for anyone else.
|
|
126
|
+
*/
|
|
127
|
+
export function entrantsFor(division, { itf = false } = {}) {
|
|
128
|
+
const teams = division.team_count ?? 0
|
|
129
|
+
|
|
130
|
+
if (teams === 0) {
|
|
131
|
+
return 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (itf) {
|
|
135
|
+
return teams
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const groupSize = division.group_size ?? 0
|
|
139
|
+
const advancing = division.advance_per_group ?? 0
|
|
140
|
+
|
|
141
|
+
if (groupSize <= 0 || advancing <= 0) {
|
|
142
|
+
return 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return Math.ceil(teams / groupSize) * advancing
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function nextPowerOfTwo(n) {
|
|
149
|
+
let value = 1
|
|
150
|
+
|
|
151
|
+
while (value < n) {
|
|
152
|
+
value *= 2
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return value
|
|
156
|
+
}
|
package/lib/lighting.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A slot needs a lit court when the match played in it ENDS after
|
|
7
|
+
* dark, not when it starts after some fixed clock time. That
|
|
8
|
+
* distinction is what keeps the rule general across competitions: a
|
|
9
|
+
* group can only use an unlit court if its whole section finishes
|
|
10
|
+
* before dark, so with 45-minute slots and dark at 18:00 a 5-team
|
|
11
|
+
* competition (5-match, 3h45 sections) runs out of unlit capacity at
|
|
12
|
+
* 15:30 while a 4-team one (3-match, 2h15 sections) keeps going until
|
|
13
|
+
* 17:00 - same courts, same lights, different boundary, both derived
|
|
14
|
+
* rather than configured.
|
|
15
|
+
*
|
|
16
|
+
* The backend sends raw times only and passes dark-from as an
|
|
17
|
+
* attribute on the element; deciding what that means for a court is a
|
|
18
|
+
* scheduling decision and belongs client-side (spec.md).
|
|
19
|
+
*
|
|
20
|
+
* @param {{slots: Array<{end_minutes?: number}>}} state
|
|
21
|
+
* @param {string} [darkFromTime] "HH:MM"
|
|
22
|
+
* @returns {Object} the same state, its slots carrying lit_required
|
|
23
|
+
*/
|
|
24
|
+
export function withLitRequired(state, darkFromTime) {
|
|
25
|
+
if (!state?.slots) {
|
|
26
|
+
return state
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const darkFrom = toMinutes(darkFromTime)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
...state,
|
|
33
|
+
slots: state.slots.map(slot => ({
|
|
34
|
+
...slot,
|
|
35
|
+
lit_required: darkFrom == null || slot.end_minutes == null
|
|
36
|
+
? slot.lit_required ?? false
|
|
37
|
+
: slot.end_minutes > darkFrom,
|
|
38
|
+
})),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toMinutes(time) {
|
|
43
|
+
if (typeof time !== 'string') {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [hours, minutes] = time.split(':').map(Number)
|
|
48
|
+
|
|
49
|
+
return Number.isFinite(hours) ? hours * 60 + (Number.isFinite(minutes) ? minutes : 0) : null
|
|
50
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How long a match takes, and how long a team is owed between two of
|
|
7
|
+
* its own - the two figures every planner shape measures its slots
|
|
8
|
+
* against. Shared so the group side and the knockout side cannot drift
|
|
9
|
+
* apart on what a match costs.
|
|
10
|
+
*/
|
|
11
|
+
export const MATCH_MINUTES = 45
|
|
12
|
+
|
|
13
|
+
export const REST_MINUTES = 45
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which courts are busy, and when.
|
|
7
|
+
*
|
|
8
|
+
* Held as real time spans rather than cells of a grid: competitions do
|
|
9
|
+
* not share a clock - one runs 45-minute matches from 08:00, another
|
|
10
|
+
* 90-minute ones from 09:00 - and no single grid holds both. The only
|
|
11
|
+
* question worth asking is whether a court is already occupied for the
|
|
12
|
+
* stretch being asked about.
|
|
13
|
+
*
|
|
14
|
+
* Shared by both planners so neither can double-book what the other
|
|
15
|
+
* has taken.
|
|
16
|
+
*/
|
|
17
|
+
export function makeOccupancy() {
|
|
18
|
+
const spans = new Map()
|
|
19
|
+
|
|
20
|
+
const key = (date, courtId) => `${date}|${courtId}`
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
free(date, courtId, start, end) {
|
|
24
|
+
const taken = spans.get(key(date, courtId))
|
|
25
|
+
|
|
26
|
+
return !taken?.some(([from, to]) => start < to && from < end)
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a court is free for every one of these slots.
|
|
31
|
+
*/
|
|
32
|
+
freeFor(date, courtId, slots) {
|
|
33
|
+
return slots.every(slot => this.free(date, courtId, slot.start_minutes, slot.end_minutes))
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
take(date, courtId, start, end) {
|
|
37
|
+
const id = key(date, courtId)
|
|
38
|
+
|
|
39
|
+
if (!spans.has(id)) {
|
|
40
|
+
spans.set(id, [])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
spans.get(id).push([start, end])
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a court can host a stretch of play.
|
|
50
|
+
*
|
|
51
|
+
* Lighting is a question about the whole stretch, not the individual
|
|
52
|
+
* match: a run reaching past dark needs a lit court from its first
|
|
53
|
+
* match, because everyone on court at the same time plays in the same
|
|
54
|
+
* conditions.
|
|
55
|
+
*/
|
|
56
|
+
export function courtCanHost(court, slots, darkFromMinutes) {
|
|
57
|
+
if (!court.active) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A court a director has opened up for the day only from a given
|
|
62
|
+
// time onward (auto-planner.mjs's openCourtsFor) - baked onto the
|
|
63
|
+
// court object itself, so this needs no date of its own to enforce.
|
|
64
|
+
if (court.availableFromMinutes != null
|
|
65
|
+
&& slots.some(slot => slot.start_minutes < court.availableFromMinutes)) {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (darkFromMinutes == null || court.lit) {
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return slots.every(slot => slot.end_minutes <= darkFromMinutes)
|
|
74
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { courtCanHost } from './occupancy.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Plans any group shape whose round robin needs more than one court at
|
|
9
|
+
* once, courtsPerRound of them, all bundled groups taking their turn
|
|
10
|
+
* on the same set before handing it to the next: 4-team's three rounds
|
|
11
|
+
* of two matches is the shape this was written for, but the same
|
|
12
|
+
* mechanism gives a 3-team group (courtsPerRound 1 - it never needs a
|
|
13
|
+
* second court, but still bundles with another 3-team group so each
|
|
14
|
+
* gets a rest between its own matches instead of playing straight
|
|
15
|
+
* through alone) the same courts, the same rest, the same priority
|
|
16
|
+
* order, as 4-team - see three-team-planner.mjs.
|
|
17
|
+
*
|
|
18
|
+
* courtsPerRound 2 (4-team): courtsPerRound 1 (3-team):
|
|
19
|
+
* 1: A v B C v D 1: A v B
|
|
20
|
+
* 2: A v B C v D 1: C v D
|
|
21
|
+
* 1: A v C B v D 2: A v B
|
|
22
|
+
* ... 2: C v D
|
|
23
|
+
* ...
|
|
24
|
+
*
|
|
25
|
+
* That waiting is the whole reason to bundle: alone, a group's own
|
|
26
|
+
* round robin runs its matches back to back with no rest between a
|
|
27
|
+
* team's own appearances. Bundled with another group of the same
|
|
28
|
+
* shape, they take turns - one court (or courtsPerRound of them) a
|
|
29
|
+
* turn - so a team gets the other group's turn to rest through.
|
|
30
|
+
*
|
|
31
|
+
* TODO: 4-team and 5-team should both be able to choose courtsPerRound
|
|
32
|
+
* explicitly (a division opting into "pairs" plays its round robin
|
|
33
|
+
* across 2 courts for the rest that gives; opting out runs the whole
|
|
34
|
+
* thing on one, the run-queue-planner.mjs way) rather than 4-team
|
|
35
|
+
* being hardcoded to 2 and 5-team's engine never touching this module
|
|
36
|
+
* at all. Not built yet - flagged here so the two do not drift apart
|
|
37
|
+
* when it is.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Lays out a set of same-shape groups sharing courtsPerRound courts at
|
|
42
|
+
* once.
|
|
43
|
+
*
|
|
44
|
+
* Groups take turns: every group's first round, then every group's
|
|
45
|
+
* second, and so on - nothing sits idle, and a group waits out as many
|
|
46
|
+
* slots between its own rounds as there are other groups in the unit.
|
|
47
|
+
*
|
|
48
|
+
* @param {Array<{id: number, teamIds: number[]}>} groups
|
|
49
|
+
* @param {Object} params
|
|
50
|
+
* @param {Array<{slot: number, pairs: number[][]}>} params.template The
|
|
51
|
+
* group shape's own round robin, one entry per round - see
|
|
52
|
+
* group-schedule.mjs's GROUP_TEMPLATES and four-team-planner.mjs's
|
|
53
|
+
* FOUR_TEAM_TEMPLATE. A round's pairs.length is courtsPerRound.
|
|
54
|
+
* @param {(group: {id: number, teamIds: number[]}) => Array<{slot: number, pairIndex: number, index: number}>} params.matchesFor
|
|
55
|
+
* A group's own matches, flattened and indexed in play order.
|
|
56
|
+
* @returns {Array<Array<{groupId: number, index: number}|null>>} slot by court
|
|
57
|
+
*/
|
|
58
|
+
export function unitLayout(groups, { template, matchesFor }) {
|
|
59
|
+
const slots = []
|
|
60
|
+
|
|
61
|
+
for (const round of template) {
|
|
62
|
+
// What each group actually plays this round: courtsPerRound
|
|
63
|
+
// matches, or fewer where a vacant seat leaves one empty.
|
|
64
|
+
const turns = groups
|
|
65
|
+
.map(group => {
|
|
66
|
+
const matches = matchesFor(group)
|
|
67
|
+
|
|
68
|
+
return round.pairs
|
|
69
|
+
.map((pair, pairIndex) => {
|
|
70
|
+
const index = matches.findIndex(
|
|
71
|
+
match => match.slot === round.slot && match.pairIndex === pairIndex
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return index === -1 ? null : { groupId: group.id, index }
|
|
75
|
+
})
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
})
|
|
78
|
+
.filter(turn => turn.length > 0)
|
|
79
|
+
|
|
80
|
+
// One turn to a slot, so groups take it in turns rather than all
|
|
81
|
+
// at once - the rest the bundling exists for.
|
|
82
|
+
for (const turn of turns) {
|
|
83
|
+
slots.push(round.pairs.map((_, courtIndex) => turn[courtIndex] ?? null))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return slots
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Places an already-computed layout (see unitLayout) onto the lowest
|
|
92
|
+
* stretch of courts that is actually free, and the earliest start on
|
|
93
|
+
* it - no bands, no reservation.
|
|
94
|
+
*
|
|
95
|
+
* Split out from placeUnit so a caller that needs to place only part
|
|
96
|
+
* of a unit's layout - auto-planner.mjs cuts a unit's sequence in half
|
|
97
|
+
* across two days when its dates span two of them - can reuse this
|
|
98
|
+
* search directly instead of reimplementing it.
|
|
99
|
+
*
|
|
100
|
+
* A unit only ever needs as many courts as some row of the layout
|
|
101
|
+
* actually uses - a unit of nothing but byes never has two matches at
|
|
102
|
+
* once, and searching for courtsPerRound of them would strand a
|
|
103
|
+
* perfectly usable neighbouring court idle beside every one of its
|
|
104
|
+
* slots for no reason.
|
|
105
|
+
*
|
|
106
|
+
* @param {Array<Array<{groupId: number, index: number}|null>>} layout
|
|
107
|
+
* @param {(groupId: number) => {id, competitionId, divisionId, divisionName, divisionGender, groupName}} lookup
|
|
108
|
+
*/
|
|
109
|
+
export function placeLayout({ layout, lookup, courts, slots, date, occupancy, darkFrom, blocks }) {
|
|
110
|
+
if (layout.length === 0) {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const width = Math.max(1, ...layout.map(row => row.filter(Boolean).length))
|
|
115
|
+
|
|
116
|
+
for (let start = 0; start + layout.length <= slots.length; start++) {
|
|
117
|
+
const window = slots.slice(start, start + layout.length)
|
|
118
|
+
|
|
119
|
+
// Neighbours among the courts that can actually host this window,
|
|
120
|
+
// not among the courts as they are numbered. An unlit court in the
|
|
121
|
+
// middle of the row would otherwise strand the court beside it: a
|
|
122
|
+
// pair needs two, and if one of them can never host the run the
|
|
123
|
+
// other is never offered to anybody.
|
|
124
|
+
const eligible = courts.filter(court => courtCanHost(court, window, darkFrom))
|
|
125
|
+
|
|
126
|
+
for (let index = 0; index + width <= eligible.length; index++) {
|
|
127
|
+
const set = eligible.slice(index, index + width)
|
|
128
|
+
|
|
129
|
+
const usable = set.every(court =>
|
|
130
|
+
occupancy.freeFor(date, court.id, window)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if (!usable) {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
layout.forEach((row, offset) => {
|
|
138
|
+
row.forEach((cell, courtIndex) => {
|
|
139
|
+
if (!cell) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const group = lookup(cell.groupId)
|
|
144
|
+
const court = set[courtIndex]
|
|
145
|
+
const slot = window[offset]
|
|
146
|
+
|
|
147
|
+
blocks.push({
|
|
148
|
+
competition_id: group.competitionId,
|
|
149
|
+
division_id: group.divisionId ?? null,
|
|
150
|
+
division_name: group.divisionName ?? null,
|
|
151
|
+
division_gender: group.divisionGender ?? null,
|
|
152
|
+
group_id: group.id,
|
|
153
|
+
group_name: group.groupName ?? null,
|
|
154
|
+
phase: 'group',
|
|
155
|
+
round: 'group',
|
|
156
|
+
match_slot: cell.index,
|
|
157
|
+
court_id: court.id,
|
|
158
|
+
date,
|
|
159
|
+
slot: slot.key,
|
|
160
|
+
short_group: true,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
occupancy.take(date, court.id, slot.start_minutes, slot.end_minutes)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* How a set of same-shape groups is split into units sharing courts.
|
|
176
|
+
*
|
|
177
|
+
* Units of two are the default: a shorter window drops into the light
|
|
178
|
+
* where a unit of three will not. What decides the split is parity
|
|
179
|
+
* rather than greed - a group left over on its own has nobody to share
|
|
180
|
+
* with, so an odd count takes exactly one unit of three and pairs off
|
|
181
|
+
* the rest.
|
|
182
|
+
*
|
|
183
|
+
* 7 groups -> 3, 2, 2 9 groups -> 3, 2, 2, 2
|
|
184
|
+
*
|
|
185
|
+
* When courts are scarce the same no-leftovers rule is applied the
|
|
186
|
+
* other way round, taking as many threes as possible to hold fewer at
|
|
187
|
+
* once:
|
|
188
|
+
*
|
|
189
|
+
* 7 groups -> 3, 2, 2 9 groups -> 3, 3, 3
|
|
190
|
+
*
|
|
191
|
+
* @param {number} count
|
|
192
|
+
* @param {{dense?: boolean}} options
|
|
193
|
+
* @returns {number[]} unit sizes, largest first
|
|
194
|
+
*/
|
|
195
|
+
export function unitSizes(count, { dense = false } = {}) {
|
|
196
|
+
if (count < 2) {
|
|
197
|
+
// Nothing to share with. The caller decides what to do with it
|
|
198
|
+
// rather than being handed a unit of one.
|
|
199
|
+
return []
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!dense) {
|
|
203
|
+
const threes = count % 2 === 0 ? 0 : 1
|
|
204
|
+
return sizes(threes, (count - threes * 3) / 2)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// As many threes as fit without stranding a single group: a
|
|
208
|
+
// remainder of one means dropping back a three to leave four, which
|
|
209
|
+
// pairs off.
|
|
210
|
+
let threes = Math.floor(count / 3)
|
|
211
|
+
|
|
212
|
+
if (count - threes * 3 === 1) {
|
|
213
|
+
threes -= 1
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return sizes(threes, (count - threes * 3) / 2)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sizes(threes, twos) {
|
|
220
|
+
return [...Array(threes).fill(3), ...Array(twos).fill(2)]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Splits a set of same-shape groups into units sharing a court set.
|
|
225
|
+
*/
|
|
226
|
+
export function buildUnits(groups, { dense = false } = {}) {
|
|
227
|
+
const units = []
|
|
228
|
+
let cursor = 0
|
|
229
|
+
|
|
230
|
+
for (const size of unitSizes(groups.length, { dense })) {
|
|
231
|
+
units.push(groups.slice(cursor, cursor + size))
|
|
232
|
+
cursor += size
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return units
|
|
236
|
+
}
|