@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,185 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared court-layout math for every planner shape (3/4/5-team groups,
|
|
7
|
+
* knockout): divisions in priority order get a contiguous band, a
|
|
8
|
+
* fraction of the court list, so the strongest field always sits on the
|
|
9
|
+
* lowest courts and its neighbour in priority order sits beside it
|
|
10
|
+
* rather than wherever a "first free court" search happens to land it.
|
|
11
|
+
*
|
|
12
|
+
* Lifted out of five-team-planner.mjs, where this was proven against a
|
|
13
|
+
* real tournament's worth of scheduling before any other planner shape
|
|
14
|
+
* had it at all.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} params
|
|
17
|
+
* @param {Array<string|number>} params.divisions Priority order - the
|
|
18
|
+
* strongest field first.
|
|
19
|
+
* @param {Map<string|number, number>} params.totals How many runs each
|
|
20
|
+
* division needs across every section, keyed by division id.
|
|
21
|
+
* @param {number[]} params.capacities How many courts each section can
|
|
22
|
+
* actually host (already filtered for lit/usable, per caller).
|
|
23
|
+
* @returns {{
|
|
24
|
+
* quotasBySection: Array<Map<string|number, number>>,
|
|
25
|
+
* bandsBySection: Array<Map<string|number, {start: number, end: number}>>
|
|
26
|
+
* }}
|
|
27
|
+
*/
|
|
28
|
+
export function computeSectionBands({ divisions, totals, capacities }) {
|
|
29
|
+
const sectionCount = capacities.length
|
|
30
|
+
const quotasBySection = []
|
|
31
|
+
const bandsBySection = []
|
|
32
|
+
const granted = new Map(divisions.map(id => [id, 0]))
|
|
33
|
+
|
|
34
|
+
for (let section = 0; section < sectionCount; section++) {
|
|
35
|
+
const quotas = new Map(divisions.map(id => [id, 0]))
|
|
36
|
+
let left = capacities[section] ?? 0
|
|
37
|
+
|
|
38
|
+
// One spot each, before anything is shared out proportionally, so a
|
|
39
|
+
// division missing from a section never leaves a group with nowhere
|
|
40
|
+
// to play.
|
|
41
|
+
for (const id of divisions) {
|
|
42
|
+
if (left <= 0) {
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
quotas.set(id, 1)
|
|
47
|
+
left -= 1
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const runsTotal = divisions.reduce((sum, id) => sum + (totals.get(id) ?? 0), 0)
|
|
51
|
+
|
|
52
|
+
if (runsTotal > 0 && left > 0) {
|
|
53
|
+
// Largest remainder over what is left after the floors.
|
|
54
|
+
const exact = divisions.map(id => (left * (totals.get(id) ?? 0)) / runsTotal)
|
|
55
|
+
|
|
56
|
+
divisions.forEach((id, index) => {
|
|
57
|
+
quotas.set(id, quotas.get(id) + Math.floor(exact[index]))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
let spare = left - exact.reduce((sum, value) => sum + Math.floor(value), 0)
|
|
61
|
+
|
|
62
|
+
const byRemainder = exact
|
|
63
|
+
.map((value, index) => ({ index, remainder: value - Math.floor(value) }))
|
|
64
|
+
.sort((a, b) => b.remainder - a.remainder)
|
|
65
|
+
|
|
66
|
+
for (const { index } of byRemainder) {
|
|
67
|
+
if (spare <= 0) {
|
|
68
|
+
break
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
quotas.set(divisions[index], quotas.get(divisions[index]) + 1)
|
|
72
|
+
spare -= 1
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A division may hold no more of a section than an even share of
|
|
77
|
+
// its day - otherwise one with few runs can be handed a generous
|
|
78
|
+
// quota early and be spent before later sections need it.
|
|
79
|
+
for (const id of divisions) {
|
|
80
|
+
const runs = totals.get(id) ?? 0
|
|
81
|
+
const evenShare = Math.max(1, Math.ceil(runs / sectionCount))
|
|
82
|
+
|
|
83
|
+
quotas.set(id, Math.min(quotas.get(id), evenShare))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Every court of the section is shared out here, not left to a
|
|
87
|
+
// fallback that knows nothing about the bands - that is what
|
|
88
|
+
// scattered a division's tail runs to the end of the row.
|
|
89
|
+
const sectionsLeft = sectionCount - section - 1
|
|
90
|
+
let room = (capacities[section] ?? 0) - divisions.reduce((sum, id) => sum + quotas.get(id), 0)
|
|
91
|
+
|
|
92
|
+
const headroom = id => (totals.get(id) ?? 0) - granted.get(id) - quotas.get(id)
|
|
93
|
+
|
|
94
|
+
while (room > 0) {
|
|
95
|
+
const able = divisions.filter(id => headroom(id) > sectionsLeft)
|
|
96
|
+
|
|
97
|
+
if (able.length === 0) {
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const next = able.reduce((a, b) => (headroom(b) > headroom(a) ? b : a))
|
|
102
|
+
|
|
103
|
+
quotas.set(next, quotas.get(next) + 1)
|
|
104
|
+
room -= 1
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The band a division sits in, as a fraction of the section's own
|
|
108
|
+
// courts, straight from what it was just given. Divisions come in
|
|
109
|
+
// priority order, so the strongest field takes the lowest courts
|
|
110
|
+
// and its neighbour in priority sits beside it.
|
|
111
|
+
const placed = divisions.reduce((sum, id) => sum + quotas.get(id), 0)
|
|
112
|
+
let filled = 0
|
|
113
|
+
const bands = new Map()
|
|
114
|
+
|
|
115
|
+
for (const id of divisions) {
|
|
116
|
+
granted.set(id, granted.get(id) + quotas.get(id))
|
|
117
|
+
|
|
118
|
+
const start = placed === 0 ? 0 : filled / placed
|
|
119
|
+
filled += quotas.get(id)
|
|
120
|
+
const end = placed === 0 ? 1 : filled / placed
|
|
121
|
+
|
|
122
|
+
bands.set(id, { start, end })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
quotasBySection.push(quotas)
|
|
126
|
+
bandsBySection.push(bands)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { quotasBySection, bandsBySection }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* How far a court (as a fraction of its list) sits from a division's
|
|
134
|
+
* band. Zero inside the band; a band is an interval, not a point, so a
|
|
135
|
+
* court just past either edge is still closer than one at the far end
|
|
136
|
+
* of the row.
|
|
137
|
+
*/
|
|
138
|
+
/**
|
|
139
|
+
* Chops a day's slots into named sections - S1, S2, S3, S4 - the unit
|
|
140
|
+
* every planner shape works in, not clock times but a whole run's worth
|
|
141
|
+
* of court time (a 5-team run, a 4-team round, a knockout round). Every
|
|
142
|
+
* planner needs the same answer to "what section is this slot in, and
|
|
143
|
+
* is it played under lights" instead of working it out its own way.
|
|
144
|
+
*
|
|
145
|
+
* Only a section that can hold a whole run counts - rounding up
|
|
146
|
+
* invents a final section out of the few slots left over, and whatever
|
|
147
|
+
* gets shared out to it is then unplaceable.
|
|
148
|
+
*
|
|
149
|
+
* @param {Array} slots The day's slots for one competition, in order.
|
|
150
|
+
* @param {number} runLength How many slots one run/round occupies.
|
|
151
|
+
* @param {Object} [context]
|
|
152
|
+
* @param {Array} [context.courts] The courts these runs may use, so a
|
|
153
|
+
* section's capacity can be reported alongside it.
|
|
154
|
+
* @param {Function} [context.usable] (court, slot) => boolean
|
|
155
|
+
* @returns {Array<{index: number, label: string, slots: Array, night: boolean, capacity: number}>}
|
|
156
|
+
*/
|
|
157
|
+
export function sectionsFor(slots, runLength, { courts = [], usable = () => true } = {}) {
|
|
158
|
+
const sectionCount = Math.max(1, Math.floor(slots.length / runLength))
|
|
159
|
+
const sections = []
|
|
160
|
+
|
|
161
|
+
for (let index = 0; index < sectionCount; index++) {
|
|
162
|
+
const first = index * runLength
|
|
163
|
+
const own = slots.slice(first, first + runLength)
|
|
164
|
+
|
|
165
|
+
sections.push({
|
|
166
|
+
index,
|
|
167
|
+
label: `S${index + 1}`,
|
|
168
|
+
slots: own,
|
|
169
|
+
night: own.some(slot => slot.lit_required),
|
|
170
|
+
capacity: own.length === 0
|
|
171
|
+
? 0
|
|
172
|
+
: courts.filter(court => own.every(slot => usable(court, slot))).length,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return sections
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function bandDistance(position, band) {
|
|
180
|
+
if (position >= band.start && position < band.end) {
|
|
181
|
+
return 0
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return Math.min(Math.abs(position - band.start), Math.abs(position - band.end))
|
|
185
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether a court can host a run of consecutive slots.
|
|
7
|
+
*
|
|
8
|
+
* Lifted out of auto-planner.mjs, where the group planner has always
|
|
9
|
+
* asked this before taking a court: a run goes on a court only when
|
|
10
|
+
* the whole run fits there, never a first slot now and the rest
|
|
11
|
+
* wherever happens to be free later.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether a court may be played on in this slot at all.
|
|
16
|
+
*
|
|
17
|
+
* @param {{lit: boolean, availableFromMinutes: ?number}} court
|
|
18
|
+
* @param {{lit_required: boolean, start_minutes: number}} slot
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function courtUsableAt(court, slot) {
|
|
22
|
+
if (slot.lit_required && !court.lit) {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A court opened up for the day only from a given time onward (see
|
|
27
|
+
// openCourtsFor) - baked onto the court object itself, so this needs
|
|
28
|
+
// no date of its own to enforce it.
|
|
29
|
+
const opensAt = court.availableFromMinutes
|
|
30
|
+
|
|
31
|
+
if (opensAt != null && slot.start_minutes < opensAt) {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a run of runLength slots, starting at slotIndex, fits on
|
|
40
|
+
* this court end to end.
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} court
|
|
43
|
+
* @param {Object} context
|
|
44
|
+
* @param {Array} context.slots The asking competition's own slots.
|
|
45
|
+
* @param {number} context.slotIndex Where the run starts.
|
|
46
|
+
* @param {string} context.date
|
|
47
|
+
* @param {Object} context.taken Occupancy (see occupancy.mjs).
|
|
48
|
+
* @param {boolean} [context.requireLit] The run must touch a lit slot.
|
|
49
|
+
* @param {number} runLength
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function runFits(
|
|
53
|
+
court, { slots, slotIndex, date, taken, requireLit }, runLength
|
|
54
|
+
) {
|
|
55
|
+
if (slotIndex + runLength > slots.length) {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let touchesLit = false
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < runLength; i++) {
|
|
62
|
+
const slot = slots[slotIndex + i]
|
|
63
|
+
|
|
64
|
+
const vacant = taken.free(
|
|
65
|
+
date, court.id, slot.start_minutes, slot.end_minutes
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if (!courtUsableAt(court, slot) || !vacant) {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
touchesLit = touchesLit || slot.lit_required
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return requireLit ? touchesLit : true
|
|
76
|
+
}
|
package/lib/day-rows.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { slotsForDate } from './auto-planner.mjs'
|
|
6
|
+
|
|
7
|
+
/** What an hour is worth when nothing says otherwise. */
|
|
8
|
+
const FALLBACK_SLOT_MINUTES = 45
|
|
9
|
+
|
|
10
|
+
function timeToMinutes(time) {
|
|
11
|
+
const [hours, minutes] = String(time).split(':').map(Number)
|
|
12
|
+
|
|
13
|
+
return hours * 60 + (minutes ?? 0)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function minutesToTime(minutes) {
|
|
17
|
+
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}`
|
|
18
|
+
+ `:${String(minutes % 60).padStart(2, '0')}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The rows one day of the grid has, in order.
|
|
23
|
+
*
|
|
24
|
+
* Competitions do not share a clock: a 90 minute tie and a 45 minute
|
|
25
|
+
* match starting the same morning sit on different hours, so a day
|
|
26
|
+
* carries each competition's own lattice rather than one grid for the
|
|
27
|
+
* event. Planning and Scheduling draw the same rows by asking here.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} params
|
|
30
|
+
* @param {string} params.date
|
|
31
|
+
* @param {Array} params.competitions Each with its own sessions.
|
|
32
|
+
* @param {Array} params.cells What is already booked that day, so a
|
|
33
|
+
* cell is never stranded on a row the sessions no longer describe.
|
|
34
|
+
* @param {Array} [params.fallbackSlots] Used only where the day has no
|
|
35
|
+
* session at all, so there is still something to drag onto.
|
|
36
|
+
* @returns {Array<{key: string, label: string, start_minutes: number,
|
|
37
|
+
* end_minutes: number}>}
|
|
38
|
+
*/
|
|
39
|
+
export function rowsForDate({ date, competitions, cells, fallbackSlots = [] }) {
|
|
40
|
+
|
|
41
|
+
const byKey = new Map()
|
|
42
|
+
|
|
43
|
+
// Only competitions that open on this date, each on its own clock.
|
|
44
|
+
for (const competition of competitions ?? []) {
|
|
45
|
+
const sessions = [...(competition.sessions ?? [])]
|
|
46
|
+
.sort((a, b) =>
|
|
47
|
+
(a.date + a.start_time).localeCompare(b.date + b.start_time)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if (!sessions.some(session => session.date === date)) {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// From the hour it holds a court, not the hour its session
|
|
55
|
+
// opens: the leading empty rows of a competition that waits are
|
|
56
|
+
// dropped. What follows its last cell stays.
|
|
57
|
+
const mine = cells
|
|
58
|
+
.filter(cell =>
|
|
59
|
+
cell.date === date && cell.competition_id === competition.id
|
|
60
|
+
)
|
|
61
|
+
.map(cell => cell.slot)
|
|
62
|
+
.sort()
|
|
63
|
+
|
|
64
|
+
for (const slot of slotsForDate(sessions, date, null)) {
|
|
65
|
+
if (mine.length > 0 && slot.key < mine[0]) {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Two competitions can open at the same minute with different
|
|
70
|
+
// playtimes. The row takes the finer of the two; a coarser
|
|
71
|
+
// competition's own cell still draws its full span.
|
|
72
|
+
const existing = byKey.get(slot.key)
|
|
73
|
+
const duration = slot.end_minutes - slot.start_minutes
|
|
74
|
+
|
|
75
|
+
const finer = existing
|
|
76
|
+
&& existing.end_minutes - existing.start_minutes <= duration
|
|
77
|
+
|
|
78
|
+
if (finer) {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
byKey.set(slot.key, {
|
|
83
|
+
...slot,
|
|
84
|
+
label: `${slot.key}-${minutesToTime(slot.end_minutes)}`,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A day nothing is configured for still needs a grid to drag on.
|
|
90
|
+
if (byKey.size === 0) {
|
|
91
|
+
for (const slot of fallbackSlots ?? []) {
|
|
92
|
+
byKey.set(slot.key, slot)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Whatever is already booked, so a block is never stranded on a
|
|
97
|
+
// row the sessions no longer describe - a competition whose
|
|
98
|
+
// knockout runs past the day its session opens, or a plan saved
|
|
99
|
+
// before the sessions were changed. It still needs a real span on
|
|
100
|
+
// the timeline, not just a label, so it gets one at the fallback
|
|
101
|
+
// length.
|
|
102
|
+
for (const cell of cells) {
|
|
103
|
+
if (cell.date === date && !byKey.has(cell.slot)) {
|
|
104
|
+
const start = timeToMinutes(cell.slot)
|
|
105
|
+
|
|
106
|
+
byKey.set(cell.slot, {
|
|
107
|
+
key: cell.slot,
|
|
108
|
+
label: cell.slot,
|
|
109
|
+
start_minutes: start,
|
|
110
|
+
end_minutes: start + FALLBACK_SLOT_MINUTES,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key))
|
|
116
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The angle successive divisions are spaced by, in degrees.
|
|
7
|
+
*
|
|
8
|
+
* The golden angle rather than an even split of the circle: an even
|
|
9
|
+
* split over six lands them all in the same part of the spectrum,
|
|
10
|
+
* which reads as six shades of green.
|
|
11
|
+
*/
|
|
12
|
+
const GOLDEN_ANGLE = 137.508
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A hue per division, in the order the competitions list them.
|
|
16
|
+
*
|
|
17
|
+
* A hue, not a colour. A board decides which divisions should look
|
|
18
|
+
* different from each other; how strongly that reads is the page's
|
|
19
|
+
* business, so the hue goes out as a custom property and the
|
|
20
|
+
* stylesheet picks saturation and lightness to suit its theme.
|
|
21
|
+
*
|
|
22
|
+
* Planning and Scheduling both draw it, from the same order, so a
|
|
23
|
+
* division a director learned the colour of on one board is that
|
|
24
|
+
* colour on the other.
|
|
25
|
+
*
|
|
26
|
+
* @param {Array<{divisions: ?Array<number|{id: number}>}>} competitions
|
|
27
|
+
* @returns {Map<number, number>}
|
|
28
|
+
*/
|
|
29
|
+
export function divisionHues(competitions) {
|
|
30
|
+
const divisionIds = competitions.flatMap(
|
|
31
|
+
competition => (competition.divisions ?? [])
|
|
32
|
+
.map(division => division?.id ?? division)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return new Map(
|
|
36
|
+
divisionIds.map((id, index) => [
|
|
37
|
+
id, Math.round((index * GOLDEN_ANGLE) % 360),
|
|
38
|
+
])
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The hue a competition falls back to where its divisions are not
|
|
44
|
+
* known - an even split of the circle, since there are few of them.
|
|
45
|
+
*
|
|
46
|
+
* @param {number} index
|
|
47
|
+
* @param {number} total
|
|
48
|
+
* @returns {?number}
|
|
49
|
+
*/
|
|
50
|
+
export function competitionHue(index, total) {
|
|
51
|
+
return index < 0 ? null : Math.round((index * 360) / Math.max(1, total))
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { makeRunQueuePlanner } from './run-queue-planner.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A 5-team division's group play: five teams, one court to itself, the
|
|
9
|
+
* whole ten-match round robin run off across two days as one unbroken
|
|
10
|
+
* stretch per day.
|
|
11
|
+
*
|
|
12
|
+
* This is a thin, shape-named entry point onto run-queue-planner.mjs's
|
|
13
|
+
* shared engine - see that module for how enqueue/prepare/pick/commit
|
|
14
|
+
* actually work. Kept as its own module (rather than callers reaching
|
|
15
|
+
* for makeRunQueuePlanner directly) so a 5-team competition's planner
|
|
16
|
+
* stays its own testable unit, distinct from three-team-planner.mjs's,
|
|
17
|
+
* even though today they share every line of implementation.
|
|
18
|
+
*/
|
|
19
|
+
export function makeFiveTeamPlanner() {
|
|
20
|
+
return makeRunQueuePlanner()
|
|
21
|
+
}
|
package/lib/flags.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import twemoji from '@twemoji/api'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Writes a name, drawing the flag in front of it as an image where the
|
|
9
|
+
* platform will not draw it as a flag.
|
|
10
|
+
*
|
|
11
|
+
* Windows has no flag glyphs: a regional indicator pair renders as the
|
|
12
|
+
* two letters of the country code, so "AW Alice" where every other
|
|
13
|
+
* platform shows a flag. Parsing the emoji into Twemoji's SVG puts the
|
|
14
|
+
* flag back.
|
|
15
|
+
*
|
|
16
|
+
* @param {HTMLElement} element
|
|
17
|
+
* @param {string} value
|
|
18
|
+
* @param {string} [mode] always, never, or windows (the default):
|
|
19
|
+
* parse only where the platform needs it.
|
|
20
|
+
* @returns {void}
|
|
21
|
+
*/
|
|
22
|
+
export function setNameText(element, value, mode = 'windows') {
|
|
23
|
+
element.textContent = value
|
|
24
|
+
|
|
25
|
+
if (!shouldParseFlags(mode)) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
twemoji.parse(element, { folder: 'svg', ext: '.svg' })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} mode
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
export function shouldParseFlags(mode = 'windows') {
|
|
37
|
+
const setting = String(mode || 'windows').toLowerCase()
|
|
38
|
+
|
|
39
|
+
if (setting === 'always') {
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (setting === 'never') {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return isWindows()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
export function isWindows() {
|
|
54
|
+
if (typeof navigator === 'undefined') {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Boolean(navigator.platform?.startsWith('Win'))
|
|
59
|
+
|| Boolean(navigator.userAgent?.includes('Windows'))
|
|
60
|
+
}
|