]> git.sesse.net Git - ultimatescore/blob - carousel.js
Add a function to help with logging.
[ultimatescore] / carousel.js
1 'use strict';
2
3 // Log with deep clone, so that the browser will show the object at time of log,
4 // instead of what it looks like at time of view.
5 function dlog()
6 {
7         let args = [];
8         for (const arg of arguments) {
9                 args.push(JSON.parse(JSON.stringify(arg)));
10         }
11         console.log(args);
12 }
13
14 function addheading(carousel, colspan, content)
15 {
16         let thead = document.createElement("thead");
17         let tr = document.createElement("tr");
18         let th = document.createElement("th");
19         th.innerHTML = content;
20         th.setAttribute("colspan", colspan);
21         tr.appendChild(th);
22         thead.appendChild(tr);
23         carousel.appendChild(thead);
24 };
25 function addtd(tr, className, content) {
26         let td = document.createElement("td");
27         td.appendChild(document.createTextNode(content));
28         td.className = className;
29         tr.appendChild(td);
30 };
31 function addth(tr, className, content) {
32         let th = document.createElement("th");
33         th.appendChild(document.createTextNode(content));
34         th.className = className;
35         tr.appendChild(th);
36 };
37
38 function subrank_partitions(games, parts, start_rank, tiebreakers, func) {
39         let result = [];
40         for (let i = 0; i < parts.length; ++i) {
41                 let part = func(games, parts[i], start_rank, tiebreakers);
42                 for (let j = 0; j < part.length; ++j) {
43                         result.push(part[j]);
44                 }
45                 start_rank += part.length;
46         }
47         return result;
48 };
49
50 function partition(teams, compare)
51 {
52         teams.sort(compare);
53
54         let parts = [];
55         let curr_part = [teams[0]];
56         for (let i = 1; i < teams.length; ++i) {
57                 if (compare(teams[i], curr_part[0]) != 0) {
58                         parts.push(curr_part);
59                         curr_part = [];
60                 }
61                 curr_part.push(teams[i]);
62         }
63         if (curr_part.length != 0) {
64                 parts.push(curr_part);
65         }
66         return parts;
67 };
68
69 function explain_tiebreaker(parts, rule_name)
70 {
71         let result = [];
72         for (let i = 0; i < parts.length; ++i) {
73                 result.push(parts[i].map(function(x) { return x.shortname; }).join("/"));
74         }
75         return result.join(" > ") + " (" + rule_name + ")";
76 }
77
78 function make_teams_to_idx(teams)
79 {
80         let teams_to_idx = [];
81         for (let i = 0; i < teams.length; i++) {
82                 teams_to_idx[teams[i].name] = i;
83                 teams_to_idx[teams[i].mediumname] = i;
84                 teams_to_idx[teams[i].shortname] = i;
85         }
86         return teams_to_idx;
87 }
88
89 function partition_by_beat(teams, fill_beatmatrix)
90 {
91         // Head-to-head score by way of components. First construct the beat matrix.
92         let n = teams.length;
93         let beat = new Array(n);
94         let teams_to_idx = make_teams_to_idx(teams);
95         for (let i = 0; i < n; i++) {
96                 beat[i] = new Array(n);
97                 for (let j = 0; j < n; j++) {
98                         beat[i][j] = 0;
99                 }
100         }
101         fill_beatmatrix(beat, teams_to_idx);
102         // Floyd-Warshall for transitive closure.
103         for (let k = 0; k < n; ++k) {
104                 for (let i = 0; i < n; ++i) {
105                         for (let j = 0; j < n; ++j) {
106                                 if (beat[i][k] && beat[k][j]) {
107                                         beat[i][j] = 1;
108                                 }
109                         }
110                 }
111         }
112
113         // See if we can find any team that is comparable to all others.
114         for (let pivot_idx = 0; pivot_idx < n; pivot_idx++) {
115                 let incomparable = false;
116                 for (let i = 0; i < n; ++i) {
117                         if (i != pivot_idx && beat[pivot_idx][i] == 0 && beat[i][pivot_idx] == 0) {
118                                 incomparable = true;
119                                 break;
120                         }
121                 }
122                 if (!incomparable) {
123                         // Split the teams into three partitions:
124                         let better_than_pivot = [], equal = [], worse_than_pivot = [];
125                         for (let i = 0; i < n; ++i) {
126                                 let we_beat = (beat[pivot_idx][i] == 1);
127                                 let they_beat = (beat[i][pivot_idx] == 1);
128                                 if ((i == pivot_idx) || (we_beat && they_beat)) {
129                                         equal.push(teams[i]);
130                                 } else if (we_beat && !they_beat) {
131                                         worse_than_pivot.push(teams[i]);
132                                 } else if (they_beat && !we_beat) {
133                                         better_than_pivot.push(teams[i]);
134                                 } else {
135                                         console.log("this shouldn't happen");
136                                 }
137                         } 
138                         let result = [];
139                         if (better_than_pivot.length > 0) {
140                                 result = partition_by_beat(better_than_pivot, fill_beatmatrix);
141                         }
142                         result.push(equal);  // Obviously can't be partitioned further.
143                         if (worse_than_pivot.length > 0) {
144                                 result = result.concat(partition_by_beat(worse_than_pivot, fill_beatmatrix));
145                         }
146                         return result;
147                 }
148         }
149
150         // No usable pivot was found, so the graph is inherently
151         // disconnected, and we cannot partition it.
152         return [teams];
153 }
154
155 // Takes in an array, gives every element a rank starting with 1, and returns.
156 function rank(games, teams, start_rank, tiebreakers) {
157         if (teams.length <= 1) {
158                 // Only one team, so trivial.
159                 teams[0].rank = start_rank;
160                 return teams;
161         }
162
163         // Rule #0: Partition the teams by score.
164         let score_parts = partition(teams, function(a, b) { return b.pts - a.pts });
165         if (score_parts.length > 1) {
166                 return subrank_partitions(games, score_parts, start_rank, tiebreakers, rank);
167         }
168
169         // Rule #1: Head-to-head wins.
170         let num_relevant_games = 0;
171         let beat_parts = partition_by_beat(teams, function(beat, teams_to_idx) {
172                 for (let i = 0; i < games.length; ++i) {
173                         let idx1 = teams_to_idx[games[i].name1];
174                         let idx2 = teams_to_idx[games[i].name2];
175                         if (idx1 !== undefined && idx2 !== undefined) {
176                                 if (games[i].score1 > games[i].score2) {
177                                         beat[idx1][idx2] = 1;
178                                         ++num_relevant_games;
179                                 } else if (games[i].score1 < games[i].score2) {
180                                         beat[idx2][idx1] = 1;
181                                         ++num_relevant_games;
182                                 }
183                         }
184                 }
185         });
186         if (beat_parts.length > 1) {
187                 tiebreakers.push(explain_tiebreaker(beat_parts, 'head-to-head'));
188                 return subrank_partitions(games, beat_parts, start_rank, tiebreakers, rank);
189         }
190
191         // Rule #2: Number of games played (fewer is better).
192         // Actually the rule says “fewest losses”, but fewer games is equivalent
193         // as long as teams have the same amount of points and ties don't exist.
194         let nplayed_parts = partition(teams, function(a, b) { return a.nplayed - b.nplayed });
195         if (nplayed_parts.length > 1) {
196                 tiebreakers.push(explain_tiebreaker(nplayed_parts, 'fewer losses'));
197                 return subrank_partitions(games, nplayed_parts, start_rank, tiebreakers, rank);
198         }
199
200         // Rule #3: Head-to-head goal difference (if all have played).
201         let teams_to_idx = make_teams_to_idx(teams);
202         if (num_relevant_games >= teams.length * (teams.length - 1) / 2) {
203                 for (let i = 0; i < teams.length; i++) {
204                         teams[i].h2h_gd = 0;
205                         teams[i].h2h_goals = 0;
206                 }
207                 for (let i = 0; i < games.length; ++i) {
208                         let idx1 = teams_to_idx[games[i].name1];
209                         let idx2 = teams_to_idx[games[i].name2];
210                         if (idx1 !== undefined && idx2 !== undefined &&
211                             !isNaN(games[i].score1) && !isNaN(games[i].score2)) {
212                                 teams[idx1].h2h_gd += games[i].score1;
213                                 teams[idx1].h2h_gd -= games[i].score2;
214                                 teams[idx2].h2h_gd += games[i].score2;
215                                 teams[idx2].h2h_gd -= games[i].score1;
216
217                                 teams[idx1].h2h_goals += games[i].score1;
218                                 teams[idx2].h2h_goals += games[i].score2;
219                         }
220                 }
221                 let h2h_gd_parts = partition(teams, function(a, b) { return b.h2h_gd - a.h2h_gd });
222                 if (h2h_gd_parts.length > 1) {
223                         tiebreakers.push(explain_tiebreaker(h2h_gd_parts, 'head-to-head goal difference'));
224                         return subrank_partitions(games, h2h_gd_parts, start_rank, tiebreakers, rank);
225                 }
226         }
227
228         // Rule #4: Goal difference against common opponents.
229         var results = {};
230         for (let i = 0; i < games.length; ++i) {
231                 if (results[games[i].name1] === undefined) {
232                         results[games[i].name1] = {};
233                 }
234                 if (results[games[i].name2] === undefined) {
235                         results[games[i].name2] = {};
236                 }
237                 results[games[i].name1][games[i].name2] = [ games[i].score1, games[i].score2 ];
238                 results[games[i].name2][games[i].name1] = [ games[i].score2, games[i].score1 ];
239         }
240         let gd_parts = partition_by_beat(teams, function(beat, teams_to_idx) {
241                 for (const team_i of Object.keys(teams_to_idx)) {
242                         let i = teams_to_idx[team_i];
243                         for (const team_j of Object.keys(teams_to_idx)) {
244                                 let j = teams_to_idx[team_j];
245                                 let results_i = results[team_i], results_j = results[team_j];
246                                 let gd_i = 0, gd_j = 0;
247
248                                 // See if the two teams have both played a third team k.
249                                 for (let k in results_i) {
250                                         if (!results_i.hasOwnProperty(k)) continue;
251                                         if (results_j !== undefined && results_j[k] !== undefined) {
252                                                 gd_i += results_i[k][0] - results_i[k][1];
253                                                 gd_j += results_j[k][0] - results_j[k][1];
254                                         }
255                                 }
256
257                                 if (gd_i > gd_j) {
258                                         beat[i][j] = 1;
259                                 } else if (gd_i < gd_j) {
260                                         beat[j][i] = 1;
261                                 }
262                         }
263                 }
264         });
265         if (gd_parts.length > 1) {
266                 tiebreakers.push(explain_tiebreaker(gd_parts, 'goal difference versus common opponents'));
267                 return subrank_partitions(games, gd_parts, start_rank, tiebreakers, rank);
268         }
269
270         // Rule #5: Head-to-head scored goals (if all have played).
271         if (num_relevant_games >= teams.length * (teams.length - 1) / 2) {
272                 let h2h_goals_parts = partition(teams, function(a, b) { return b.h2h_goals - a.h2h_goals });
273                 if (h2h_goals_parts.length > 1) {
274                         tiebreakers.push(explain_tiebreaker(h2h_goals_parts, 'head-to-head scored goals'));
275                         return subrank_partitions(games, h2h_goals_parts, start_rank, tiebreakers, rank);
276                 }
277         }
278
279         // Rule #6: Goals scored against common opponents.
280         let goals_parts = partition_by_beat(teams, function(beat, teams_to_idx) {
281                 for (const team_i of Object.keys(teams_to_idx)) {
282                         let i = teams_to_idx[team_i];
283                         for (const team_j of Object.keys(teams_to_idx)) {
284                                 let j = teams_to_idx[team_j];
285                                 let results_i = results[team_i], results_j = results[team_j];
286                                 let goals_i = 0, goals_j = 0;
287
288                                 // See if the two teams have both played a third team k.
289                                 for (let k in results_i) {
290                                         if (!results_i.hasOwnProperty(k)) continue;
291                                         if (results_j !== undefined && results_j[k] !== undefined) {
292                                                 goals_i += results_i[k][0];
293                                                 goals_j += results_j[k][0];
294                                         }
295                                 }
296
297                                 if (goals_i > goals_j) {
298                                         beat[i][j] = 1;
299                                 } else if (goals_i < goals_j) {
300                                         beat[j][i] = 1;
301                                 }
302                         }
303                 }
304         });
305         if (goals_parts.length > 1) {
306                 tiebreakers.push(explain_tiebreaker(goals_parts, 'goals scored against common opponents'));
307                 return subrank_partitions(games, goals_parts, start_rank, tiebreakers, rank);
308         }
309
310         // OK, it's a tie. Give them all the same rank.
311         let result = [];
312         for (let i = 0; i < teams.length; ++i) {
313                 result.push(teams[i]);
314                 result[i].rank = start_rank;
315         }
316         return result; 
317 }; 
318
319 // Same, but with the simplified rules for ranking thirds. games isn't used and can be empty.
320 function rank_thirds(games, teams, start_rank, tiebreakers) {
321         if (teams.length <= 1) {
322                 // Only one team, so trivial.
323                 teams[0].rank = start_rank;
324                 return teams;
325         }
326
327         // Rule #1: Partition the teams by score.
328         let score_parts = partition(teams, function(a, b) { return b.pts - a.pts });
329         if (score_parts.length > 1) {
330                 tiebreakers.push(explain_tiebreaker(score_parts, 'most games won'));
331                 return subrank_partitions(games, score_parts, start_rank, tiebreakers, rank_thirds);
332         }
333
334         // Rule #2: Goal difference against common opponents.
335         let gd_parts = partition(teams, function(a, b) { return b.gd - a.gd });
336         if (gd_parts.length > 1) {
337                 tiebreakers.push(explain_tiebreaker(gd_parts, 'goal difference'));
338                 return subrank_partitions(games, gd_parts, start_rank, tiebreakers, rank_thirds);
339         }
340         
341         // Rule #3: Goals scored.
342         let goal_parts = partition(teams, function(a, b) { return b.goals - a.goals });
343         if (goal_parts.length > 1) {
344                 tiebreakers.push(explain_tiebreaker(goal_parts, 'goals scored'));
345                 return subrank_partitions(games, goal_parts, start_rank, tiebreakers, rank_thirds);
346         }
347
348         // OK, it's a tie. Give them all the same rank.
349         let result = [];
350         for (let i = 0; i < teams.length; ++i) {
351                 result.push(teams[i]);
352                 result[i].rank = start_rank;
353         }
354         return result; 
355 }; 
356
357 function parse_teams_from_spreadsheet(response) {
358         let teams = [];
359         for (let i = 2; response.values[i].length >= 1; ++i) {
360                 teams.push({
361                         "name": response.values[i][0],
362                         "mediumname": response.values[i][1],
363                         "shortname": response.values[i][2],
364                         //"tags": response.values[i][3],
365                         "ngames": 0,
366                         "nplayed": 0,
367                         "gd": 0,
368                         "pts": 0,
369                         "goals": 0
370                 });
371         }
372         return teams;
373 };
374
375 function parse_games_from_spreadsheet(response, group_name, include_unplayed) {
376         let games = [];
377         let i;
378         for (i = 0; i < response.values.length; ++i) {
379                 if (response.values[i][0] === 'Results') {
380                         i += 2;
381                         break;
382                 }
383         }
384
385         for ( ; response.values[i] !== undefined && response.values[i].length >= 1; ++i) {
386                 if ((response.values[i][2] && response.values[i][3]) || include_unplayed) {
387                         let real_group_name = response.values[i][9];
388                         if (real_group_name === undefined) {
389                                 real_group_name = group_name;
390                         }
391                         games.push({
392                                 "name1": response.values[i][0],
393                                 "name2": response.values[i][1],
394                                 "score1": parseInt(response.values[i][2]),
395                                 "score2": parseInt(response.values[i][3]),
396                                 "streamday": response.values[i][7],
397                                 "streamtime": response.values[i][8],
398                                 "group_name": real_group_name
399                         });
400                 }
401         }
402         return games;
403 };
404
405 function apply_games_to_teams(games, teams)
406 {
407         let teams_to_idx = make_teams_to_idx(teams);
408         for (let i = 0; i < games.length; ++i) {
409                 let idx1 = teams_to_idx[games[i].name1];
410                 let idx2 = teams_to_idx[games[i].name2];
411                 if (games[i].score1 === undefined || games[i].score2 === undefined ||
412                     isNaN(games[i].score1) || isNaN(games[i].score2) ||
413                     idx1 === undefined || idx2 === undefined ||
414                     games[i].score1 == games[i].score2) {
415                         continue;
416                 }
417                 ++teams[idx1].nplayed;
418                 ++teams[idx2].nplayed;
419                 teams[idx1].goals += games[i].score1;
420                 teams[idx2].goals += games[i].score2;
421                 teams[idx1].gd += games[i].score1;
422                 teams[idx2].gd += games[i].score2;
423                 teams[idx1].gd -= games[i].score2;
424                 teams[idx2].gd -= games[i].score1;
425                 if (games[i].score1 > games[i].score2) {
426                         teams[idx1].pts += 2;
427                 } else {
428                         teams[idx2].pts += 2;
429                 }
430         }
431 }
432
433 // So that we can just have one team list, and let membership be defined by games.
434 function filter_teams(teams, response)
435 {
436         let teams_to_idx = make_teams_to_idx(teams);
437         let games = parse_games_from_spreadsheet(response, 'irrelevant group name', true);
438         for (let i = 0; i < games.length; ++i) {
439                 let idx1 = teams_to_idx[games[i].name1];
440                 let idx2 = teams_to_idx[games[i].name2];
441                 if (idx1 !== undefined) {
442                         ++teams[idx1].ngames;
443                 }
444                 if (idx2 !== undefined) {
445                         ++teams[idx2].ngames;
446                 }
447         }
448         return teams.filter(function(team) { return team.ngames > 0; });
449 }
450
451 function display_group_parsed(teams, games, group_name)
452 {
453         document.getElementById('entire-bug').style.display = 'none';
454
455         apply_games_to_teams(games, teams);
456         let tiebreakers = [];
457         teams = rank(games, teams, 1, tiebreakers);
458
459         let carousel = document.getElementById('carousel');
460         clear_carousel(carousel);
461
462         addheading(carousel, 5, "Current standings, " + ultimateconfig['tournament_title'] + "<br />" + group_name);
463         let tr = document.createElement("tr");
464         tr.className = "subfooter";
465         addth(tr, "rank", "");
466         addth(tr, "team", "");
467         addth(tr, "nplayed", "P");
468         addth(tr, "gd", "GD");
469         addth(tr, "pts", "Pts");
470         carousel.appendChild(tr);
471
472         let row_num = 2;
473         for (let i = 0; i < teams.length; ++i) {
474                 let tr = document.createElement("tr");
475
476                 addth(tr, "rank", teams[i].rank);
477                 addtd(tr, "team", teams[i].name);
478                 addtd(tr, "nplayed", teams[i].nplayed);
479                 addtd(tr, "gd", teams[i].gd.toString().replace(/-/, '−'));
480                 addtd(tr, "pts", teams[i].pts);
481
482                 carousel.appendChild(tr);
483         }
484
485         if (tiebreakers.length > 0) {
486                 let tie_tr = document.createElement("tr");
487                 tie_tr.className = "footer";
488                 let td = document.createElement("td");
489                 td.appendChild(document.createTextNode("Tiebreaks applied: " + tiebreakers.join(', ')));
490                 td.setAttribute("colspan", "5");
491                 tie_tr.appendChild(td);
492                 carousel.appendChild(tie_tr);
493         }
494
495         let footer_tr = document.createElement("tr");
496         footer_tr.className = "footer";
497         let td = document.createElement("td");
498         td.appendChild(document.createTextNode(ultimateconfig['tournament_footer']));
499         td.setAttribute("colspan", "5");
500         footer_tr.appendChild(td);
501         carousel.appendChild(footer_tr);
502
503         fade_in_rows(carousel);
504
505         carousel.style.display = 'table';
506 };
507
508 function fade_in_rows(table)
509 {
510         let trs = table.getElementsByTagName("tr");
511         for (let i = 1; i < trs.length; ++i) {  // The header already has its own fade-in.
512                 if (trs[i].className === "footer") {
513                         trs[i].style = "-webkit-animation: fade-in 1.0s ease; -webkit-animation-delay: " + (0.25 * i) + "s; -webkit-animation-fill-mode: both;";
514                 } else {
515                         trs[i].style = "-webkit-animation: fade-in 2.0s ease; -webkit-animation-delay: " + (0.25 * i) + "s; -webkit-animation-fill-mode: both;";
516                 }
517         }
518 };
519
520 function fade_out_rows(table)
521 {
522         let trs = table.getElementsByTagName("tr");
523         for (let i = 0; i < trs.length; ++i) {
524                 if (trs[i].className === "footer") {
525                         trs[i].style = "-webkit-animation: fade-out 1.0s ease; -webkit-animation-delay: " + (0.125 * i) + "s; -webkit-animation-fill-mode: both;";
526                 } else {
527                         trs[i].style = "-webkit-animation: fade-out 1.0s ease; -webkit-animation-delay: " + (0.125 * i) + "s; -webkit-animation-fill-mode: both;";
528                 }
529         }
530 };
531
532 function clear_carousel(table)
533 {
534         while (table.childNodes.length > 0) {
535                 table.removeChild(table.firstChild);
536         }
537 };
538
539 // Stream schedule
540 let max_list_len = 7;
541
542 function display_stream_schedule(response, group_name) {
543         let teams = parse_teams_from_spreadsheet(response);
544         let games = parse_games_from_spreadsheet(response, group_name, true);
545         display_stream_schedule_parsed(teams, games, 0);
546 };
547
548 function sort_game_list(games) {
549         games = games.filter(function(game) { return game.streamtime !== undefined && game.streamtime.match(/[0-9]+:[0-9]+/) != null; });
550         games.sort(function(a, b) {
551                 if (a.streamday !== b.streamday) {
552                         return a.streamday - b.streamday;
553                 }
554
555                 let m1 = a.streamtime.match(/([0-9]+):([0-9]+)/);
556                 let m2 = b.streamtime.match(/([0-9]+):([0-9]+)/);
557                 return (m1[1] * 60 + m1[2]) - (m2[1] * 60 + m2[2]);
558         });
559         return games;
560 }
561
562 function find_game_start_idx(games) {
563         // Pick out a reasonable place to start the list. We'll show the last
564         // completed match and start from there.
565         let start_idx = games.length - 1;
566         for (let i = 0; i < games.length; ++i) {
567                 if (isNaN(games[i].score1) || isNaN(games[i].score2) &&
568                     games[i].score1 === games[i].score2) {
569                         start_idx = i;
570                         break;
571                 }
572         }
573         if (start_idx > 0) start_idx--;
574         if (games.length >= max_list_len) {
575                 start_idx = Math.min(start_idx, games.length - max_list_len);
576         }
577         return start_idx;
578 }
579
580 function find_num_pages(games) {
581         games = sort_game_list(games);
582         let start_idx = find_game_start_idx(games);
583         return Math.ceil((games.length - start_idx) / max_list_len);
584 }
585
586 function display_stream_schedule_parsed(teams, games, page) {
587         document.getElementById('entire-bug').style.display = 'none';
588
589         games = sort_game_list(games);
590         let start_idx = find_game_start_idx(games);
591
592         start_idx += page * max_list_len;
593         if (start_idx >= games.length) {
594                 // Error.
595                 return;
596         }
597
598         let days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
599         let shortdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
600         let today = days[(new Date).getDay()];
601
602         let covered_days = [];
603         let row_num = 0;
604         for (let i = start_idx; i < games.length && row_num++ < max_list_len; ++i) {
605                 if (i == start_idx || games[i].streamday != games[i - 1].streamday) {
606                         covered_days.push(days[games[i].streamday]);
607                 }
608         }
609         
610         let carousel = document.getElementById('carousel');
611         clear_carousel(carousel);
612         addheading(carousel, 3, "Stream schedule, " + ultimateconfig['tournament_title'] + "<br />" + covered_days.join('/') + " (all times CET)");
613
614         let teams_to_idx = make_teams_to_idx(teams);
615         row_num = 0;
616         for (let i = start_idx; i < games.length && row_num < max_list_len; ++i) {
617                 let tr = document.createElement("tr");
618
619                 let name1 = teams[teams_to_idx[games[i].name1]].mediumname;
620                 let name2 = teams[teams_to_idx[games[i].name2]].mediumname;
621
622                 addtd(tr, "matchup", name1 + "–" + name2);
623                 addtd(tr, "group", games[i].group_name);
624
625                 if (!isNaN(games[i].score1) && !isNaN(games[i].score2) &&
626                     games[i].score1 !== games[i].score2) {
627                         addtd(tr, "streamtime", games[i].score1 + "–" + games[i].score2);
628                 } else {
629                         let streamtime = games[i].streamtime;
630                         let streamday = days[games[i].streamday];
631                         if (streamday !== today) {
632                                 streamtime = shortdays[games[i].streamday] + " " + streamtime;
633                         }
634                         addth(tr, "streamtime", streamtime);
635                 }
636
637                 row_num++;
638                 carousel.appendChild(tr);
639         }
640
641         fade_in_rows(carousel);
642
643         carousel.style.display = 'table';
644 };
645
646 function get_group(group_name, cb)
647 {
648         let req = new XMLHttpRequest();
649         req.onload = function(e) {
650                 cb(JSON.parse(req.responseText), group_name);
651         };
652         req.open('GET', 'https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values/\'' + group_name + '\'!A1:J50?key=' + ultimateconfig['api_key']);
653         req.send();
654 }
655
656 function showgroup(group_name)
657 {
658         get_group(group_name, function(response, group_name) {
659                 let teams = parse_teams_from_spreadsheet(response);
660                 let games = parse_games_from_spreadsheet(response, group_name, false);
661                 teams = filter_teams(teams, response);
662                 display_group_parsed(teams, games, group_name);
663                 publish_group_rank(response, group_name);  // Update the spreadsheet in the background.
664         });
665 }
666
667
668 function showgroup_from_state()
669 {
670         showgroup(state['group_name']);
671 }
672
673 let carousel_timeout = null;
674
675 function hidetable()
676 {
677         fade_out_rows(document.getElementById('carousel'));
678 };
679
680 function showschedule(page)
681 {
682         let teams = [];
683         let games = [];
684         let num_left = 5;
685
686         let cb = function(response, group_name) {
687                 teams = teams.concat(parse_teams_from_spreadsheet(response));
688                 games = games.concat(parse_games_from_spreadsheet(response, group_name, true));
689                 if (--num_left == 0) {
690                         display_stream_schedule_parsed(teams, games, 0);
691                 }
692         };
693
694         get_group('Group A', cb);
695         get_group('Group B', cb);
696         get_group('Group C', cb);
697         get_group('Playoffs', cb);
698         get_group('Playoffs 9th-13th', cb);
699 };
700
701 function do_series(series)
702 {
703         do_series_internal(series, 0);
704 };
705
706 function do_series_internal(series, idx)
707 {
708         (series[idx][1])();
709         if (idx + 1 < series.length) {
710                 carousel_timeout = setTimeout(function() { do_series_internal(series, idx + 1); }, series[idx][0]);
711         }
712 };
713
714 function showcarousel()
715 {
716         let teams_per_group = [];
717         let games_per_group = [];
718         let combined_teams = [];
719         let combined_games = [];
720         let num_left = 5;
721
722         let cb = function(response, group_name) {
723                 let teams = parse_teams_from_spreadsheet(response);
724                 let games = parse_games_from_spreadsheet(response, group_name, true);
725                 teams = filter_teams(teams, response);
726                 teams_per_group[group_name] = teams;
727                 games_per_group[group_name] = games;
728
729                 combined_teams = combined_teams.concat(teams);
730                 combined_games = combined_games.concat(games);
731                 if (--num_left == 0) {
732                         let series = [
733                                 [ 13000, function() { display_group_parsed(teams_per_group['Group A'], games_per_group['Group A'], 'Group A'); } ],
734                                 [ 2000, function() { hidetable(); } ],
735                                 [ 13000, function() { display_group_parsed(teams_per_group['Group B'], games_per_group['Group B'], 'Group B'); } ],
736                                 [ 2000, function() { hidetable(); } ],
737                                 [ 13000, function() { display_group_parsed(teams_per_group['Group C'], games_per_group['Group C'], 'Group C'); } ],
738                                 [ 2000, function() { hidetable(); } ],
739                                 [ 13000, function() { display_group_parsed(teams_per_group['Playoffs 9th-13th'], games_per_group['Playoffs 9th-13th'], 'Playoffs 9th–13th'); } ],
740                                 [ 2000, function() { hidetable(); } ]
741                         ];
742                         let num_pages = find_num_pages(combined_games);
743                         for (let page = 0; page < num_pages; ++page) {
744                                 series.push([ 13000, function() { display_stream_schedule_parsed(combined_teams, combined_games, page); } ]);
745                                 series.push([ 2000, function() { hidetable(); } ]);
746                         }
747
748                         do_series(series);
749                 }
750         };
751
752         get_group('Group A', cb);
753         get_group('Group B', cb);
754         get_group('Group C', cb);
755         get_group('Playoffs 9th-13th', cb);
756         get_group('Playoffs', cb);
757 };
758
759 function stopcarousel()
760 {
761         if (carousel_timeout !== null) {
762                 hidetable();
763                 clearTimeout(carousel_timeout);
764                 carousel_timeout = null;
765         }
766 };
767
768 function hidescorebug()
769 {
770         document.getElementById('entire-bug').style.display = 'none';
771 }
772
773 function showscorebug()
774 {
775         document.getElementById('entire-bug').style.display = null;
776 }
777
778 function showmatch2()
779 {
780         let css = "-webkit-animation: fade-in 1.0s ease; -webkit-animation-fill-mode: both;";
781         document.getElementById('scorebug2').style = css;
782         document.getElementById('clockbug2').style = css;
783 }
784
785 function hidematch2()
786 {
787         let css = "-webkit-animation: fade-out 1.0s ease; -webkit-animation-fill-mode: both;";
788         document.getElementById('scorebug2').style = css;
789         document.getElementById('clockbug2').style = css;
790 }
791
792 function showmatch3()
793 {
794         let css = "-webkit-animation: fade-in 1.0s ease; -webkit-animation-fill-mode: both;";
795         document.getElementById('scorebug3').style = css;
796         document.getElementById('clockbug3').style = css;
797 }
798
799 function hidematch3()
800 {
801         let css = "-webkit-animation: fade-out 1.0s ease; -webkit-animation-fill-mode: both;";
802         document.getElementById('scorebug3').style = css;
803         document.getElementById('clockbug3').style = css;
804 }