@opndev/tennis-match-center 0.0.1 → 0.0.2
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 +26 -0
- package/lib/auto-planner.mjs +5 -32
- package/lib/components/day-grid.mjs +288 -0
- package/lib/components/gameday-board.mjs +1691 -0
- package/lib/components/gameday-card.mjs +325 -0
- package/lib/components/gameday-list.mjs +199 -0
- package/lib/components/match.mjs +368 -0
- package/lib/components/participant.mjs +101 -0
- package/lib/components/planning-board.mjs +914 -131
- package/lib/components/schedule-board.mjs +219 -50
- package/lib/day-rows.mjs +19 -3
- package/lib/gameday-dialog.mjs +93 -0
- package/lib/gameday-facts.mjs +305 -0
- package/lib/gameday-next.mjs +164 -0
- package/lib/gameday-parts.mjs +112 -0
- package/lib/index.mjs +1 -3
- package/lib/knockout-draw.mjs +4 -0
- package/lib/knockout-planner.mjs +99 -5
- package/lib/lighting.mjs +50 -4
- package/lib/plan-placement.mjs +54 -3
- package/lib/planning-day.mjs +15 -9
- package/lib/webcomponents.mjs +12 -0
- package/package.json +6 -1
- package/opndev-tennis-match-center-0.0.1.tgz +0 -0
|
@@ -3,25 +3,58 @@
|
|
|
3
3
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
4
|
|
|
5
5
|
import { HTMLElementSugar } from '@skirbi/sugar'
|
|
6
|
-
import { withLitRequired } from '../lighting.mjs'
|
|
6
|
+
import { courtCanTake, toMinutes, withLitRequired } from '../lighting.mjs'
|
|
7
7
|
import { autoFillPlanning, activeSession } from '../auto-planner.mjs'
|
|
8
8
|
import { rowsForDate } from '../day-rows.mjs'
|
|
9
9
|
import { withBusyButton } from '../busy-button.mjs'
|
|
10
10
|
import { competitionHue, divisionHues } from '../division-hues.mjs'
|
|
11
|
+
import { TennisDayGridElement } from './day-grid.mjs'
|
|
12
|
+
import { fourTeamMatches, fourTeamUnitLayout } from '../four-team-planner.mjs'
|
|
13
|
+
import { threeTeamUnitLayout } from '../three-team-planner.mjs'
|
|
14
|
+
import { unitLayout } from '../paired-court-planner.mjs'
|
|
15
|
+
import { GROUP_TEMPLATES, templateMatches } from '../group-schedule.mjs'
|
|
11
16
|
|
|
12
17
|
const SAVING = { busy: 'Saving…', done: 'Saved', failed: 'Save failed' }
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The slots the planner knows how to lay: a group shape, how many
|
|
21
|
+
* groups share the court set, and how many courts that set is. Rest
|
|
22
|
+
* is what the sharing buys - a group waits out the others' turns
|
|
23
|
+
* instead of playing its own matches back to back.
|
|
24
|
+
*/
|
|
25
|
+
// How much darker every other turn of a slot is drawn, in percent of
|
|
26
|
+
// lightness - see markRuns.
|
|
27
|
+
const WASH_STEP = 10
|
|
28
|
+
|
|
29
|
+
// What Clear takes off a region: the naming, not the cells. The
|
|
30
|
+
// competition and phase are what painting put there.
|
|
31
|
+
const UNNAMED = {
|
|
32
|
+
division_id: null,
|
|
33
|
+
division_name: null,
|
|
34
|
+
division_gender: null,
|
|
35
|
+
group_id: null,
|
|
36
|
+
group_name: null,
|
|
37
|
+
round: null,
|
|
38
|
+
match_slot: null,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SHAPES = [
|
|
42
|
+
{ teams: 3, label: 'no rest', count: 1, courts: 1 },
|
|
43
|
+
{ teams: 3, label: 'rest', count: 2, courts: 1 },
|
|
44
|
+
{ teams: 4, label: '2 on 2', count: 2, courts: 2 },
|
|
45
|
+
{ teams: 4, label: '3 on 2', count: 3, courts: 2 },
|
|
46
|
+
{ teams: 5, label: '5 on 1', count: 1, courts: 1 },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
function teamIdsFor(group) {
|
|
50
|
+
return Array.from({ length: group.team_count ?? group.pool_size ?? 0 }, (_, i) => i + 1)
|
|
51
|
+
}
|
|
52
|
+
|
|
14
53
|
// A row's fallback length when a booked block's slot no longer matches
|
|
15
54
|
// any configured session (spec.md never promises one) - just enough to
|
|
16
55
|
// give it a real height on the timeline rather than none at all.
|
|
17
56
|
const FALLBACK_SLOT_MINUTES = 45
|
|
18
57
|
|
|
19
|
-
// Pixels per minute of real time on the timeline. A 45-minute slot -
|
|
20
|
-
// the shortest common match length - needs room for four lines
|
|
21
|
-
// (competition, division, group, round) plus padding, not just the
|
|
22
|
-
// one or two a bare label used to need.
|
|
23
|
-
const PX_PER_MINUTE = 64 / 45
|
|
24
|
-
|
|
25
58
|
/**
|
|
26
59
|
* <tennis-planning-board api="..." save-api="...">
|
|
27
60
|
*
|
|
@@ -83,6 +116,11 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
83
116
|
// equal parts rather than allowed to run into the small hours or
|
|
84
117
|
// spill onto the next morning - see auto-planner.mjs's own retry.
|
|
85
118
|
'day-end': {},
|
|
119
|
+
|
|
120
|
+
// Minutes before dark-from that an unlit court stops taking new
|
|
121
|
+
// play, so nothing is booked to end just as the light goes. Zero
|
|
122
|
+
// holds an unlit court open right up to dark-from.
|
|
123
|
+
'light-buffer': {},
|
|
86
124
|
}
|
|
87
125
|
|
|
88
126
|
static HtmlTemplate = ['tennis-planning-board-template', function () {
|
|
@@ -107,8 +145,13 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
107
145
|
<input data-show-data type="checkbox">
|
|
108
146
|
Show data
|
|
109
147
|
</label>
|
|
148
|
+
<label data-narrow-toggle hidden>
|
|
149
|
+
<input data-narrow-empty type="checkbox">
|
|
150
|
+
Narrow empty courts
|
|
151
|
+
</label>
|
|
110
152
|
<pre data-plan hidden></pre>
|
|
111
153
|
<div data-schedule hidden></div>
|
|
154
|
+
<planning-menu data-menu hidden></planning-menu>
|
|
112
155
|
<button data-save type="button" hidden>Save planning</button>
|
|
113
156
|
</div>
|
|
114
157
|
`)
|
|
@@ -140,6 +183,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
140
183
|
this.$('[data-registration-closed]').addEventListener('change', () => this.render())
|
|
141
184
|
this.$('[data-always-bye-short-groups]').addEventListener('change', () => this.render())
|
|
142
185
|
this.$('[data-show-data]').addEventListener('change', () => this.render())
|
|
186
|
+
this.$('[data-narrow-empty]').addEventListener('change', () => this.render())
|
|
143
187
|
this.$('[data-clear-all]').addEventListener('click', () => this.clearAll())
|
|
144
188
|
this.$('[data-clear-day]').addEventListener('click', () => {
|
|
145
189
|
const activeDate = this.$('[data-schedule] semtic-tabbed')?.getAttribute('current')
|
|
@@ -151,12 +195,24 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
151
195
|
// ends the selection instead of leaving it stuck "selecting".
|
|
152
196
|
window.addEventListener('mouseup', () => {
|
|
153
197
|
this.isSelecting = false
|
|
198
|
+
this.drop()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
window.addEventListener('mousedown', event => {
|
|
202
|
+
if (!this.$('[data-menu]').contains(event.target)) {
|
|
203
|
+
this.closeMenu()
|
|
204
|
+
}
|
|
154
205
|
})
|
|
155
206
|
|
|
156
207
|
// Backspace/Delete clears the current selection - the same
|
|
157
208
|
// action as clicking "Clear selection" in the legend, just
|
|
158
209
|
// faster for a director working through a lot of cells.
|
|
159
210
|
window.addEventListener('keydown', event => {
|
|
211
|
+
if (event.key === 'Escape') {
|
|
212
|
+
this.closeMenu()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
if (event.key !== 'Backspace' && event.key !== 'Delete') {
|
|
161
217
|
return
|
|
162
218
|
}
|
|
@@ -283,9 +339,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
283
339
|
}
|
|
284
340
|
|
|
285
341
|
isSelectable(court, slot) {
|
|
286
|
-
|
|
287
|
-
// non-lit court cannot be assigned to the evening slot".
|
|
288
|
-
return !(slot.lit_required && !court.lit)
|
|
342
|
+
return courtCanTake(court, slot)
|
|
289
343
|
}
|
|
290
344
|
|
|
291
345
|
cellKey(row, col) {
|
|
@@ -343,16 +397,18 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
343
397
|
* worth of cells. Scoped to the active day only.
|
|
344
398
|
*/
|
|
345
399
|
paintSelection() {
|
|
346
|
-
|
|
400
|
+
this.gridFor(this.selectionDate)?.paint(this.selection)
|
|
401
|
+
}
|
|
347
402
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
403
|
+
gridFor(date) {
|
|
404
|
+
return this.$(`tennis-day-grid[data-day="${date}"]`)
|
|
405
|
+
}
|
|
351
406
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
407
|
+
showDrag() {
|
|
408
|
+
this.gridFor(this.drag?.date ?? this.selectionDate)?.showDrag(
|
|
409
|
+
this.drag ? new Set(this.drag.keys) : null,
|
|
410
|
+
this.drag?.over ? this.cellKey(this.drag.over.row, this.drag.over.col) : null
|
|
411
|
+
)
|
|
356
412
|
}
|
|
357
413
|
|
|
358
414
|
/**
|
|
@@ -398,6 +454,682 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
398
454
|
this.render()
|
|
399
455
|
}
|
|
400
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Picks up what is under the cursor: the whole selection if the
|
|
459
|
+
* grabbed cell is part of it, otherwise that one block.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} date
|
|
462
|
+
* @param {number} row
|
|
463
|
+
* @param {number} col
|
|
464
|
+
*/
|
|
465
|
+
startDrag(date, row, col) {
|
|
466
|
+
const grabbed = this.cellKey(row, col)
|
|
467
|
+
const keys = this.selectionDate === date && this.selection.has(grabbed)
|
|
468
|
+
? [...this.selection]
|
|
469
|
+
: [grabbed]
|
|
470
|
+
|
|
471
|
+
const rows = this.rowsFor(date)
|
|
472
|
+
|
|
473
|
+
this.drag = {
|
|
474
|
+
date,
|
|
475
|
+
row,
|
|
476
|
+
col,
|
|
477
|
+
over: null,
|
|
478
|
+
keys: keys.filter(key => {
|
|
479
|
+
const [r, c] = key.split(',').map(Number)
|
|
480
|
+
|
|
481
|
+
return this.blockAt(this.state.courts[c].id, date, rows[r].key)
|
|
482
|
+
}),
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
this.showDrag()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Drops what is being dragged, moving every block by the same
|
|
490
|
+
* row/court step. A step that would land on a blocked cell, or on a
|
|
491
|
+
* block that is staying put, is refused outright: nothing is
|
|
492
|
+
* overwritten and nothing moves halfway.
|
|
493
|
+
*/
|
|
494
|
+
drop() {
|
|
495
|
+
const drag = this.drag
|
|
496
|
+
|
|
497
|
+
this.drag = null
|
|
498
|
+
this.gridFor(drag?.date ?? this.selectionDate)?.showDrag(null, null)
|
|
499
|
+
|
|
500
|
+
if (!drag?.over || !drag.keys.length) {
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const rowStep = drag.over.row - drag.row
|
|
505
|
+
const colStep = drag.over.col - drag.col
|
|
506
|
+
|
|
507
|
+
if (rowStep === 0 && colStep === 0) {
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const rows = this.rowsFor(drag.date)
|
|
512
|
+
const moving = new Set(drag.keys)
|
|
513
|
+
const moves = []
|
|
514
|
+
|
|
515
|
+
for (const key of drag.keys) {
|
|
516
|
+
const [row, col] = key.split(',').map(Number)
|
|
517
|
+
const toRow = row + rowStep
|
|
518
|
+
const toCol = col + colStep
|
|
519
|
+
const court = this.state.courts[toCol]
|
|
520
|
+
const slot = rows[toRow]
|
|
521
|
+
|
|
522
|
+
if (!court || !slot || !this.isSelectable(court, slot)) {
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const sitting = this.blockAt(court.id, drag.date, slot.key)
|
|
527
|
+
|
|
528
|
+
if (sitting && !moving.has(this.cellKey(toRow, toCol))) {
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
moves.push({
|
|
533
|
+
block: this.blockAt(this.state.courts[col].id, drag.date, rows[row].key),
|
|
534
|
+
courtId: court.id,
|
|
535
|
+
slot: slot.key,
|
|
536
|
+
key: this.cellKey(toRow, toCol),
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
for (const move of moves) {
|
|
541
|
+
move.block.court_id = move.courtId
|
|
542
|
+
move.block.slot = move.slot
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
this.selection = new Set(moves.map(move => move.key))
|
|
546
|
+
this.selectionDate = drag.date
|
|
547
|
+
|
|
548
|
+
this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
|
|
549
|
+
bubbles: true,
|
|
550
|
+
detail: { blocks: this.blocks },
|
|
551
|
+
}))
|
|
552
|
+
|
|
553
|
+
this.render()
|
|
554
|
+
this.paintSelection()
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* A group's own matches, off the same templates the planner lays
|
|
559
|
+
* out from.
|
|
560
|
+
*
|
|
561
|
+
* @param {Object} group
|
|
562
|
+
* @returns {Array}
|
|
563
|
+
*/
|
|
564
|
+
groupMatches(group) {
|
|
565
|
+
const teamIds = teamIdsFor(group)
|
|
566
|
+
|
|
567
|
+
return group.pool_size === 4
|
|
568
|
+
? fourTeamMatches({ teamIds })
|
|
569
|
+
: templateMatches({ teamIds, groupSize: group.pool_size })
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* How much room the painted region has, in courts and slots.
|
|
574
|
+
*
|
|
575
|
+
* @returns {Object}
|
|
576
|
+
*/
|
|
577
|
+
selectionSize() {
|
|
578
|
+
const cells = [...this.selection].map(key => key.split(',').map(Number))
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
courts: new Set(cells.map(([, col]) => col)).size,
|
|
582
|
+
slots: new Set(cells.map(([row]) => row)).size,
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The competition's groups that still owe court time. A group
|
|
588
|
+
* already holding all its cells has nothing left to slot.
|
|
589
|
+
*
|
|
590
|
+
* @param {Object} competition
|
|
591
|
+
* @returns {Array}
|
|
592
|
+
*/
|
|
593
|
+
owingGroups(competition) {
|
|
594
|
+
return (competition.groups ?? []).filter(group => {
|
|
595
|
+
const needed = this.groupMatches(group).length
|
|
596
|
+
const planned = this.blocks.filter(block => block.group_id === group.id).length
|
|
597
|
+
|
|
598
|
+
return needed > 0 && planned < needed
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The slots the planner could lay into the painted region, by group
|
|
604
|
+
* shape. Only shapes with groups left to place, and only groups
|
|
605
|
+
* that still owe court time. Anything else is manual.
|
|
606
|
+
*
|
|
607
|
+
* @returns {Array}
|
|
608
|
+
*/
|
|
609
|
+
slotOptions(context) {
|
|
610
|
+
if (context?.phase !== 'group') {
|
|
611
|
+
return []
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const competition = this.state.competitions.find(c => c.id === context.competitionId)
|
|
615
|
+
|
|
616
|
+
if (!competition) {
|
|
617
|
+
return []
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const owing = this.owingGroups(competition)
|
|
621
|
+
const region = this.selectionSize()
|
|
622
|
+
|
|
623
|
+
return SHAPES
|
|
624
|
+
.map(shape => {
|
|
625
|
+
const groups = owing.filter(group => group.pool_size === shape.teams)
|
|
626
|
+
|
|
627
|
+
if (groups.length < shape.count) {
|
|
628
|
+
return null
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const slots = (this.groupMatches(groups[0]).length / shape.courts) * shape.count
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
...shape,
|
|
635
|
+
groups,
|
|
636
|
+
slots,
|
|
637
|
+
fits: region.courts >= shape.courts && region.slots >= slots,
|
|
638
|
+
}
|
|
639
|
+
})
|
|
640
|
+
.filter(Boolean)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* How the planner lays this shape out: 4-team takes a pair of
|
|
645
|
+
* courts, 3-team and 5-team one, and every group in the unit takes
|
|
646
|
+
* its turn on it.
|
|
647
|
+
*
|
|
648
|
+
* @param {Array} groups
|
|
649
|
+
* @returns {Array}
|
|
650
|
+
*/
|
|
651
|
+
layoutFor(groups) {
|
|
652
|
+
const unit = groups.map(group => ({ id: group.id, teamIds: teamIdsFor(group) }))
|
|
653
|
+
|
|
654
|
+
if (groups[0].pool_size === 4) {
|
|
655
|
+
return fourTeamUnitLayout(unit)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (groups[0].pool_size === 3) {
|
|
659
|
+
return threeTeamUnitLayout(unit)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return unitLayout(unit, {
|
|
663
|
+
template: GROUP_TEMPLATES[5],
|
|
664
|
+
matchesFor: group => templateMatches({ teamIds: group.teamIds, groupSize: 5 }),
|
|
665
|
+
})
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Lays the chosen groups into the region, turn by turn, exactly as
|
|
670
|
+
* the planner would place them.
|
|
671
|
+
*
|
|
672
|
+
* @param {Array} groups
|
|
673
|
+
*/
|
|
674
|
+
createSlot(groups, context) {
|
|
675
|
+
if (!groups.length || !context) {
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const layout = this.layoutFor(groups)
|
|
680
|
+
|
|
681
|
+
const date = this.selectionDate
|
|
682
|
+
const rows = this.rowsFor(date)
|
|
683
|
+
const cells = [...this.selection].map(key => key.split(',').map(Number))
|
|
684
|
+
const rowIndexes = [...new Set(cells.map(([row]) => row))].sort((a, b) => a - b)
|
|
685
|
+
const colIndexes = [...new Set(cells.map(([, col]) => col))].sort((a, b) => a - b)
|
|
686
|
+
const width = Math.max(...layout.map(slot => slot.length))
|
|
687
|
+
|
|
688
|
+
if (colIndexes.length < width || rowIndexes.length < layout.length) {
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const competitionId = context.competitionId
|
|
693
|
+
|
|
694
|
+
for (const [slotIndex, slot] of layout.entries()) {
|
|
695
|
+
for (let courtIndex = 0; courtIndex < width; courtIndex++) {
|
|
696
|
+
const court = this.state.courts[colIndexes[courtIndex]]
|
|
697
|
+
const row = rows[rowIndexes[slotIndex]]
|
|
698
|
+
|
|
699
|
+
this.blocks = this.blocks.filter(block =>
|
|
700
|
+
!(block.court_id === court.id && block.date === date && block.slot === row.key)
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
const entry = slot[courtIndex]
|
|
704
|
+
|
|
705
|
+
if (!entry) {
|
|
706
|
+
continue
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const group = groups.find(candidate => candidate.id === entry.groupId)
|
|
710
|
+
|
|
711
|
+
this.blocks.push({
|
|
712
|
+
id: null,
|
|
713
|
+
court_id: court.id,
|
|
714
|
+
date,
|
|
715
|
+
slot: row.key,
|
|
716
|
+
phase: 'group',
|
|
717
|
+
competition_id: competitionId,
|
|
718
|
+
division_id: group.division_id,
|
|
719
|
+
division_name: group.division_name,
|
|
720
|
+
division_gender: group.division_gender,
|
|
721
|
+
group_id: group.id,
|
|
722
|
+
group_name: group.name,
|
|
723
|
+
match_slot: entry.index,
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
this.closeMenu()
|
|
729
|
+
|
|
730
|
+
this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
|
|
731
|
+
bubbles: true,
|
|
732
|
+
detail: { blocks: this.blocks },
|
|
733
|
+
}))
|
|
734
|
+
|
|
735
|
+
this.render()
|
|
736
|
+
this.paintSelection()
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* The blocks under the current selection.
|
|
741
|
+
*
|
|
742
|
+
* @returns {Array}
|
|
743
|
+
*/
|
|
744
|
+
selectedBlocks() {
|
|
745
|
+
if (!this.selectionDate) {
|
|
746
|
+
return []
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const rows = this.rowsFor(this.selectionDate)
|
|
750
|
+
|
|
751
|
+
return [...this.selection]
|
|
752
|
+
.map(key => {
|
|
753
|
+
const [row, col] = key.split(',').map(Number)
|
|
754
|
+
|
|
755
|
+
return this.blockAt(this.state.courts[col].id, this.selectionDate, rows[row].key)
|
|
756
|
+
})
|
|
757
|
+
.filter(Boolean)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Which competition and phase the region is being worked on as:
|
|
762
|
+
* what is painted there, or - on cells with nothing on them yet -
|
|
763
|
+
* whatever the menu was told to treat them as.
|
|
764
|
+
*
|
|
765
|
+
* @returns {?Object}
|
|
766
|
+
*/
|
|
767
|
+
selectionContext() {
|
|
768
|
+
const blocks = this.selectedBlocks()
|
|
769
|
+
const fixtures = new Set(blocks.map(block => `${block.competition_id}:${block.phase}`))
|
|
770
|
+
|
|
771
|
+
if (fixtures.size === 1) {
|
|
772
|
+
return { competitionId: blocks[0].competition_id, phase: blocks[0].phase }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return blocks.length === 0 ? this.menuContext ?? null : null
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* What the region can be told it is: the divisions of its
|
|
780
|
+
* competition, each with its groups or its rounds.
|
|
781
|
+
*
|
|
782
|
+
* @param {?Object} context
|
|
783
|
+
* @returns {?Array}
|
|
784
|
+
*/
|
|
785
|
+
identityOptions(context) {
|
|
786
|
+
const competition = context
|
|
787
|
+
&& this.state.competitions.find(c => c.id === context.competitionId)
|
|
788
|
+
|
|
789
|
+
if (!competition) {
|
|
790
|
+
return null
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return context.phase === 'knockout'
|
|
794
|
+
? this.roundOptions(competition)
|
|
795
|
+
: this.groupOptions(competition)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
groupOptions(competition) {
|
|
799
|
+
const divisions = new Map()
|
|
800
|
+
|
|
801
|
+
for (const group of competition.groups ?? []) {
|
|
802
|
+
if (!divisions.has(group.division_id)) {
|
|
803
|
+
divisions.set(group.division_id, {
|
|
804
|
+
id: group.division_id,
|
|
805
|
+
name: group.division_name,
|
|
806
|
+
items: [],
|
|
807
|
+
})
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
divisions.get(group.division_id).items.push({
|
|
811
|
+
label: `Group ${group.name}`,
|
|
812
|
+
fixture: {
|
|
813
|
+
division_id: group.division_id,
|
|
814
|
+
division_name: group.division_name,
|
|
815
|
+
division_gender: group.division_gender,
|
|
816
|
+
group_id: group.id,
|
|
817
|
+
group_name: group.name,
|
|
818
|
+
round: null,
|
|
819
|
+
},
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return [...divisions.values()]
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
roundOptions(competition) {
|
|
827
|
+
return (competition.knockout_rounds_by_division ?? []).map(division => ({
|
|
828
|
+
id: division.division_id,
|
|
829
|
+
name: division.division_name,
|
|
830
|
+
items: division.waves.map(wave => ({
|
|
831
|
+
label: wave.round,
|
|
832
|
+
fixture: {
|
|
833
|
+
division_id: division.division_id,
|
|
834
|
+
division_name: division.division_name,
|
|
835
|
+
division_gender: division.division_gender,
|
|
836
|
+
group_id: null,
|
|
837
|
+
group_name: null,
|
|
838
|
+
round: wave.round,
|
|
839
|
+
},
|
|
840
|
+
})),
|
|
841
|
+
}))
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Opens the menu at the pointer: what the planner would lay into
|
|
846
|
+
* the region, then the divisions and groups to name it by hand.
|
|
847
|
+
* Nothing to offer means no menu.
|
|
848
|
+
*
|
|
849
|
+
* @param {number} x
|
|
850
|
+
* @param {number} y
|
|
851
|
+
*/
|
|
852
|
+
openMenu(x, y) {
|
|
853
|
+
const menu = this.$('[data-menu]')
|
|
854
|
+
const context = this.selectionContext()
|
|
855
|
+
|
|
856
|
+
menu.replaceChildren()
|
|
857
|
+
|
|
858
|
+
if (!context) {
|
|
859
|
+
this.offerCompetitions(x, y)
|
|
860
|
+
return
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const slots = this.slotOptions(context)
|
|
864
|
+
const identities = this.identityOptions(context) ?? []
|
|
865
|
+
const painted = this.selectedBlocks().length > 0
|
|
866
|
+
|
|
867
|
+
if (!slots.length && !identities.length && !painted) {
|
|
868
|
+
menu.hidden = true
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const list = document.createElement('ul')
|
|
873
|
+
|
|
874
|
+
if (slots.length) {
|
|
875
|
+
const region = this.selectionSize()
|
|
876
|
+
|
|
877
|
+
list.appendChild(this.menuSection(
|
|
878
|
+
`Assign slots (${region.courts} x ${region.slots} selected)`,
|
|
879
|
+
slots.map(option => {
|
|
880
|
+
const button = this.menuButton(
|
|
881
|
+
`${option.teams} team: ${option.label} (needs ${option.courts} x ${option.slots})`,
|
|
882
|
+
() => this.openSlotPicker(option, context)
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
button.dataset.slotCreate = ''
|
|
886
|
+
button.disabled = !option.fits
|
|
887
|
+
|
|
888
|
+
return button
|
|
889
|
+
})
|
|
890
|
+
))
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (identities.length) {
|
|
894
|
+
list.appendChild(this.menuSection('Manual assignment', identities.map(division => {
|
|
895
|
+
const button = this.menuButton(division.name, () => this.openManualPicker(division, context))
|
|
896
|
+
|
|
897
|
+
button.dataset.menuDivision = division.id
|
|
898
|
+
|
|
899
|
+
return button
|
|
900
|
+
})))
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (painted) {
|
|
904
|
+
const clear = this.menuButton('Clear', () => this.assignIdentity(UNNAMED))
|
|
905
|
+
|
|
906
|
+
clear.dataset.menuClear = ''
|
|
907
|
+
list.appendChild(this.menuSection('', [clear]))
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
menu.appendChild(list)
|
|
911
|
+
this.showMenu(x, y)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* A region with nothing on it has no competition to work from, so
|
|
916
|
+
* the menu asks for one before it can offer anything.
|
|
917
|
+
*
|
|
918
|
+
* @param {number} x
|
|
919
|
+
* @param {number} y
|
|
920
|
+
*/
|
|
921
|
+
offerCompetitions(x, y) {
|
|
922
|
+
const menu = this.$('[data-menu]')
|
|
923
|
+
const entries = this.competitionsForDate(this.selectionDate)
|
|
924
|
+
|
|
925
|
+
if (!this.selection.size || !entries.length) {
|
|
926
|
+
menu.hidden = true
|
|
927
|
+
return
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const list = document.createElement('ul')
|
|
931
|
+
|
|
932
|
+
list.appendChild(this.menuSection('Which competition', entries.map(({ competition, phase }) => {
|
|
933
|
+
const button = this.menuButton(
|
|
934
|
+
`${competition.name} (${phase === 'knockout' ? 'Knockout' : 'Group'})`,
|
|
935
|
+
() => {
|
|
936
|
+
this.menuContext = { competitionId: competition.id, phase }
|
|
937
|
+
this.openMenu(x, y)
|
|
938
|
+
}
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
button.dataset.menuCompetition = competition.id
|
|
942
|
+
|
|
943
|
+
return button
|
|
944
|
+
})))
|
|
945
|
+
|
|
946
|
+
menu.appendChild(list)
|
|
947
|
+
this.showMenu(x, y)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Which groups go in the slot. The planner bundles a fixed number
|
|
952
|
+
* per court set, so the button waits for exactly that many.
|
|
953
|
+
*
|
|
954
|
+
* @param {Object} option
|
|
955
|
+
* @param {Object} context
|
|
956
|
+
*/
|
|
957
|
+
openSlotPicker(option, context) {
|
|
958
|
+
const menu = this.$('[data-menu]')
|
|
959
|
+
const chosen = new Set()
|
|
960
|
+
|
|
961
|
+
const create = this.menuButton('Assign', () => this.createSlot([...chosen], context))
|
|
962
|
+
create.dataset.slotConfirm = ''
|
|
963
|
+
|
|
964
|
+
// A competition can have sixty groups, and the list of them buries
|
|
965
|
+
// the button. Once the slot has the groups it takes, only those
|
|
966
|
+
// are left on show.
|
|
967
|
+
const draw = () => {
|
|
968
|
+
const full = chosen.size === option.count
|
|
969
|
+
const shown = full ? [...chosen] : option.groups
|
|
970
|
+
|
|
971
|
+
create.disabled = !full
|
|
972
|
+
|
|
973
|
+
const boxes = shown.map(group => {
|
|
974
|
+
const label = document.createElement('label')
|
|
975
|
+
const box = document.createElement('input')
|
|
976
|
+
|
|
977
|
+
box.type = 'checkbox'
|
|
978
|
+
box.checked = chosen.has(group)
|
|
979
|
+
box.dataset.slotGroup = group.id
|
|
980
|
+
box.addEventListener('change', () => {
|
|
981
|
+
if (box.checked) {
|
|
982
|
+
chosen.add(group)
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
chosen.delete(group)
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
draw()
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
label.append(box, document.createTextNode(` ${group.division_name} ${group.name}`))
|
|
992
|
+
|
|
993
|
+
return label
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
menu.replaceChildren()
|
|
997
|
+
menu.appendChild(document.createElement('ul')).appendChild(
|
|
998
|
+
this.menuSection(
|
|
999
|
+
full
|
|
1000
|
+
? option.label
|
|
1001
|
+
: `${option.label}: pick ${option.count} ${option.count === 1 ? 'group' : 'groups'}`,
|
|
1002
|
+
[...boxes, create]
|
|
1003
|
+
)
|
|
1004
|
+
)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
draw()
|
|
1008
|
+
menu.hidden = false
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Which group or round the region is. One answer, picked the same
|
|
1013
|
+
* way a slot's groups are.
|
|
1014
|
+
*
|
|
1015
|
+
* @param {Object} division
|
|
1016
|
+
*/
|
|
1017
|
+
openManualPicker(division, context) {
|
|
1018
|
+
const menu = this.$('[data-menu]')
|
|
1019
|
+
let chosen = null
|
|
1020
|
+
|
|
1021
|
+
const assign = this.menuButton('Assign', () => this.assignIdentity(chosen, context))
|
|
1022
|
+
assign.dataset.menuConfirm = ''
|
|
1023
|
+
assign.disabled = true
|
|
1024
|
+
|
|
1025
|
+
const choices = division.items.map(option => {
|
|
1026
|
+
const label = document.createElement('label')
|
|
1027
|
+
const radio = document.createElement('input')
|
|
1028
|
+
|
|
1029
|
+
radio.type = 'radio'
|
|
1030
|
+
radio.name = 'manual-assign'
|
|
1031
|
+
radio.dataset.menuAssign = option.label
|
|
1032
|
+
radio.addEventListener('change', () => {
|
|
1033
|
+
chosen = option.fixture
|
|
1034
|
+
assign.disabled = false
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
label.append(radio, document.createTextNode(` ${option.label}`))
|
|
1038
|
+
|
|
1039
|
+
return label
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
menu.replaceChildren()
|
|
1043
|
+
menu.appendChild(document.createElement('ul')).appendChild(
|
|
1044
|
+
this.menuSection(division.name, [...choices, assign])
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
menu.hidden = false
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
menuButton(label, onClick) {
|
|
1051
|
+
const button = document.createElement('button')
|
|
1052
|
+
|
|
1053
|
+
button.type = 'button'
|
|
1054
|
+
button.textContent = label
|
|
1055
|
+
button.addEventListener('click', onClick)
|
|
1056
|
+
|
|
1057
|
+
return button
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
menuSection(title, children) {
|
|
1061
|
+
const item = document.createElement('li')
|
|
1062
|
+
const name = document.createElement('strong')
|
|
1063
|
+
|
|
1064
|
+
name.textContent = title
|
|
1065
|
+
item.append(name, ...children)
|
|
1066
|
+
|
|
1067
|
+
return item
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
showMenu(x, y) {
|
|
1071
|
+
const menu = this.$('[data-menu]')
|
|
1072
|
+
|
|
1073
|
+
menu.style.left = `${x}px`
|
|
1074
|
+
menu.style.top = `${y}px`
|
|
1075
|
+
menu.hidden = false
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
closeMenu() {
|
|
1079
|
+
const menu = this.$('[data-menu]')
|
|
1080
|
+
|
|
1081
|
+
menu.hidden = true
|
|
1082
|
+
menu.replaceChildren()
|
|
1083
|
+
this.menuContext = null
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Names the selection: every block in it becomes this division and
|
|
1088
|
+
* group, or this division and round. The selection stays, so a
|
|
1089
|
+
* region can be renamed without picking it out again.
|
|
1090
|
+
*
|
|
1091
|
+
* @param {Object} fixture
|
|
1092
|
+
*/
|
|
1093
|
+
assignIdentity(fixture, context) {
|
|
1094
|
+
const rows = this.rowsFor(this.selectionDate)
|
|
1095
|
+
|
|
1096
|
+
for (const key of this.selection) {
|
|
1097
|
+
const [row, col] = key.split(',').map(Number)
|
|
1098
|
+
const court = this.state.courts[col]
|
|
1099
|
+
const slot = rows[row]
|
|
1100
|
+
const block = this.blockAt(court.id, this.selectionDate, slot.key)
|
|
1101
|
+
|
|
1102
|
+
if (block) {
|
|
1103
|
+
Object.assign(block, fixture)
|
|
1104
|
+
continue
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Nothing painted there yet: the menu was told what to treat
|
|
1108
|
+
// the region as, so the cell is made rather than skipped.
|
|
1109
|
+
if (context) {
|
|
1110
|
+
this.blocks.push({
|
|
1111
|
+
id: null,
|
|
1112
|
+
court_id: court.id,
|
|
1113
|
+
date: this.selectionDate,
|
|
1114
|
+
slot: slot.key,
|
|
1115
|
+
phase: context.phase,
|
|
1116
|
+
competition_id: context.competitionId,
|
|
1117
|
+
...fixture,
|
|
1118
|
+
})
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
this.closeMenu()
|
|
1123
|
+
|
|
1124
|
+
this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
|
|
1125
|
+
bubbles: true,
|
|
1126
|
+
detail: { blocks: this.blocks },
|
|
1127
|
+
}))
|
|
1128
|
+
|
|
1129
|
+
this.render()
|
|
1130
|
+
this.paintSelection()
|
|
1131
|
+
}
|
|
1132
|
+
|
|
401
1133
|
/**
|
|
402
1134
|
* How many one-hour matches are currently scheduled for a
|
|
403
1135
|
* competition in the given phase, so the legend can show
|
|
@@ -438,6 +1170,24 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
438
1170
|
return this.$('[data-always-bye-short-groups]')?.checked ?? false
|
|
439
1171
|
}
|
|
440
1172
|
|
|
1173
|
+
/**
|
|
1174
|
+
* Left off the element, the planner's own default stands. "0" is a
|
|
1175
|
+
* real answer, hence no `|| undefined`.
|
|
1176
|
+
*
|
|
1177
|
+
* @returns {?number}
|
|
1178
|
+
*/
|
|
1179
|
+
lightBuffer() {
|
|
1180
|
+
const raw = this.getConfig()['light-buffer']
|
|
1181
|
+
|
|
1182
|
+
if (raw == null || String(raw).trim() === '') {
|
|
1183
|
+
return undefined
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const minutes = Number(raw)
|
|
1187
|
+
|
|
1188
|
+
return Number.isFinite(minutes) && minutes >= 0 ? minutes : undefined
|
|
1189
|
+
}
|
|
1190
|
+
|
|
441
1191
|
/**
|
|
442
1192
|
* The groups that will be pooled rather than given a court of their
|
|
443
1193
|
* own. Worth saying out loud: they are the ones a director can still
|
|
@@ -489,6 +1239,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
489
1239
|
groupSlotsFromSessions: true,
|
|
490
1240
|
darkFrom: this.getConfig()['dark-from'],
|
|
491
1241
|
dayEnd: this.getConfig()['day-end'] || undefined,
|
|
1242
|
+
lightBuffer: this.lightBuffer(),
|
|
492
1243
|
})
|
|
493
1244
|
|
|
494
1245
|
this.blocks = blocks
|
|
@@ -566,6 +1317,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
566
1317
|
this.$('[data-registration]').hidden = false
|
|
567
1318
|
this.$('[data-always-bye]').hidden = false
|
|
568
1319
|
this.$('[data-view-toggle]').hidden = false
|
|
1320
|
+
this.$('[data-narrow-toggle]').hidden = false
|
|
569
1321
|
|
|
570
1322
|
// The plan as data, for reading rather than looking at.
|
|
571
1323
|
const showData = this.$('[data-show-data]').checked
|
|
@@ -646,6 +1398,12 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
646
1398
|
// re-renders the whole schedule, and without this it would always
|
|
647
1399
|
// snap back to the first day.
|
|
648
1400
|
const activeDate = container.querySelector('semtic-tabbed')?.getAttribute('current')
|
|
1401
|
+
// A day scrolls inside its own tab, and that tab is thrown away
|
|
1402
|
+
// here, so a director working on court 30 would be handed back
|
|
1403
|
+
// court 1 after every assignment.
|
|
1404
|
+
const wasAt = this.scrollPosition(
|
|
1405
|
+
container.querySelector(`semtic-tab[tab="${activeDate}"]`)
|
|
1406
|
+
)
|
|
649
1407
|
|
|
650
1408
|
container.replaceChildren()
|
|
651
1409
|
|
|
@@ -701,6 +1459,33 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
701
1459
|
tabbed.addEventListener('semtic-tabbed:activated', event => buildDay(event.detail.tab))
|
|
702
1460
|
|
|
703
1461
|
container.appendChild(tabbed)
|
|
1462
|
+
this.scrollBackTo(tabbed.querySelector(`:scope > semtic-tab[tab="${initial}"]`), wasAt)
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
scrollPosition(tab) {
|
|
1466
|
+
const view = this.ownerDocument?.defaultView
|
|
1467
|
+
|
|
1468
|
+
return {
|
|
1469
|
+
left: tab?.scrollLeft ?? 0,
|
|
1470
|
+
top: tab?.scrollTop ?? 0,
|
|
1471
|
+
pageX: view?.scrollX ?? 0,
|
|
1472
|
+
pageY: view?.scrollY ?? 0,
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
scrollBackTo(tab, wasAt) {
|
|
1477
|
+
if (tab) {
|
|
1478
|
+
tab.scrollLeft = wasAt.left
|
|
1479
|
+
tab.scrollTop = wasAt.top
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const view = this.ownerDocument?.defaultView
|
|
1483
|
+
|
|
1484
|
+
// Only when it actually moved: asking otherwise is how a board
|
|
1485
|
+
// ends up fighting whatever else is scrolling the page.
|
|
1486
|
+
if (view && (view.scrollX !== wasAt.pageX || view.scrollY !== wasAt.pageY)) {
|
|
1487
|
+
view.scrollTo?.(wasAt.pageX, wasAt.pageY)
|
|
1488
|
+
}
|
|
704
1489
|
}
|
|
705
1490
|
|
|
706
1491
|
/**
|
|
@@ -772,6 +1557,8 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
772
1557
|
competitions: this.state.competitions ?? [],
|
|
773
1558
|
cells: this.blocks,
|
|
774
1559
|
fallbackSlots: this.state.slots ?? [],
|
|
1560
|
+
darkFromMinutes: toMinutes(this.getConfig()['dark-from']),
|
|
1561
|
+
lightBufferMinutes: this.lightBuffer() ?? 0,
|
|
775
1562
|
})
|
|
776
1563
|
}
|
|
777
1564
|
|
|
@@ -875,106 +1662,144 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
875
1662
|
* canvas is continuous, the cells on it are not.
|
|
876
1663
|
*/
|
|
877
1664
|
dayForDate(date) {
|
|
878
|
-
const
|
|
879
|
-
day.dataset.day = date
|
|
880
|
-
day.setAttribute('date', date)
|
|
881
|
-
|
|
1665
|
+
const grid = document.createElement(TennisDayGridElement.tag)
|
|
882
1666
|
const rows = this.rowsFor(date)
|
|
883
|
-
const range = this.dayRangeFor(rows)
|
|
884
1667
|
|
|
885
|
-
|
|
1668
|
+
grid.addEventListener('day-grid:press', event => this.pressed(event.detail))
|
|
1669
|
+
grid.addEventListener('day-grid:enter', event => this.entered(event.detail))
|
|
1670
|
+
grid.addEventListener('day-grid:menu', event => this.rightClicked(event.detail))
|
|
886
1671
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1672
|
+
grid.draw({
|
|
1673
|
+
date,
|
|
1674
|
+
courts: this.state.courts,
|
|
1675
|
+
rows,
|
|
1676
|
+
cellFor: (court, courtIndex, slot) => this.cellFor(court, date, slot),
|
|
1677
|
+
compact: this.$('[data-narrow-empty]')?.checked ?? false,
|
|
891
1678
|
})
|
|
892
1679
|
|
|
893
|
-
|
|
1680
|
+
this.markRuns(grid, date, rows)
|
|
1681
|
+
|
|
1682
|
+
return grid
|
|
894
1683
|
}
|
|
895
1684
|
|
|
896
1685
|
/**
|
|
897
|
-
*
|
|
1686
|
+
* What Planning puts in a cell: nothing on a court that may not
|
|
1687
|
+
* take this hour, and otherwise whatever is booked there.
|
|
1688
|
+
*
|
|
1689
|
+
* @param {Object} court
|
|
1690
|
+
* @param {string} date
|
|
1691
|
+
* @param {Object} slot
|
|
1692
|
+
* @returns {Object}
|
|
898
1693
|
*/
|
|
899
|
-
|
|
900
|
-
|
|
1694
|
+
cellFor(court, date, slot) {
|
|
1695
|
+
if (!this.isSelectable(court, slot)) {
|
|
1696
|
+
return { blocked: true }
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const block = this.blockAt(court.id, date, slot.key)
|
|
1700
|
+
|
|
1701
|
+
return {
|
|
1702
|
+
minutes: block ? this.blockDurationMinutes(block, date) : undefined,
|
|
1703
|
+
content: block ? this.matchElement(block) : null,
|
|
1704
|
+
filled: Boolean(block),
|
|
1705
|
+
}
|
|
901
1706
|
}
|
|
902
1707
|
|
|
903
1708
|
/**
|
|
904
|
-
*
|
|
905
|
-
*
|
|
1709
|
+
* A press on the grid: on a match already picked out it takes hold
|
|
1710
|
+
* of it, and anywhere else it starts a selection.
|
|
1711
|
+
*
|
|
1712
|
+
* @param {Object} where
|
|
906
1713
|
*/
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
//
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
const body = document.createElement('planning-rows')
|
|
915
|
-
body.style.setProperty('--planning-day-height', `${this.pxFor(range.end - range.start)}px`)
|
|
916
|
-
|
|
917
|
-
rows.forEach(slot => {
|
|
918
|
-
const time = document.createElement('planning-time')
|
|
919
|
-
time.setAttribute('start', slot.key)
|
|
920
|
-
time.style.top = `${this.pxFor(slot.start_minutes - range.start)}px`
|
|
921
|
-
time.style.height = `${this.pxFor(slot.end_minutes - slot.start_minutes)}px`
|
|
922
|
-
time.textContent = slot.label ?? slot.key
|
|
923
|
-
body.appendChild(time)
|
|
924
|
-
})
|
|
1714
|
+
pressed({ date, row, col, onContent, button, ctrlKey }) {
|
|
1715
|
+
// A mac's ctrl-click is a right-click, and its mousedown lands
|
|
1716
|
+
// first. Acting on it would throw away the very region the menu
|
|
1717
|
+
// is about to work on.
|
|
1718
|
+
if (button === 2 || ctrlKey) {
|
|
1719
|
+
return
|
|
1720
|
+
}
|
|
925
1721
|
|
|
926
|
-
|
|
1722
|
+
const mine = this.selectionDate === date && this.selection.has(this.cellKey(row, col))
|
|
927
1723
|
|
|
928
|
-
|
|
929
|
-
|
|
1724
|
+
if (onContent && mine) {
|
|
1725
|
+
this.startDrag(date, row, col)
|
|
930
1726
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1727
|
+
return
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
this.startSelection(date, row, col)
|
|
1731
|
+
}
|
|
935
1732
|
|
|
936
|
-
|
|
937
|
-
|
|
1733
|
+
entered({ date, row, col }) {
|
|
1734
|
+
if (this.isSelecting && this.selectionDate === date) {
|
|
1735
|
+
this.updateSelection(row, col)
|
|
938
1736
|
}
|
|
939
1737
|
|
|
940
|
-
if (
|
|
941
|
-
|
|
1738
|
+
if (this.drag?.date === date) {
|
|
1739
|
+
this.drag.over = { row, col }
|
|
1740
|
+
this.showDrag()
|
|
942
1741
|
}
|
|
1742
|
+
}
|
|
943
1743
|
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1744
|
+
/**
|
|
1745
|
+
* Right-clicking outside the painted region takes that one cell
|
|
1746
|
+
* rather than doing nothing.
|
|
1747
|
+
*
|
|
1748
|
+
* @param {Object} where
|
|
1749
|
+
*/
|
|
1750
|
+
rightClicked({ date, row, col, x, y }) {
|
|
1751
|
+
if (this.selectionDate !== date || !this.selection.has(this.cellKey(row, col))) {
|
|
1752
|
+
this.startSelection(date, row, col)
|
|
1753
|
+
this.isSelecting = false
|
|
1754
|
+
}
|
|
947
1755
|
|
|
948
|
-
|
|
949
|
-
|
|
1756
|
+
this.openMenu(x, y)
|
|
1757
|
+
}
|
|
950
1758
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1759
|
+
/**
|
|
1760
|
+
* A slot is a run of cells down one court, all of one division. The
|
|
1761
|
+
* first and last of a run say so, so the stylesheet can close a
|
|
1762
|
+
* border round the whole thing rather than round every cell.
|
|
1763
|
+
*
|
|
1764
|
+
* @param {Array} placed
|
|
1765
|
+
*/
|
|
1766
|
+
markRuns(grid, date, rows) {
|
|
1767
|
+
const together = (a, b) => Boolean(a) && Boolean(b)
|
|
1768
|
+
&& a.competition_id === b.competition_id
|
|
1769
|
+
&& a.division_id === b.division_id
|
|
1770
|
+
&& a.phase === b.phase
|
|
1771
|
+
|
|
1772
|
+
for (const column of grid.columns()) {
|
|
1773
|
+
const placed = column.cells.map(({ element, row }) => ({
|
|
1774
|
+
element,
|
|
1775
|
+
block: row == null ? null : this.blockAt(column.courtId, date, rows[row].key),
|
|
1776
|
+
}))
|
|
959
1777
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1778
|
+
// Zebra down the run: a slot hands the court from one group to
|
|
1779
|
+
// the next every turn, and striping those turns is what shows
|
|
1780
|
+
// where each turn starts.
|
|
1781
|
+
let turn = 0
|
|
964
1782
|
|
|
965
|
-
|
|
966
|
-
|
|
1783
|
+
placed.forEach(({ element, block }, index) => {
|
|
1784
|
+
const match = element.querySelector('planning-match')
|
|
967
1785
|
|
|
968
|
-
|
|
1786
|
+
if (!match) {
|
|
1787
|
+
return
|
|
1788
|
+
}
|
|
969
1789
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1790
|
+
if (!together(placed[index - 1]?.block, block)) {
|
|
1791
|
+
match.setAttribute('run-start', '')
|
|
1792
|
+
turn = 0
|
|
1793
|
+
}
|
|
974
1794
|
|
|
975
|
-
|
|
1795
|
+
if (!together(block, placed[index + 1]?.block)) {
|
|
1796
|
+
match.setAttribute('run-end', '')
|
|
1797
|
+
}
|
|
976
1798
|
|
|
977
|
-
|
|
1799
|
+
match.style.setProperty('--group-wash', turn % 2 === 0 ? 0 : -WASH_STEP)
|
|
1800
|
+
turn += 1
|
|
1801
|
+
})
|
|
1802
|
+
}
|
|
978
1803
|
}
|
|
979
1804
|
|
|
980
1805
|
/**
|
|
@@ -991,48 +1816,6 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
991
1816
|
return session?.playtime ?? FALLBACK_SLOT_MINUTES
|
|
992
1817
|
}
|
|
993
1818
|
|
|
994
|
-
slotElement(court, date, slot, rowIndex, courtIndex, range) {
|
|
995
|
-
const element = document.createElement('planning-slot')
|
|
996
|
-
const block = this.blockAt(court.id, date, slot.key)
|
|
997
|
-
const duration = block
|
|
998
|
-
? this.blockDurationMinutes(block, date)
|
|
999
|
-
: slot.end_minutes - slot.start_minutes
|
|
1000
|
-
|
|
1001
|
-
element.setAttribute('start', slot.key)
|
|
1002
|
-
element.style.top = `${this.pxFor(slot.start_minutes - range.start)}px`
|
|
1003
|
-
element.style.height = `${this.pxFor(duration)}px`
|
|
1004
|
-
|
|
1005
|
-
if (slot.start_minutes != null) {
|
|
1006
|
-
element.setAttribute('duration', duration)
|
|
1007
|
-
element.dataset.spanEnd = slot.start_minutes + duration
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
if (!this.isSelectable(court, slot)) {
|
|
1011
|
-
element.setAttribute('blocked', '')
|
|
1012
|
-
return element
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
element.dataset.row = rowIndex
|
|
1016
|
-
element.dataset.col = courtIndex
|
|
1017
|
-
|
|
1018
|
-
element.addEventListener('mousedown', event => {
|
|
1019
|
-
event.preventDefault()
|
|
1020
|
-
this.startSelection(date, rowIndex, courtIndex)
|
|
1021
|
-
})
|
|
1022
|
-
|
|
1023
|
-
element.addEventListener('mouseenter', () => {
|
|
1024
|
-
if (this.isSelecting && this.selectionDate === date) {
|
|
1025
|
-
this.updateSelection(rowIndex, courtIndex)
|
|
1026
|
-
}
|
|
1027
|
-
})
|
|
1028
|
-
|
|
1029
|
-
if (block) {
|
|
1030
|
-
element.appendChild(this.matchElement(block))
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
return element
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
1819
|
/**
|
|
1037
1820
|
* What is played in a slot. Everything Planning has decided about it
|
|
1038
1821
|
* is an attribute, so the booking can be read straight off the DOM.
|