@opndev/tennis-match-center 0.0.2 → 0.0.4
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 +66 -1
- package/lib/auto-planner.mjs +1280 -601
- package/lib/components/day-grid.mjs +19 -7
- package/lib/components/gameday-board.mjs +111 -13
- package/lib/components/gameday-card.mjs +31 -0
- package/lib/components/match.mjs +17 -0
- package/lib/components/planning-board.mjs +233 -8
- package/lib/components/roster-board.mjs +42 -2
- package/lib/components/schedule-board.mjs +10 -6
- package/lib/court-bands.mjs +80 -1
- package/lib/four-team-planner.mjs +2 -2
- package/lib/index.mjs +1 -1
- package/lib/knockout-draw.mjs +32 -6
- package/lib/knockout-planner.mjs +12 -4
- package/lib/paired-court-planner.mjs +65 -2
- package/lib/planning-day.mjs +165 -0
- package/lib/run-queue-planner.mjs +167 -11
- package/lib/sessions.mjs +34 -0
- package/lib/three-team-planner.mjs +2 -1
- package/package.json +1 -1
|
@@ -5,6 +5,37 @@
|
|
|
5
5
|
import { HTMLElementSugar } from '@skirbi/sugar'
|
|
6
6
|
import { seedGroups, groupName } from '../roster-seeding.mjs'
|
|
7
7
|
|
|
8
|
+
const DAY = new Intl.DateTimeFormat('en-GB', {
|
|
9
|
+
weekday: 'short',
|
|
10
|
+
day: 'numeric',
|
|
11
|
+
month: 'short',
|
|
12
|
+
timeZone: 'UTC',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The day an ISO timestamp falls on, read off the string so the event's
|
|
17
|
+
* own clock is what is said, not the browser's.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} iso
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function dayOf(iso) {
|
|
23
|
+
return DAY.format(new Date(`${iso.slice(0, 10)}T12:00:00Z`))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* When a group starts playing, as far as the planning knows it: the
|
|
28
|
+
* first match of each day it plays.
|
|
29
|
+
*
|
|
30
|
+
* @param {Array<string>} starts - ISO timestamps, one per day
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function startsLine(starts) {
|
|
34
|
+
return starts
|
|
35
|
+
.map(iso => `${dayOf(iso)}, ${iso.slice(11, 16)}`)
|
|
36
|
+
.join(' / ')
|
|
37
|
+
}
|
|
38
|
+
|
|
8
39
|
/**
|
|
9
40
|
* <tennis-roster-board api="..." save-api="...">
|
|
10
41
|
*
|
|
@@ -173,11 +204,11 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
|
|
|
173
204
|
.map(id => this.saved.teams.find(team => team.id === id))
|
|
174
205
|
.filter(Boolean)
|
|
175
206
|
|
|
176
|
-
columns.appendChild(this.columnFor(group.name, teams))
|
|
207
|
+
columns.appendChild(this.columnFor(group.name, teams, group.starts))
|
|
177
208
|
}
|
|
178
209
|
}
|
|
179
210
|
|
|
180
|
-
columnFor(currentGroupName, teams) {
|
|
211
|
+
columnFor(currentGroupName, teams, starts = []) {
|
|
181
212
|
const column = document.createElement('div')
|
|
182
213
|
|
|
183
214
|
column.dataset.group = currentGroupName ?? ''
|
|
@@ -212,6 +243,15 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
|
|
|
212
243
|
count.textContent = String(teams.length)
|
|
213
244
|
heading.appendChild(count)
|
|
214
245
|
|
|
246
|
+
if (starts?.length) {
|
|
247
|
+
const when = document.createElement('p')
|
|
248
|
+
|
|
249
|
+
when.dataset.planned = ''
|
|
250
|
+
when.title = 'First match of the group, per day'
|
|
251
|
+
when.textContent = startsLine(starts)
|
|
252
|
+
column.appendChild(when)
|
|
253
|
+
}
|
|
254
|
+
|
|
215
255
|
const list = document.createElement('div')
|
|
216
256
|
list.dataset.list = ''
|
|
217
257
|
column.appendChild(list)
|
|
@@ -10,7 +10,7 @@ import { competitionHue, divisionHues } from '../division-hues.mjs'
|
|
|
10
10
|
import { setNameText } from '../flags.mjs'
|
|
11
11
|
import { rowsForDate } from '../day-rows.mjs'
|
|
12
12
|
import { courtCanTake, toMinutes } from '../lighting.mjs'
|
|
13
|
-
import {
|
|
13
|
+
import { TennisDayGridElement } from './day-grid.mjs'
|
|
14
14
|
import { withBusyButton } from '../busy-button.mjs'
|
|
15
15
|
|
|
16
16
|
const KNOCKOUT_ROUND_ORDER = ['R256', 'R128', 'R64', 'R32', 'R16', 'QF', 'SF', 'F']
|
|
@@ -468,10 +468,15 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
|
|
|
468
468
|
* Sent as a delete rather than as an empty save: which rows to drop
|
|
469
469
|
* follows from the event and the date, and a board that holds one
|
|
470
470
|
* day at a time could not send the rest of them to be replaced.
|
|
471
|
+
*
|
|
472
|
+
* A played match is not dropped. The database refuses to let the
|
|
473
|
+
* planner delete or move one, so the server keeps it - and a board
|
|
474
|
+
* that emptied its own copy anyway would show a morning that has
|
|
475
|
+
* been played as an empty morning until the next reload.
|
|
471
476
|
*/
|
|
472
477
|
clearAll() {
|
|
473
478
|
return this.clear(this.getConfig()['clear-api'], null, () => {
|
|
474
|
-
this.matches =
|
|
479
|
+
this.matches = this.matches.filter(match => match.played === true)
|
|
475
480
|
this.unscheduledByCompetition = new Map()
|
|
476
481
|
})
|
|
477
482
|
}
|
|
@@ -482,7 +487,9 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
|
|
|
482
487
|
}
|
|
483
488
|
|
|
484
489
|
return this.clear(`${this.getConfig()['day-api']}/${date}`, date, () => {
|
|
485
|
-
this.matches = this.matches.filter(match =>
|
|
490
|
+
this.matches = this.matches.filter(match =>
|
|
491
|
+
match.date !== date || match.played === true
|
|
492
|
+
)
|
|
486
493
|
})
|
|
487
494
|
}
|
|
488
495
|
|
|
@@ -948,9 +955,6 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
|
|
|
948
955
|
rows: this.rowsFor(date),
|
|
949
956
|
cellFor: (court, courtIndex, slot) => this.cellFor(court, date, slot),
|
|
950
957
|
compact: this.$('[data-narrow-empty]')?.checked ?? false,
|
|
951
|
-
// A scheduled cell carries four names and what is being played,
|
|
952
|
-
// where a planned one carries a division and a group.
|
|
953
|
-
scale: PX_PER_MINUTE * 2,
|
|
954
958
|
})
|
|
955
959
|
|
|
956
960
|
return grid
|
package/lib/court-bands.mjs
CHANGED
|
@@ -20,13 +20,18 @@
|
|
|
20
20
|
* division needs across every section, keyed by division id.
|
|
21
21
|
* @param {number[]} params.capacities How many courts each section can
|
|
22
22
|
* actually host (already filtered for lit/usable, per caller).
|
|
23
|
+
* @param {number} [params.rotation] Which day of the competition this
|
|
24
|
+
* is, counting from zero. Where a division's runs do not divide
|
|
25
|
+
* evenly over the sections, the extra ones move two sections on each
|
|
26
|
+
* day, the same way its groups do.
|
|
23
27
|
* @returns {{
|
|
24
28
|
* quotasBySection: Array<Map<string|number, number>>,
|
|
25
29
|
* bandsBySection: Array<Map<string|number, {start: number, end: number}>>
|
|
26
30
|
* }}
|
|
27
31
|
*/
|
|
28
|
-
export function computeSectionBands({ divisions, totals, capacities }) {
|
|
32
|
+
export function computeSectionBands({ divisions, totals, capacities, rotation = 0 }) {
|
|
29
33
|
const sectionCount = capacities.length
|
|
34
|
+
const evened = evenQuotas({ divisions, totals, capacities, rotation })
|
|
30
35
|
const quotasBySection = []
|
|
31
36
|
const bandsBySection = []
|
|
32
37
|
const granted = new Map(divisions.map(id => [id, 0]))
|
|
@@ -104,6 +109,15 @@ export function computeSectionBands({ divisions, totals, capacities }) {
|
|
|
104
109
|
room -= 1
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
// Where every division can be spread as evenly as its runs allow
|
|
113
|
+
// and the sections can hold it, that is the answer, whatever the
|
|
114
|
+
// capacity-driven shares above came to.
|
|
115
|
+
if (evened) {
|
|
116
|
+
for (const id of divisions) {
|
|
117
|
+
quotas.set(id, evened[section].get(id))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
// The band a division sits in, as a fraction of the section's own
|
|
108
122
|
// courts, straight from what it was just given. Divisions come in
|
|
109
123
|
// priority order, so the strongest field takes the lowest courts
|
|
@@ -129,6 +143,71 @@ export function computeSectionBands({ divisions, totals, capacities }) {
|
|
|
129
143
|
return { quotasBySection, bandsBySection }
|
|
130
144
|
}
|
|
131
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Each division's runs spread as evenly over the sections as they go.
|
|
148
|
+
*
|
|
149
|
+
* Every division takes the whole number of runs that divides evenly in
|
|
150
|
+
* each section, and whatever is left over goes one to a section, to the
|
|
151
|
+
* sections with the most room, so the extra run of one division sits
|
|
152
|
+
* where another's does not and the sections fill alike. Ties go to the
|
|
153
|
+
* earliest section, turned two sections on for each day of rotation:
|
|
154
|
+
* the group that had an extra run in a section yesterday is the one a
|
|
155
|
+
* rotation sends to the section two on today.
|
|
156
|
+
*
|
|
157
|
+
* Only when the sections can hold it. If they cannot, the caller's own
|
|
158
|
+
* capacity-driven shares are all there is, and this says so with null.
|
|
159
|
+
*
|
|
160
|
+
* @returns {?Array<Map<string|number, number>>} quotas by section
|
|
161
|
+
*/
|
|
162
|
+
function evenQuotas({ divisions, totals, capacities, rotation }) {
|
|
163
|
+
const sections = capacities.length
|
|
164
|
+
|
|
165
|
+
if (sections === 0) {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const load = new Array(sections).fill(0)
|
|
170
|
+
const quotas = capacities.map(() => new Map())
|
|
171
|
+
|
|
172
|
+
for (const id of divisions) {
|
|
173
|
+
const base = Math.floor((totals.get(id) ?? 0) / sections)
|
|
174
|
+
|
|
175
|
+
for (let section = 0; section < sections; section++) {
|
|
176
|
+
quotas[section].set(id, base)
|
|
177
|
+
load[section] += base
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Earliest first, turned by the day: [0, 1, 2], then [2, 0, 1].
|
|
182
|
+
const turn = (2 * rotation) % sections
|
|
183
|
+
const order = Array.from({ length: sections }, (_, i) => (i + turn) % sections)
|
|
184
|
+
|
|
185
|
+
for (const id of divisions) {
|
|
186
|
+
const extra = (totals.get(id) ?? 0) % sections
|
|
187
|
+
const given = new Set()
|
|
188
|
+
|
|
189
|
+
for (let i = 0; i < extra; i++) {
|
|
190
|
+
let best = -1
|
|
191
|
+
|
|
192
|
+
for (const section of order) {
|
|
193
|
+
if (given.has(section)) {
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (best === -1 || capacities[section] - load[section] > capacities[best] - load[best]) {
|
|
198
|
+
best = section
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
given.add(best)
|
|
203
|
+
load[best] += 1
|
|
204
|
+
quotas[best].set(id, quotas[best].get(id) + 1)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return load.every((runs, section) => runs <= capacities[section]) ? quotas : null
|
|
209
|
+
}
|
|
210
|
+
|
|
132
211
|
/**
|
|
133
212
|
* How far a court (as a fraction of its list) sits from a division's
|
|
134
213
|
* band. Zero inside the band; a band is an interval, not a point, so a
|
|
@@ -211,6 +211,6 @@ export function fourTeamUnitSizes(count, { dense = false } = {}) {
|
|
|
211
211
|
* @param {Array<{id: number, teamIds: number[]}>} groups
|
|
212
212
|
* @returns {Array<Array<{groupId: number, index: number}>>} slot by court
|
|
213
213
|
*/
|
|
214
|
-
export function fourTeamUnitLayout(groups) {
|
|
215
|
-
return unitLayout(groups, { template: FOUR_TEAM_TEMPLATE, matchesFor: fourTeamMatches })
|
|
214
|
+
export function fourTeamUnitLayout(groups, done = null) {
|
|
215
|
+
return unitLayout(groups, { template: FOUR_TEAM_TEMPLATE, matchesFor: fourTeamMatches, done })
|
|
216
216
|
}
|
package/lib/index.mjs
CHANGED
package/lib/knockout-draw.mjs
CHANGED
|
@@ -58,6 +58,7 @@ export class KnockoutDraw {
|
|
|
58
58
|
this.lastCourts = null
|
|
59
59
|
this.lastParts = 1
|
|
60
60
|
this.freeFrom = -Infinity
|
|
61
|
+
this.freeDate = null
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/** Whether every round of the draw has been planned. */
|
|
@@ -108,11 +109,22 @@ export class KnockoutDraw {
|
|
|
108
109
|
* Whether this draw may go on in this slot: it has a round left and
|
|
109
110
|
* has had whatever rest it is owed.
|
|
110
111
|
*
|
|
112
|
+
* Rest is owed within a day and a night is more of it than any rule
|
|
113
|
+
* asks, so a draw arriving on a new date is ready from its first
|
|
114
|
+
* slot. The slot index it was last free from means nothing on
|
|
115
|
+
* another day's list, which is what used to lock a draw that could
|
|
116
|
+
* not finish out of every day after it.
|
|
117
|
+
*
|
|
111
118
|
* @param {number} slotIndex
|
|
119
|
+
* @param {?string} [date]
|
|
112
120
|
* @returns {boolean}
|
|
113
121
|
*/
|
|
114
|
-
readyAt(slotIndex) {
|
|
115
|
-
|
|
122
|
+
readyAt(slotIndex, date = null) {
|
|
123
|
+
if (this.done) {
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return date !== this.freeDate || slotIndex >= this.freeFrom
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
/**
|
|
@@ -123,19 +135,31 @@ export class KnockoutDraw {
|
|
|
123
135
|
* narrow it leaves the parts; quarters and eighths need parts that
|
|
124
136
|
* still hold matches of their own.
|
|
125
137
|
*
|
|
138
|
+
* A round does not have to be a power of two. Three of a round of
|
|
139
|
+
* eight played and five left is the same thing as a bracket with
|
|
140
|
+
* byes, and five still halves - 3 then 2 - which is what pays for
|
|
141
|
+
* the rest. A cut that would leave a part with nothing in it is not
|
|
142
|
+
* offered.
|
|
143
|
+
*
|
|
126
144
|
* @param {boolean} split Whether cutting is allowed at all.
|
|
127
145
|
* @returns {Array<{parts: number, count: number}>} Courts needed
|
|
128
|
-
* (count) for
|
|
146
|
+
* (count) for the widest part.
|
|
129
147
|
*/
|
|
130
148
|
cuts(split) {
|
|
131
149
|
const options = []
|
|
132
150
|
|
|
133
151
|
for (const parts of SPLIT_SIZES) {
|
|
134
|
-
if (parts > 1 &&
|
|
152
|
+
if (parts > 1 && !split) {
|
|
135
153
|
continue
|
|
136
154
|
}
|
|
137
155
|
|
|
138
|
-
const count = parts === 1 ? this.width : this.width / parts
|
|
156
|
+
const count = parts === 1 ? this.width : Math.ceil(this.width / parts)
|
|
157
|
+
|
|
158
|
+
// Every part has to hold something: a round of five in four
|
|
159
|
+
// parts is three parts and an empty one.
|
|
160
|
+
if (parts > 1 && count * (parts - 1) >= this.width) {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
139
163
|
|
|
140
164
|
if (parts > 2 && count < MIN_ROUND_PART) {
|
|
141
165
|
continue
|
|
@@ -190,9 +214,11 @@ export class KnockoutDraw {
|
|
|
190
214
|
* @param {number} params.parts The cut they were booked in.
|
|
191
215
|
* @param {number} params.slotIndex Where the cut started.
|
|
192
216
|
* @param {number} params.restSlots Slots owed after a whole round.
|
|
217
|
+
* @param {?string} [params.date] The day that slot index belongs to.
|
|
193
218
|
*/
|
|
194
|
-
book({ count, parts, slotIndex, restSlots }) {
|
|
219
|
+
book({ count, parts, slotIndex, restSlots, date = null }) {
|
|
195
220
|
this.placed += count
|
|
221
|
+
this.freeDate = date
|
|
196
222
|
|
|
197
223
|
if (this.placedInRound + count < this.width) {
|
|
198
224
|
this.placedInRound += count
|
package/lib/knockout-planner.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { MATCH_MINUTES, REST_MINUTES } from './match-time.mjs'
|
|
6
6
|
import { SPLIT_SIZES } from './knockout-draw.mjs'
|
|
7
|
-
import { activePhase, activeSession } from './sessions.mjs'
|
|
7
|
+
import { activePhase, activeRounds, activeSession } from './sessions.mjs'
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -242,7 +242,7 @@ function placeRound(item, held, day, where) {
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
draw.book({ count: placed, parts, slotIndex, restSlots })
|
|
245
|
+
draw.book({ count: placed, parts, slotIndex, restSlots, date: day.date })
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/**
|
|
@@ -327,8 +327,11 @@ export function assignKnockout(day, { competitions, draws, sessions, split }) {
|
|
|
327
327
|
const ownSession = activeSession(ownSessions, date)
|
|
328
328
|
const restSlots = restSlotsFor(ownSession?.playtime ?? MATCH_MINUTES)
|
|
329
329
|
|
|
330
|
+
// A slot with no phase of its own belongs to a replan, which owns
|
|
331
|
+
// the whole day whatever the session had meant it for.
|
|
330
332
|
const firstSlot = own.findIndex(slot =>
|
|
331
|
-
|
|
333
|
+
slot.phase === null
|
|
334
|
+
|| (slot.phase ?? activePhase(ownSessions, date, slot.key)) === 'knockout'
|
|
332
335
|
)
|
|
333
336
|
|
|
334
337
|
if (firstSlot === -1) {
|
|
@@ -349,6 +352,7 @@ export function assignKnockout(day, { competitions, draws, sessions, split }) {
|
|
|
349
352
|
|
|
350
353
|
for (let slotIndex = firstSlot; slotIndex < own.length; slotIndex++) {
|
|
351
354
|
const slot = own[slotIndex]
|
|
355
|
+
const onRounds = activeRounds(ownSessions, date, slot.key)
|
|
352
356
|
|
|
353
357
|
/** The lane's courts with nothing on them in this slot. */
|
|
354
358
|
function free() {
|
|
@@ -360,7 +364,11 @@ export function assignKnockout(day, { competitions, draws, sessions, split }) {
|
|
|
360
364
|
}
|
|
361
365
|
|
|
362
366
|
let ready = units
|
|
363
|
-
.filter(draw => draw.readyAt(slotIndex))
|
|
367
|
+
.filter(draw => draw.readyAt(slotIndex, date))
|
|
368
|
+
// A session may say which rounds are on. Nothing else opens
|
|
369
|
+
// until one does, and a draw whose turn has not come waits
|
|
370
|
+
// for the day it was given rather than running ahead.
|
|
371
|
+
.filter(draw => onRounds == null || onRounds.includes(draw.round?.round))
|
|
364
372
|
.sort((a, b) => (b.width - a.width) || (b.level - a.level))
|
|
365
373
|
|
|
366
374
|
if (ready.length === 0) {
|
|
@@ -53,9 +53,16 @@ import { courtCanHost } from './occupancy.mjs'
|
|
|
53
53
|
* FOUR_TEAM_TEMPLATE. A round's pairs.length is courtsPerRound.
|
|
54
54
|
* @param {(group: {id: number, teamIds: number[]}) => Array<{slot: number, pairIndex: number, index: number}>} params.matchesFor
|
|
55
55
|
* A group's own matches, flattened and indexed in play order.
|
|
56
|
+
* @param {?(groupId: number, index: number) => boolean} [params.done]
|
|
57
|
+
* Which of them are already played, for a day laid out into a plan
|
|
58
|
+
* that exists.
|
|
56
59
|
* @returns {Array<Array<{groupId: number, index: number}|null>>} slot by court
|
|
57
60
|
*/
|
|
58
|
-
export function unitLayout(groups, { template, matchesFor }) {
|
|
61
|
+
export function unitLayout(groups, { template, matchesFor, done = null }) {
|
|
62
|
+
if (done) {
|
|
63
|
+
return remainderLayout(groups, { template, matchesFor, done })
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
const slots = []
|
|
60
67
|
|
|
61
68
|
for (const round of template) {
|
|
@@ -71,7 +78,15 @@ export function unitLayout(groups, { template, matchesFor }) {
|
|
|
71
78
|
match => match.slot === round.slot && match.pairIndex === pairIndex
|
|
72
79
|
)
|
|
73
80
|
|
|
74
|
-
|
|
81
|
+
// A fixture already played keeps its place in the
|
|
82
|
+
// numbering and leaves a hole in the layout - the index
|
|
83
|
+
// is what a cell is booked as, so it cannot be squeezed
|
|
84
|
+
// out by renumbering what is left.
|
|
85
|
+
if (index === -1 || done?.(group.id, index)) {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { groupId: group.id, index }
|
|
75
90
|
})
|
|
76
91
|
.filter(Boolean)
|
|
77
92
|
})
|
|
@@ -87,6 +102,54 @@ export function unitLayout(groups, { template, matchesFor }) {
|
|
|
87
102
|
return slots
|
|
88
103
|
}
|
|
89
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The same, for groups part-way through.
|
|
107
|
+
*
|
|
108
|
+
* A round with two of its fixtures played is not a round of two with
|
|
109
|
+
* a hole where they were: the hole is a court standing empty and the
|
|
110
|
+
* group finishing later than it needs to. Two groups with three
|
|
111
|
+
* fixtures left each should finish together, this morning, rather
|
|
112
|
+
* than waiting out each other's gaps all day.
|
|
113
|
+
*
|
|
114
|
+
* So the remainder is packed rather than skipped. Each group's own
|
|
115
|
+
* matches keep their order and their numbers - the number is what a
|
|
116
|
+
* cell is booked as - but they are dealt out in turns of
|
|
117
|
+
* courtsPerRound, group after group, until everyone is done. A group
|
|
118
|
+
* with nothing left takes no turn.
|
|
119
|
+
*
|
|
120
|
+
* @param {Array} groups
|
|
121
|
+
* @param {Object} params see unitLayout
|
|
122
|
+
* @returns {Array<Array<{groupId: number, index: number}|null>>}
|
|
123
|
+
*/
|
|
124
|
+
function remainderLayout(groups, { template, matchesFor, done }) {
|
|
125
|
+
const width = Math.max(...template.map(round => round.pairs.length))
|
|
126
|
+
|
|
127
|
+
const left = groups.map(group => ({
|
|
128
|
+
id: group.id,
|
|
129
|
+
matches: matchesFor(group)
|
|
130
|
+
.map((match, index) => ({ ...match, index }))
|
|
131
|
+
.filter(match => !done(group.id, match.index)),
|
|
132
|
+
}))
|
|
133
|
+
|
|
134
|
+
const slots = []
|
|
135
|
+
|
|
136
|
+
while (left.some(group => group.matches.length > 0)) {
|
|
137
|
+
for (const group of left) {
|
|
138
|
+
if (group.matches.length === 0) {
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const turn = group.matches.splice(0, width)
|
|
143
|
+
|
|
144
|
+
slots.push(Array.from({ length: width }, (_, courtIndex) =>
|
|
145
|
+
turn[courtIndex] ? { groupId: group.id, index: turn[courtIndex].index } : null
|
|
146
|
+
))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return slots
|
|
151
|
+
}
|
|
152
|
+
|
|
90
153
|
/**
|
|
91
154
|
* Places an already-computed layout (see unitLayout) onto the lowest
|
|
92
155
|
* stretch of courts that is actually free, and the earliest start on
|
package/lib/planning-day.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
4
|
|
|
5
5
|
import { courtUsableAt, runFits } from './court-runs.mjs'
|
|
6
|
+
import { placeLayout } from './paired-court-planner.mjs'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* How long before dark an unlit court stops taking new play, where the
|
|
@@ -180,4 +181,168 @@ export class PlanningDay {
|
|
|
180
181
|
this.date, block.court_id, slot.start_minutes, slot.end_minutes
|
|
181
182
|
)
|
|
182
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Lays one unit of groups onto this day: its share of the unit's
|
|
187
|
+
* sequence for today, on the courts of its lane.
|
|
188
|
+
*
|
|
189
|
+
* @param {Object} unit
|
|
190
|
+
* @param {Function} unitLayoutFn Lays the unit's groups out in order,
|
|
191
|
+
* leaving out the fixtures its second argument says are done.
|
|
192
|
+
* @param {Array} fallbackSlots Used when the competition has none.
|
|
193
|
+
* @param {Function} done Whether a fixture is already in the plan.
|
|
194
|
+
* @returns {number|boolean} how many courts it took, or false
|
|
195
|
+
*/
|
|
196
|
+
placeGroupUnit(unit, unitLayoutFn, fallbackSlots, done) {
|
|
197
|
+
const { date } = this
|
|
198
|
+
const blocks = this.plan.blocks
|
|
199
|
+
const unitDates = unit.dates.slice(0, 2)
|
|
200
|
+
const dayIndex = unitDates.indexOf(date)
|
|
201
|
+
|
|
202
|
+
if (dayIndex === -1 || unit.placed.has(date)) {
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const unitSlots = this.slotsByCompetition
|
|
207
|
+
.get(unit.entries[0].competition.id) ?? fallbackSlots
|
|
208
|
+
|
|
209
|
+
if (unitSlots.length === 0 || unitSlots[0].phase === 'knockout') {
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const groups = unit.entries.map(entry => ({
|
|
214
|
+
id: entry.group.id,
|
|
215
|
+
teamIds: entry.group.team_ids,
|
|
216
|
+
groupSize: entry.group.pool_size,
|
|
217
|
+
}))
|
|
218
|
+
|
|
219
|
+
// Which fixtures belong to today is a question about the whole
|
|
220
|
+
// unit. Asked of what is left after some were played, the first
|
|
221
|
+
// half of the remainder is not today's half.
|
|
222
|
+
const sequence = unitLayoutFn(groups, () => false)
|
|
223
|
+
const firstDay = Math.floor(sequence.length / unitDates.length)
|
|
224
|
+
const share = dayIndex === 0
|
|
225
|
+
? sequence.slice(0, firstDay)
|
|
226
|
+
: sequence.slice(firstDay)
|
|
227
|
+
|
|
228
|
+
const fixtures = share.flat().filter(Boolean)
|
|
229
|
+
const owed = new Set(
|
|
230
|
+
fixtures
|
|
231
|
+
.filter(cell => !done(cell.groupId, cell.index))
|
|
232
|
+
.map(cell => `${cell.groupId}:${cell.index}`)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
const layout = owed.size === fixtures.length
|
|
236
|
+
? share
|
|
237
|
+
: unitLayoutFn(
|
|
238
|
+
groups, (groupId, index) => !owed.has(`${groupId}:${index}`)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
const before = blocks.length
|
|
242
|
+
|
|
243
|
+
const placed = placeLayout({
|
|
244
|
+
layout,
|
|
245
|
+
lookup: groupId => {
|
|
246
|
+
const entry = unit.entries.find(x => x.group.id === groupId)
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
id: entry.group.id,
|
|
250
|
+
competitionId: entry.competition.id,
|
|
251
|
+
divisionId: entry.group.division_id ?? null,
|
|
252
|
+
divisionName: entry.group.division_name ?? null,
|
|
253
|
+
divisionGender: entry.group.division_gender ?? null,
|
|
254
|
+
groupName: entry.group.name ?? null,
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
courts: this.courtsFor(unit.isPro),
|
|
258
|
+
slots: unitSlots,
|
|
259
|
+
date,
|
|
260
|
+
occupancy: this.occupancy,
|
|
261
|
+
darkFrom: this.darkFromMinutes,
|
|
262
|
+
blocks,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
if (placed) {
|
|
266
|
+
unit.placed.add(date)
|
|
267
|
+
return new Set(blocks.slice(before).map(block => block.court_id)).size
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return false
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Lays out the units that are due today, a lane at a time.
|
|
275
|
+
*
|
|
276
|
+
* A division gets a share of the lane's courts in proportion to its
|
|
277
|
+
* units, so one division cannot take the whole room before another
|
|
278
|
+
* has been looked at. What the shares left over is then offered to
|
|
279
|
+
* whoever has not been placed.
|
|
280
|
+
*
|
|
281
|
+
* @param {Array} units
|
|
282
|
+
* @param {Function} unitLayoutFn Lays a unit's groups out in order.
|
|
283
|
+
* @param {Array} fallbackSlots Used when a competition has none.
|
|
284
|
+
* @param {Function} done Whether a fixture is already in the plan.
|
|
285
|
+
*/
|
|
286
|
+
placeGroupUnits(units, unitLayoutFn, fallbackSlots, done) {
|
|
287
|
+
const { date } = this
|
|
288
|
+
|
|
289
|
+
for (const kind of [true, false]) {
|
|
290
|
+
const due = units.filter(unit =>
|
|
291
|
+
unit.isPro === kind && !unit.placed.has(date)
|
|
292
|
+
&& unit.dates.includes(date)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if (due.length === 0) {
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const order = []
|
|
300
|
+
|
|
301
|
+
for (const unit of due) {
|
|
302
|
+
if (!order.includes(unit.divisionId)) {
|
|
303
|
+
order.push(unit.divisionId)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const roomCourts = this.courtsFor(kind).length
|
|
308
|
+
|
|
309
|
+
const quota = new Map(order.map((divisionId, index) => {
|
|
310
|
+
const mine = due.filter(unit => unit.divisionId === divisionId)
|
|
311
|
+
const share = mine.length / due.length
|
|
312
|
+
|
|
313
|
+
return [
|
|
314
|
+
divisionId,
|
|
315
|
+
index === order.length - 1
|
|
316
|
+
? Infinity
|
|
317
|
+
: Math.max(2, Math.round(share * roomCourts)),
|
|
318
|
+
]
|
|
319
|
+
}))
|
|
320
|
+
|
|
321
|
+
const claimed = new Map()
|
|
322
|
+
|
|
323
|
+
for (const unit of due) {
|
|
324
|
+
const mine = claimed.get(unit.divisionId) ?? 0
|
|
325
|
+
|
|
326
|
+
if (mine >= (quota.get(unit.divisionId) ?? Infinity)) {
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const courtsUsed = this.placeGroupUnit(
|
|
331
|
+
unit, unitLayoutFn, fallbackSlots, done
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if (courtsUsed) {
|
|
335
|
+
claimed.set(
|
|
336
|
+
unit.divisionId, (claimed.get(unit.divisionId) ?? 0) + courtsUsed
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const unit of due) {
|
|
342
|
+
if (!unit.placed.has(date)) {
|
|
343
|
+
this.placeGroupUnit(unit, unitLayoutFn, fallbackSlots, done)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
183
348
|
}
|