]> git.sesse.net Git - pkanalytics/blob - ultimate.js
Goal counts as a touch.
[pkanalytics] / ultimate.js
1 'use strict';
2
3 // No frameworks, no compilers, no npm, just JavaScript. :-)
4
5 let global_json;
6 let global_filters = {};
7 let next_filterset_id = 2;  // Arbitrary IDs are fine as long as they never collide.
8
9 addEventListener('hashchange', () => { process_matches(global_json, global_filters); });
10 addEventListener('click', possibly_close_menu);
11 fetch('ultimate.json')
12    .then(response => response.json())
13    .then(response => { global_json = response; process_matches(global_json, global_filters); });
14
15 function format_time(t)
16 {
17         const ms = t % 1000 + '';
18         t = Math.floor(t / 1000);
19         const sec = t % 60 + '';
20         t = Math.floor(t / 60);
21         const min = t % 60 + '';
22         const hour = Math.floor(t / 60) + '';
23         return hour + ':' + min.padStart(2, '0') + ':' + sec.padStart(2, '0') + '.' + ms.padStart(3, '0');
24 }
25
26 function attribute_player_time(player, to, from, offense) {
27         let delta_time;
28         if (player.on_field_since > from) {
29                 // Player came in while play happened (without a stoppage!?).
30                 delta_time = to - player.on_field_since;
31         } else {
32                 delta_time = to - from;
33         }
34         player.playing_time_ms += delta_time;
35         if (offense === true) {
36                 player.offensive_playing_time_ms += delta_time;
37         } else if (offense === false) {
38                 player.defensive_playing_time_ms += delta_time;
39         }
40 }
41
42 function take_off_field(player, t, live_since, offense, keep) {
43         if (keep) {
44                 if (live_since === null) {
45                         // Play isn't live, so nothing to do.
46                 } else {
47                         attribute_player_time(player, t, live_since, offense);
48                 }
49                 if (player.on_field_since !== null) {  // Just a safeguard; out without in should never happen.
50                         player.field_time_ms += t - player.on_field_since;
51                 }
52         }
53         player.on_field_since = null;
54 }
55
56 function add_cell(tr, element_type, text) {
57         let element = document.createElement(element_type);
58         element.textContent = text;
59         tr.appendChild(element);
60         return element;
61 }
62
63 function add_th(tr, text, colspan) {
64         let element = document.createElement('th');
65         let link = document.createElement('a');
66         link.style.cursor = 'pointer';
67         link.addEventListener('click', (e) => {
68                 sort_by(element);
69                 process_matches(global_json, global_filters);
70         });
71         link.textContent = text;
72         element.appendChild(link);
73         tr.appendChild(element);
74
75         if (colspan > 0) {
76                 element.setAttribute('colspan', colspan);
77         } else {
78                 element.setAttribute('colspan', '3');
79         }
80         return element;
81 }
82
83 function add_3cell(tr, text, cls) {
84         let p1 = add_cell(tr, 'td', '');
85         let element = add_cell(tr, 'td', text);
86         let p2 = add_cell(tr, 'td', '');
87
88         p1.classList.add('pad');
89         p2.classList.add('pad');
90         if (cls === undefined) {
91                 element.classList.add('num');
92         } else {
93                 element.classList.add(cls);
94         }
95         return element;
96 }
97
98 function add_3cell_with_filler_ci(tr, text, cls) {
99         let element = add_3cell(tr, text, cls);
100
101         let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
102         svg.classList.add('fillerci');
103         svg.setAttribute('width', ci_width);
104         svg.setAttribute('height', ci_height);
105         element.appendChild(svg);
106
107         return element;
108 }
109
110 function add_3cell_ci(tr, ci) {
111         if (isNaN(ci.val)) {
112                 add_3cell_with_filler_ci(tr, 'N/A');
113                 return;
114         }
115
116         let text;
117         if (ci.format === 'percentage') {
118                 text = (100 * ci.val).toFixed(0) + '%';
119         } else {
120                 text = ci.val.toFixed(2);
121         }
122         let element = add_3cell(tr, text);
123         let to_x = (val) => { return ci_width * (val - ci.min) / (ci.max - ci.min); };
124
125         // Container.
126         let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
127         if (ci.inverted === true) {
128                 svg.classList.add('invertedci');
129         } else {
130                 svg.classList.add('ci');
131         }
132         svg.setAttribute('width', ci_width);
133         svg.setAttribute('height', ci_height);
134
135         // The good (green) and red (bad) ranges.
136         let s0 = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
137         s0.classList.add('range');
138         s0.classList.add('s0');
139         s0.setAttribute('width', to_x(ci.desired));
140         s0.setAttribute('height', ci_height);
141         s0.setAttribute('x', '0');
142
143         let s1 = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
144         s1.classList.add('range');
145         s1.classList.add('s1');
146         s1.setAttribute('width', ci_width - to_x(ci.desired));
147         s1.setAttribute('height', ci_height);
148         s1.setAttribute('x', to_x(ci.desired));
149
150         // Confidence bar.
151         let bar = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
152         bar.classList.add('bar');
153         bar.setAttribute('width', to_x(ci.upper_ci) - to_x(ci.lower_ci));
154         bar.setAttribute('height', ci_height / 3);
155         bar.setAttribute('x', to_x(ci.lower_ci));
156         bar.setAttribute('y', ci_height / 3);
157
158         // Marker line for average.
159         let marker = document.createElementNS('http://www.w3.org/2000/svg', 'line');
160         marker.classList.add('marker');
161         marker.setAttribute('x1', to_x(ci.val));
162         marker.setAttribute('x2', to_x(ci.val));
163         marker.setAttribute('y1', ci_height / 6);
164         marker.setAttribute('y2', ci_height * 5 / 6);
165
166         svg.appendChild(s0);
167         svg.appendChild(s1);
168         svg.appendChild(bar);
169         svg.appendChild(marker);
170
171         element.appendChild(svg);
172 }
173
174 function calc_stats(json, filters) {
175         let players = {};
176         for (const player of json['players']) {
177                 players[player['player_id']] = {
178                         'name': player['name'],
179                         'gender': player['gender'],
180                         'number': player['number'],
181
182                         'goals': 0,
183                         'assists': 0,
184                         'hockey_assists': 0,
185                         'catches': 0,
186                         'touches': 0,
187                         'num_throws': 0,
188                         'throwaways': 0,
189                         'drops': 0,
190                         'was_ds': 0,
191                         'stallouts': 0,
192
193                         'defenses': 0,
194                         'callahans': 0,
195                         'points_played': 0,
196                         'playing_time_ms': 0,
197                         'offensive_playing_time_ms': 0,
198                         'defensive_playing_time_ms': 0,
199                         'field_time_ms': 0,
200
201                         // For efficiency.
202                         'offensive_points_completed': 0,
203                         'offensive_points_won': 0,
204                         'defensive_points_completed': 0,
205                         'defensive_points_won': 0,
206
207                         'offensive_soft_plus': 0,
208                         'offensive_soft_minus': 0,
209                         'defensive_soft_plus': 0,
210                         'defensive_soft_minus': 0,
211
212                         'pulls': 0,
213                         'pull_times': [],
214                         'oob_pulls': 0,
215
216                         // Internal.
217                         'last_point_seen': null,
218                         'on_field_since': null,
219                 };
220         }
221
222         // Globals.
223         players['globals'] = {
224                 'points_played': 0,
225                 'playing_time_ms': 0,
226                 'offensive_playing_time_ms': 0,
227                 'defensive_playing_time_ms': 0,
228                 'field_time_ms': 0,
229
230                 'offensive_points_completed': 0,
231                 'offensive_points_won': 0,
232                 'clean_holds': 0,
233
234                 'defensive_points_completed': 0,
235                 'defensive_points_won': 0,
236                 'clean_breaks': 0,
237
238                 'turnovers_won': 0,
239                 'turnovers_lost': 0,
240                 'their_clean_holds': 0,
241                 'their_clean_breaks': 0,
242         };
243         let globals = players['globals'];
244
245         for (const match of json['matches']) {
246                 if (!keep_match(match['match_id'], filters)) {
247                         continue;
248                 }
249
250                 let our_score = 0;
251                 let their_score = 0;
252                 let between_points = true;
253                 let handler = null;
254                 let handler_got_by_interception = false;  // Only relevant if handler !== null.
255                 let prev_handler = null;
256                 let live_since = null;
257                 let offense = null;  // True/false/null (unknown).
258                 let puller = null;
259                 let pull_started = null;
260                 let last_pull_was_ours = null;  // Effectively whether we're playing an O or D point (not affected by turnovers).
261                 let point_num = 0;
262                 let game_started = null;
263                 let last_goal = null;
264                 let current_predominant_gender = null;
265                 let current_num_players_on_field = null;
266
267                 let we_lost_disc = false;
268                 let they_lost_disc = false;
269
270                 // The last used formations of the given kind, if any; they may be reused
271                 // when the point starts, if nothing else is set.
272                 let last_offensive_formation = null;
273                 let last_defensive_formation = null;
274
275                 // Formations we've set, but haven't really had the chance to use yet
276                 // (e.g., we get a “use zone defense” event while we're still on offense).
277                 let pending_offensive_formation = null;
278                 let pending_defensive_formation = null;
279
280                 // Formations that we have played at least once this point, after all
281                 // heuristics and similar.
282                 let formations_used_this_point = new Set();
283
284                 for (const [q,p] of Object.entries(players)) {
285                         p.on_field_since = null;
286                         p.last_point_seen = null;
287                 }
288                 for (const e of match['events']) {
289                         let t = e['t'];
290                         let type = e['type'];
291                         let p = players[e['player']];
292
293                         // Sub management
294                         let keep = keep_event(players, formations_used_this_point, last_pull_was_ours, filters);
295                         if (type === 'in' && p.on_field_since === null) {
296                                 p.on_field_since = t;
297                                 if (!keep && keep_event(players, formations_used_this_point, last_pull_was_ours, filters)) {
298                                         // A player needed for the filters went onto the field,
299                                         // so pretend people walked on right now (to start their
300                                         // counting time).
301                                         for (const [q,p2] of Object.entries(players)) {
302                                                 if (p2.on_field_since !== null) {
303                                                         p2.on_field_since = t;
304                                                 }
305                                         }
306                                 }
307                         } else if (type === 'out') {
308                                 take_off_field(p, t, live_since, offense, keep);
309                                 if (keep && !keep_event(players, formations_used_this_point, last_pull_was_ours, filters)) {
310                                         // A player needed for the filters went off the field,
311                                         // so we need to attribute time for all the others.
312                                         // Pretend they walked off and then immediately on again.
313                                         //
314                                         // TODO: We also need to take care of this to get the globals right.
315                                         for (const [q,p2] of Object.entries(players)) {
316                                                 if (p2.on_field_since !== null) {
317                                                         take_off_field(p2, t, live_since, offense, keep);
318                                                         p2.on_field_since = t;
319                                                 }
320                                         }
321                                 }
322                         }
323
324                         keep = keep_event(players, formations_used_this_point, last_pull_was_ours, filters);  // Recompute after in/out.
325
326                         if (match['gender_rule_a']) {
327                                 if (type === 'restart' && !between_points) {
328                                         let predominant_gender = find_predominant_gender(players);
329                                         let num_players_on_field = find_num_players_on_field(players);
330                                         if (predominant_gender !== current_predominant_gender && current_predominant_gender !== null) {
331                                                 console.log(match['description'] + ' ' + format_time(t) + ': Stoppage changed predominant gender from ' + current_predominant_gender + ' to ' + predominant_gender);
332                                         }
333                                         if (num_players_on_field !== current_num_players_on_field && current_num_players_on_field !== null) {
334                                                 console.log(match['description'] + ' ' + format_time(t) + ': Stoppage changed number of players on field from ' + current_num_players_on_field + ' to ' + num_players_on_field);
335                                         }
336                                         current_predominant_gender = predominant_gender;
337                                         current_num_players_on_field = num_players_on_field;
338                                 } else if (type === 'pull' || type === 'their_pull') {
339                                         let predominant_gender = find_predominant_gender(players);
340                                         let num_players_on_field = find_num_players_on_field(players);
341                                         let changed = (predominant_gender !== current_predominant_gender);
342                                         if (point_num !== 0) {
343                                                 let should_change = (point_num % 4 == 1 || point_num % 4 == 3);  // ABBA changes on 1 and 3.
344                                                 if (changed && !should_change) {
345                                                         console.log(match['description'] + ' ' + format_time(t) + ': Gender ratio should have stayed the same, changed to predominance of ' + predominant_gender);
346                                                 } else if (!changed && should_change) {
347                                                         console.log(match['description'] + ' ' + format_time(t) + ': Gender ratio should have changed, remained predominantly ' + predominant_gender);
348                                                 }
349                                                 if (num_players_on_field !== current_num_players_on_field && current_num_players_on_field !== null) {
350                                                         console.log(match['description'] + ' ' + format_time(t) + ': Number of players on field changed from ' + current_num_players_on_field + ' to ' + num_players_on_field);
351                                                 }
352                                         }
353                                         current_predominant_gender = predominant_gender;
354                                         current_num_players_on_field = num_players_on_field;
355                                 }
356                         }
357                         if (match['gender_pull_rule']) {
358                                 if (type === 'pull') {
359                                         if (current_predominant_gender !== null &&
360                                             p.gender !== current_predominant_gender) {
361                                                 console.log(match['description'] + ' ' + format_time(t) + ': ' + p.name + ' pulled, should have been ' + current_predominant_gender);
362                                         }
363                                 }
364                         }
365
366                         // Liveness management
367                         if (type === 'pull' || type === 'their_pull' || type === 'restart') {
368                                 live_since = t;
369                                 between_points = false;
370                         } else if (type === 'catch' && last_pull_was_ours === null) {
371                                 // Someone forgot to add the pull, so we'll need to wing it.
372                                 console.log(match['description'] + ' ' + format_time(t) + ': Missing pull on ' + our_score + '\u2013' + their_score + '; pretending to have one.');
373                                 live_since = t;
374                                 last_pull_was_ours = !offense;
375                                 between_points = false;
376                         } else if (type === 'goal' || type === 'their_goal' || type === 'stoppage') {
377                                 if (type === 'goal') {
378                                         if (keep) ++p.touches;
379                                 }
380                                 for (const [q,p] of Object.entries(players)) {
381                                         if (p.on_field_since === null) {
382                                                 continue;
383                                         }
384                                         if (type !== 'stoppage' && p.last_point_seen !== point_num) {
385                                                 if (keep) {
386                                                         // In case the player did nothing this point,
387                                                         // not even subbing in.
388                                                         p.last_point_seen = point_num;
389                                                         ++p.points_played;
390                                                 }
391                                         }
392                                         if (keep) attribute_player_time(p, t, live_since, offense);
393
394                                         if (type !== 'stoppage') {
395                                                 if (keep) {
396                                                         if (last_pull_was_ours === true) {  // D point.
397                                                                 ++p.defensive_points_completed;
398                                                                 if (type === 'goal') {
399                                                                         ++p.defensive_points_won;
400                                                                 }
401                                                         } else if (last_pull_was_ours === false) {  // O point.
402                                                                 ++p.offensive_points_completed;
403                                                                 if (type === 'goal') {
404                                                                         ++p.offensive_points_won;
405                                                                 }
406                                                         }
407                                                 }
408                                         }
409                                 }
410
411                                 if (keep) {
412                                         if (type !== 'stoppage') {
413                                                 // Update globals.
414                                                 ++globals.points_played;
415                                                 if (last_pull_was_ours === true) {  // D point.
416                                                         ++globals.defensive_points_completed;
417                                                         if (type === 'goal') {
418                                                                 ++globals.defensive_points_won;
419                                                         }
420                                                 } else if (last_pull_was_ours === false) {  // O point.
421                                                         ++globals.offensive_points_completed;
422                                                         if (type === 'goal') {
423                                                                 ++globals.offensive_points_won;
424                                                         }
425                                                 }
426                                         }
427                                         if (live_since !== null) {
428                                                 globals.playing_time_ms += t - live_since;
429                                                 if (offense === true) {
430                                                         globals.offensive_playing_time_ms += t - live_since;
431                                                 } else if (offense === false) {
432                                                         globals.defensive_playing_time_ms += t - live_since;
433                                                 }
434                                         }
435                                 }
436
437                                 live_since = null;
438                         }
439
440                         // Score management
441                         if (type === 'goal') {
442                                 ++our_score;
443                         } else if (type === 'their_goal') {
444                                 ++their_score;
445                         }
446
447                         // Point count management
448                         if (p !== undefined && type !== 'out' && p.last_point_seen !== point_num) {
449                                 if (keep) {
450                                         p.last_point_seen = point_num;
451                                         ++p.points_played;
452                                 }
453                         }
454                         if (type === 'goal' || type === 'their_goal') {
455                                 ++point_num;
456                                 last_goal = t;
457                         }
458                         if (type !== 'out' && game_started === null) {
459                                 game_started = t;
460                         }
461
462                         // Pull management
463                         if (type === 'pull') {
464                                 puller = e['player'];
465                                 pull_started = t;
466                                 if (keep) ++p.pulls;
467                         } else if (type === 'in' || type === 'out' || type === 'stoppage' || type === 'restart' || type === 'unknown' || type === 'set_defense' || type === 'set_offense') {
468                                 // No effect on pull.
469                         } else if (type === 'pull_landed' && puller !== null) {
470                                 if (keep) players[puller].pull_times.push(t - pull_started);
471                         } else if (type === 'pull_oob' && puller !== null) {
472                                 if (keep) ++players[puller].oob_pulls;
473                         } else {
474                                 // Not pulling (if there was one, we never recorded its outcome, but still count it).
475                                 puller = pull_started = null;
476                         }
477
478                         // Stats for clean holds or not (must be done before resetting we_lost_disc etc. below).
479                         if (keep) {
480                                 if (type === 'goal' && !we_lost_disc) {
481                                         if (last_pull_was_ours === false) {  // O point.
482                                                 ++globals.clean_holds;
483                                         } else if (last_pull_was_ours === true) {
484                                                 ++globals.clean_breaks;
485                                         }
486                                 } else if (type === 'their_goal' && !they_lost_disc) {
487                                         if (last_pull_was_ours === true) {  // O point for them.
488                                                 ++globals.their_clean_holds;
489                                         } else if (last_pull_was_ours === false) {
490                                                 ++globals.their_clean_breaks;
491                                         }
492                                 }
493                         }
494
495                         // Offense/defense management
496                         let last_offense = offense;
497                         if (type === 'set_defense' || type === 'goal' || type === 'throwaway' || type === 'drop' || type === 'was_d' || type === 'stallout') {
498                                 offense = false;
499                                 we_lost_disc = true;
500                                 if (keep && type !== 'goal' && !(type === 'set_defense' && last_pull_was_ours === null)) {
501                                         ++globals.turnovers_lost;
502                                 }
503                         } else if (type === 'set_offense' || type === 'their_goal' || type === 'their_throwaway' || type === 'defense' || type === 'interception') {
504                                 offense = true;
505                                 they_lost_disc = true;
506                                 if (keep && type !== 'their_goal' && !(type === 'set_offense' && last_pull_was_ours === null)) {
507                                         ++globals.turnovers_won;
508                                 }
509                         }
510                         if (type === 'goal' || type === 'their_goal') {
511                                 between_points = true;
512                                 we_lost_disc = false;
513                                 they_lost_disc = false;
514                         }
515                         if (last_offense !== offense && live_since !== null) {
516                                 // Switched offense/defense status, so attribute this drive as needed,
517                                 // and update live_since to take that into account.
518                                 if (keep) {
519                                         for (const [q,p] of Object.entries(players)) {
520                                                 if (p.on_field_since === null) {
521                                                         continue;
522                                                 }
523                                                 attribute_player_time(p, t, live_since, last_offense);
524                                         }
525                                         globals.playing_time_ms += t - live_since;
526                                         if (offense === true) {
527                                                 globals.offensive_playing_time_ms += t - live_since;
528                                         } else if (offense === false) {
529                                                 globals.defensive_playing_time_ms += t - live_since;
530                                         }
531                                 }
532                                 live_since = t;
533                         }
534
535                         if (type === 'pull') {
536                                 last_pull_was_ours = true;
537                         } else if (type === 'their_pull') {
538                                 last_pull_was_ours = false;
539                         } else if (type === 'set_offense' && last_pull_was_ours === null) {
540                                 // set_offense could either be “changed to offense for some reason
541                                 // we could not express”, or “we started in the middle of a point,
542                                 // and we are offense”. We assume that if we already saw the pull,
543                                 // it's the former, and if not, it's the latter; thus, the === null
544                                 // test above. (It could also be “we set offense before the pull,
545                                 // so that we get the right button enabled”, in which case it will
546                                 // be overwritten by the next pull/their_pull event anyway.)
547                                 last_pull_was_ours = false;
548                         } else if (type === 'set_defense' && last_pull_was_ours === null) {
549                                 // Similar.
550                                 last_pull_was_ours = true;
551                         } else if (type === 'goal' || type === 'their_goal') {
552                                 last_pull_was_ours = null;
553                         }
554
555                         // Formation management
556                         if (type === 'formation_offense' || type === 'formation_defense') {
557                                 let id = e.formation === null ? 0 : e.formation;
558                                 let for_offense = (type === 'formation_offense');
559                                 if (offense === for_offense) {
560                                         formations_used_this_point.add(id);
561                                 } else if (for_offense) {
562                                         pending_offensive_formation = id;
563                                 } else {
564                                         pending_defensive_formation = id;
565                                 }
566                                 if (for_offense) {
567                                         last_offensive_formation = id;
568                                 } else {
569                                         last_defensive_formation = id;
570                                 }
571                         } else if (last_offense !== offense) {
572                                 if (offense === true && pending_offensive_formation !== null) {
573                                         formations_used_this_point.add(pending_offensive_formation);
574                                         pending_offensive_formation = null;
575                                 } else if (offense === false && pending_defensive_formation !== null) {
576                                         formations_used_this_point.add(pending_defensive_formation);
577                                         pending_defensive_formation = null;
578                                 } else if (offense === true && last_defensive_formation !== null) {
579                                         if (should_reuse_last_formation(match['events'], t)) {
580                                                 formations_used_this_point.add(last_defensive_formation);
581                                         }
582                                 } else if (offense === false && last_offensive_formation !== null) {
583                                         if (should_reuse_last_formation(match['events'], t)) {
584                                                 formations_used_this_point.add(last_offensive_formation);
585                                         }
586                                 }
587                         }
588
589                         if (type !== 'out' && type !== 'in' && p !== undefined && p.on_field_since === null) {
590                                 console.log(match['description'] + ' ' + format_time(t) + ': Event “' + type + '” on subbed-out player ' + p.name);
591                         }
592                         if (type === 'catch' && handler !== null && players[handler].on_field_since === null) {
593                                 // The handler subbed out and was replaced with another handler,
594                                 // so this wasn't a pass.
595                                 handler = null;
596                         }
597
598                         // Event management
599                         if (type === 'goal' && handler === e['player'] && handler_got_by_interception) {
600                                 // Self-pass to goal after an interception; this is not a real pass,
601                                 // just how we represent a Callahan right now -- so don't
602                                 // count the throw, any assists or similar.
603                                 //
604                                 // It's an open question how we should handle a self-pass that is
605                                 // _not_ after an interception, or a self-pass that's not a goal.
606                                 // (It must mean we tipped off someone.) We'll count it as a regular one
607                                 // for the time being, although it will make hockey assists weird.
608                                 if (keep) {
609                                         ++p.goals;
610                                         ++p.callahans;
611                                 }
612                                 handler = prev_handler = null;
613                         } else if (type === 'catch' || type === 'goal') {
614                                 if (handler !== null) {
615                                         if (keep) {
616                                                 ++players[handler].num_throws;
617                                                 ++p.catches;
618                                         }
619                                 }
620
621                                 if (keep) ++p.touches;
622                                 if (type === 'goal') {
623                                         if (keep) {
624                                                 if (prev_handler !== null) {
625                                                         ++players[prev_handler].hockey_assists;
626                                                 }
627                                                 if (handler !== null) {
628                                                         ++players[handler].assists;
629                                                 }
630                                                 ++p.goals;
631                                         }
632                                         handler = prev_handler = null;
633                                 } else {
634                                         // Update hold history.
635                                         prev_handler = handler;
636                                         handler = e['player'];
637                                         handler_got_by_interception = false;
638                                 }
639                         } else if (type === 'throwaway') {
640                                 if (keep) {
641                                         ++p.num_throws;
642                                         ++p.throwaways;
643                                 }
644                                 handler = prev_handler = null;
645                         } else if (type === 'drop') {
646                                 if (keep) ++p.drops;
647                                 handler = prev_handler = null;
648                         } else if (type === 'stallout') {
649                                 if (keep) ++p.stallouts;
650                                 handler = prev_handler = null;
651                         } else if (type === 'was_d') {
652                                 if (keep) ++p.was_ds;
653                                 handler = prev_handler = null;
654                         } else if (type === 'defense') {
655                                 if (keep) ++p.defenses;
656                         } else if (type === 'interception') {
657                                 if (keep) {
658                                         ++p.catches;
659                                         ++p.defenses;
660                                         ++p.touches;
661                                 }
662                                 prev_handler = null;
663                                 handler = e['player'];
664                                 handler_got_by_interception = true;
665                         } else if (type === 'offensive_soft_plus' || type === 'offensive_soft_minus' || type === 'defensive_soft_plus' || type === 'defensive_soft_minus') {
666                                 if (keep) ++p[type];
667                         } else if (type !== 'in' && type !== 'out' && type !== 'pull' &&
668                                    type !== 'their_goal' && type !== 'stoppage' && type !== 'restart' && type !== 'unknown' &&
669                                    type !== 'set_defense' && type !== 'goal' && type !== 'throwaway' &&
670                                    type !== 'drop' && type !== 'was_d' && type !== 'stallout' && type !== 'set_offense' && type !== 'their_goal' &&
671                                    type !== 'pull' && type !== 'pull_landed' && type !== 'pull_oob' && type !== 'their_pull' &&
672                                    type !== 'their_throwaway' && type !== 'defense' && type !== 'interception' &&
673                                    type !== 'formation_offense' && type !== 'formation_defense') {
674                                 console.log(format_time(t) + ": Unknown event “" + e + "”");
675                         }
676
677                         if (type === 'goal' || type === 'their_goal') {
678                                 formations_used_this_point.clear();
679                         }
680                 }
681
682                 // Add field time for all players still left at match end.
683                 const keep = keep_event(players, formations_used_this_point, last_pull_was_ours, filters);
684                 if (keep) {
685                         for (const [q,p] of Object.entries(players)) {
686                                 if (p.on_field_since !== null && last_goal !== null) {
687                                         p.field_time_ms += last_goal - p.on_field_since;
688                                 }
689                         }
690                         if (game_started !== null && last_goal !== null) {
691                                 globals.field_time_ms += last_goal - game_started;
692                         }
693                         if (live_since !== null && last_goal !== null) {
694                                 globals.playing_time_ms += last_goal - live_since;
695                                 if (offense === true) {
696                                         globals.offensive_playing_time_ms += last_goal - live_since;
697                                 } else if (offense === false) {
698                                         globals.defensive_playing_time_ms += last_goal - live_since;
699                                 }
700                         }
701                 }
702         }
703         return players;
704 }
705
706 function process_matches(json, filtersets) {
707         let chosen_category = get_chosen_category();
708         write_main_menu(chosen_category);
709
710         let filterset_values = Object.values(filtersets);
711         if (filterset_values.length === 0) {
712                 filterset_values = [[]];
713         }
714
715         let rowsets = [];
716         for (const filter of filterset_values) {
717                 let players = calc_stats(json, filter);
718                 let rows = [];
719                 if (chosen_category === 'general') {
720                         rows = make_table_general(players);
721                 } else if (chosen_category === 'offense') {
722                         rows = make_table_offense(players);
723                 } else if (chosen_category === 'defense') {
724                         rows = make_table_defense(players);
725                 } else if (chosen_category === 'playing_time') {
726                         rows = make_table_playing_time(players);
727                 } else if (chosen_category === 'per_point') {
728                         rows = make_table_per_point(players);
729                 } else if (chosen_category === 'team_wide') {
730                         rows = make_table_team_wide(players);
731                 }
732                 rowsets.push(rows);
733         }
734
735         let merged_rows = [];
736         if (filterset_values.length > 1) {
737                 for (let i = 0; i < rowsets.length; ++i) {
738                         let marker = make_filter_marker(filterset_values[i]);
739
740                         // Make filter header.
741                         let tr = rowsets[i][0];
742                         let th = document.createElement('th');
743                         th.textContent = '';
744                         tr.insertBefore(th, tr.firstChild);
745
746                         for (let j = 1; j < rowsets[i].length; ++j) {
747                                 let tr = rowsets[i][j];
748
749                                 // Remove the name for cleanness.
750                                 if (i != 0) {
751                                         tr.firstChild.nextSibling.textContent = '';
752                                 }
753
754                                 if (i != 0) {
755                                         tr.style.borderTop = '0px';
756                                 }
757                                 if (i != rowsets.length - 1) {
758                                         tr.style.borderBottom = '0px';
759                                 }
760
761                                 // Make filter marker.
762                                 let td = document.createElement('td');
763                                 td.textContent = marker;
764                                 td.classList.add('filtermarker');
765                                 tr.insertBefore(td, tr.firstChild);
766                         }
767                 }
768
769                 for (let i = 0; i < rowsets[0].length; ++i) {
770                         for (let j = 0; j < rowsets.length; ++j) {
771                                 merged_rows.push(rowsets[j][i]);
772                                 if (i === 0) break;  // Don't merge the headings.
773                         }
774                 }
775         } else {
776                 merged_rows = rowsets[0];
777         }
778         document.getElementById('stats').replaceChildren(...merged_rows);
779 }
780
781 function get_chosen_category() {
782         if (window.location.hash === '#offense') {
783                 return 'offense';
784         } else if (window.location.hash === '#defense') {
785                 return 'defense';
786         } else if (window.location.hash === '#playing_time') {
787                 return 'playing_time';
788         } else if (window.location.hash === '#per_point') {
789                 return 'per_point';
790         } else if (window.location.hash === '#team_wide') {
791                 return 'team_wide';
792         } else {
793                 return 'general';
794         }
795 }
796
797 function write_main_menu(chosen_category) {
798         let elems = [];
799         const categories = [
800                 ['general', 'General'],
801                 ['offense', 'Offense'],
802                 ['defense', 'Defense'],
803                 ['playing_time', 'Playing time'],
804                 ['per_point', 'Per point'],
805                 ['team_wide', 'Team-wide'],
806         ];
807         for (const [id, title] of categories) {
808                 if (chosen_category === id) {
809                         let span = document.createElement('span');
810                         span.innerText = title;
811                         elems.push(span);
812                 } else {
813                         let a = document.createElement('a');
814                         a.appendChild(document.createTextNode(title));
815                         a.setAttribute('href', '#' + id);
816                         elems.push(a);
817                 }
818         }
819
820         document.getElementById('mainmenu').replaceChildren(...elems);
821 }
822
823 // https://en.wikipedia.org/wiki/1.96#History
824 const z = 1.959964;
825
826 const ci_width = 100;
827 const ci_height = 20;
828
829 function make_binomial_ci(val, num, z) {
830         let avg = val / num;
831
832         // https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Wilson_score_interval
833         let low  = (avg + z*z/(2*num) - z * Math.sqrt(avg * (1.0 - avg) / num + z*z/(4*num*num))) / (1 + z*z/num);   
834         let high = (avg + z*z/(2*num) + z * Math.sqrt(avg * (1.0 - avg) / num + z*z/(4*num*num))) / (1 + z*z/num); 
835
836         // Fix the signs so that we don't get -0.00.
837         low = Math.max(low, 0.0);
838         return {
839                 'val': avg,
840                 'lower_ci': low,
841                 'upper_ci': high,
842                 'min': 0.0,
843                 'max': 1.0,
844         };
845 }
846
847 // These can only happen once per point, but you get -1 and +1
848 // instead of 0 and +1. After we rewrite to 0 and 1, it's a binomial,
849 // and then we can rewrite back.
850 function make_efficiency_ci(points_won, points_completed, z)
851 {
852         let ci = make_binomial_ci(points_won, points_completed, z);
853         ci.val = 2.0 * ci.val - 1.0;
854         ci.lower_ci = 2.0 * ci.lower_ci - 1.0;
855         ci.upper_ci = 2.0 * ci.upper_ci - 1.0;
856         ci.min = -1.0;
857         ci.max = 1.0;
858         ci.desired = 0.0;  // Desired = positive efficiency.
859         return ci;
860 }
861
862 // Ds, throwaways and drops can happen multiple times per point,
863 // so they are Poisson distributed.
864 //
865 // Modified Wald (recommended by http://www.ine.pt/revstat/pdf/rs120203.pdf
866 // since our rates are definitely below 2 per point).
867 function make_poisson_ci(val, num, z, inverted)
868 {
869         let low  = (val == 0) ? 0.0 : ((val - 0.5) - Math.sqrt(val - 0.5)) / num;
870         let high = (val == 0) ? -Math.log(0.025) / num : ((val + 0.5) + Math.sqrt(val + 0.5)) / num;
871
872         // Fix the signs so that we don't get -0.00.
873         low = Math.max(low, 0.0);
874
875         // The display range of 0 to 0.5 is fairly arbitrary. So is the desired 0.05 per point.
876         let avg = val / num;
877         return {
878                 'val': avg,
879                 'lower_ci': low,
880                 'upper_ci': high,
881                 'min': 0.0,
882                 'max': 0.5,
883                 'desired': 0.05,
884                 'inverted': inverted,
885         };
886 }
887
888 function make_table_general(players) {
889         let rows = [];
890         {
891                 let header = document.createElement('tr');
892                 add_th(header, 'Player');
893                 add_th(header, '+/-');
894                 add_th(header, 'Soft +/-');
895                 add_th(header, 'O efficiency');
896                 add_th(header, 'D efficiency');
897                 add_th(header, 'Points played');
898                 rows.push(header);
899         }
900
901         for (const [q,p] of get_sorted_players(players)) {
902                 if (q === 'globals') continue;
903                 let row = document.createElement('tr');
904                 let pm = p.goals + p.assists + p.hockey_assists + p.defenses - p.throwaways - p.drops - p.was_ds - p.stallouts;
905                 let soft_pm = p.offensive_soft_plus + p.defensive_soft_plus - p.offensive_soft_minus - p.defensive_soft_minus;
906                 let o_efficiency = make_efficiency_ci(p.offensive_points_won, p.offensive_points_completed, z);
907                 let d_efficiency = make_efficiency_ci(p.defensive_points_won, p.defensive_points_completed, z);
908                 let name = add_3cell(row, p.name, 'name');  // TODO: number?
909                 add_3cell(row, pm > 0 ? ('+' + pm) : pm);
910                 add_3cell(row, soft_pm > 0 ? ('+' + soft_pm) : soft_pm);
911                 add_3cell_ci(row, o_efficiency);
912                 add_3cell_ci(row, d_efficiency);
913                 add_3cell(row, p.points_played);
914                 row.dataset.player = q;
915                 rows.push(row);
916         }
917
918         // Globals.
919         let globals = players['globals'];
920         let o_efficiency = make_efficiency_ci(globals.offensive_points_won, globals.offensive_points_completed, z);
921         let d_efficiency = make_efficiency_ci(globals.defensive_points_won, globals.defensive_points_completed, z);
922         let row = document.createElement('tr');
923         add_3cell(row, '');
924         add_3cell(row, '');
925         add_3cell(row, '');
926         add_3cell_ci(row, o_efficiency);
927         add_3cell_ci(row, d_efficiency);
928         add_3cell(row, globals.points_played);
929         rows.push(row);
930
931         return rows;
932 }
933
934 function make_table_team_wide(players) {
935         let globals = players['globals'];
936
937         let rows = [];
938         {
939                 let header = document.createElement('tr');
940                 add_th(header, '');
941                 add_th(header, 'Our team', 6);
942                 add_th(header, 'Opponents', 6);
943                 rows.push(header);
944         }
945
946         // Turnovers.
947         {
948                 let row = document.createElement('tr');
949                 let name = add_3cell(row, 'Turnovers generated', 'name');
950                 add_3cell(row, '');
951                 add_3cell(row, globals.turnovers_won);
952                 add_3cell(row, '');
953                 add_3cell(row, globals.turnovers_lost);
954                 rows.push(row);
955         }
956
957         // Clean holds.
958         {
959                 let row = document.createElement('tr');
960                 let name = add_3cell(row, 'Clean holds', 'name');
961                 let our_clean_holds = make_binomial_ci(globals.clean_holds, globals.offensive_points_completed, z);
962                 let their_clean_holds = make_binomial_ci(globals.their_clean_holds, globals.defensive_points_completed, z);
963                 our_clean_holds.desired = 0.3;  // Arbitrary.
964                 our_clean_holds.format = 'percentage';
965                 their_clean_holds.desired = 0.3;
966                 their_clean_holds.format = 'percentage';
967                 add_3cell(row, globals.clean_holds + ' / ' + globals.offensive_points_completed);
968                 add_3cell_ci(row, our_clean_holds);
969                 add_3cell(row, globals.their_clean_holds + ' / ' + globals.defensive_points_completed);
970                 add_3cell_ci(row, their_clean_holds);
971                 rows.push(row);
972         }
973
974         // Clean breaks.
975         {
976                 let row = document.createElement('tr');
977                 let name = add_3cell(row, 'Clean breaks', 'name');
978                 let our_clean_breaks = make_binomial_ci(globals.clean_breaks, globals.defensive_points_completed, z);
979                 let their_clean_breaks = make_binomial_ci(globals.their_clean_breaks, globals.offensive_points_completed, z);
980                 our_clean_breaks.desired = 0.3;  // Arbitrary.
981                 our_clean_breaks.format = 'percentage';
982                 their_clean_breaks.desired = 0.3;
983                 their_clean_breaks.format = 'percentage';
984                 add_3cell(row, globals.clean_breaks + ' / ' + globals.defensive_points_completed);
985                 add_3cell_ci(row, our_clean_breaks);
986                 add_3cell(row, globals.their_clean_breaks + ' / ' + globals.offensive_points_completed);
987                 add_3cell_ci(row, their_clean_breaks);
988                 rows.push(row);
989         }
990
991         return rows;
992 }
993
994 function make_table_offense(players) {
995         let rows = [];
996         {
997                 let header = document.createElement('tr');
998                 add_th(header, 'Player');
999                 add_th(header, 'Goals');
1000                 add_th(header, 'Assists');
1001                 add_th(header, 'Hockey assists');
1002                 add_th(header, 'Throws');
1003                 add_th(header, 'Throwaways');
1004                 add_th(header, '%OK');
1005                 add_th(header, 'Catches');
1006                 add_th(header, 'Drops');
1007                 add_th(header, 'D-ed');
1008                 add_th(header, '%OK');
1009                 add_th(header, 'Stalls');
1010                 add_th(header, 'Soft +/-', 6);
1011                 rows.push(header);
1012         }
1013
1014         let goals = 0;
1015         let num_throws = 0;
1016         let throwaways = 0;
1017         let catches = 0;
1018         let drops = 0;
1019         let was_ds = 0;
1020         let stallouts = 0;
1021         for (const [q,p] of get_sorted_players(players)) {
1022                 if (q === 'globals') continue;
1023                 let throw_ok = make_binomial_ci(p.num_throws - p.throwaways, p.num_throws, z);
1024                 let catch_ok = make_binomial_ci(p.catches, p.catches + p.drops + p.was_ds, z);
1025
1026                 throw_ok.format = 'percentage';
1027                 catch_ok.format = 'percentage';
1028
1029                 // Desire at least 90% percentage. Fairly arbitrary.
1030                 throw_ok.desired = 0.9;
1031                 catch_ok.desired = 0.9;
1032
1033                 let row = document.createElement('tr');
1034                 add_3cell(row, p.name, 'name');  // TODO: number?
1035                 add_3cell(row, p.goals);
1036                 add_3cell(row, p.assists);
1037                 add_3cell(row, p.hockey_assists);
1038                 add_3cell(row, p.num_throws);
1039                 add_3cell(row, p.throwaways);
1040                 add_3cell_ci(row, throw_ok);
1041                 add_3cell(row, p.catches);
1042                 add_3cell(row, p.drops);
1043                 add_3cell(row, p.was_ds);
1044                 add_3cell_ci(row, catch_ok);
1045                 add_3cell(row, p.stallouts);
1046                 add_3cell(row, '+' + p.offensive_soft_plus);
1047                 add_3cell(row, '-' + p.offensive_soft_minus);
1048                 row.dataset.player = q;
1049                 rows.push(row);
1050
1051                 goals += p.goals;
1052                 num_throws += p.num_throws;
1053                 throwaways += p.throwaways;
1054                 catches += p.catches;
1055                 drops += p.drops;
1056                 was_ds += p.was_ds;
1057                 stallouts += p.stallouts;
1058         }
1059
1060         // Globals.
1061         let throw_ok = make_binomial_ci(num_throws - throwaways, num_throws, z);
1062         let catch_ok = make_binomial_ci(catches, catches + drops + was_ds, z);
1063         throw_ok.format = 'percentage';
1064         catch_ok.format = 'percentage';
1065         throw_ok.desired = 0.9;
1066         catch_ok.desired = 0.9;
1067
1068         let row = document.createElement('tr');
1069         add_3cell(row, '');
1070         add_3cell(row, goals);
1071         add_3cell(row, '');
1072         add_3cell(row, '');
1073         add_3cell(row, num_throws);
1074         add_3cell(row, throwaways);
1075         add_3cell_ci(row, throw_ok);
1076         add_3cell(row, catches);
1077         add_3cell(row, drops);
1078         add_3cell(row, was_ds);
1079         add_3cell_ci(row, catch_ok);
1080         add_3cell(row, stallouts);
1081         add_3cell(row, '');
1082         add_3cell(row, '');
1083         rows.push(row);
1084
1085         return rows;
1086 }
1087
1088 function make_table_defense(players) {
1089         let rows = [];
1090         {
1091                 let header = document.createElement('tr');
1092                 add_th(header, 'Player');
1093                 add_th(header, 'Ds');
1094                 add_th(header, 'Pulls');
1095                 add_th(header, 'OOB pulls');
1096                 add_th(header, 'OOB%');
1097                 add_th(header, 'Avg. hang time (IB)');
1098                 add_th(header, 'Callahans');
1099                 add_th(header, 'Soft +/-', 6);
1100                 rows.push(header);
1101         }
1102
1103         let defenses = 0;
1104         let pulls = 0;
1105         let oob_pulls = 0;
1106         let sum_sum_time = 0;
1107         let callahans = 0;
1108
1109         for (const [q,p] of get_sorted_players(players)) {
1110                 if (q === 'globals') continue;
1111                 let sum_time = 0;
1112                 for (const t of p.pull_times) {
1113                         sum_time += t;
1114                 }
1115                 let avg_time = 1e-3 * sum_time / (p.pulls - p.oob_pulls);
1116                 let oob_pct = 100 * p.oob_pulls / p.pulls;
1117
1118                 let ci_oob = make_binomial_ci(p.oob_pulls, p.pulls, z);
1119                 ci_oob.format = 'percentage';
1120                 ci_oob.desired = 0.2;  // Arbitrary.
1121                 ci_oob.inverted = true;
1122
1123                 let row = document.createElement('tr');
1124                 add_3cell(row, p.name, 'name');  // TODO: number?
1125                 add_3cell(row, p.defenses);
1126                 add_3cell(row, p.pulls);
1127                 add_3cell(row, p.oob_pulls);
1128                 add_3cell_ci(row, ci_oob);
1129                 if (p.pulls > p.oob_pulls) {
1130                         add_3cell(row, avg_time.toFixed(1) + ' sec');
1131                 } else {
1132                         add_3cell(row, 'N/A');
1133                 }
1134                 add_3cell(row, p.callahans);
1135                 add_3cell(row, '+' + p.defensive_soft_plus);
1136                 add_3cell(row, '-' + p.defensive_soft_minus);
1137                 row.dataset.player = q;
1138                 rows.push(row);
1139
1140                 defenses += p.defenses;
1141                 pulls += p.pulls;
1142                 oob_pulls += p.oob_pulls;
1143                 sum_sum_time += sum_time;
1144                 callahans += p.callahans;
1145         }
1146
1147         // Globals.
1148         let ci_oob = make_binomial_ci(oob_pulls, pulls, z);
1149         ci_oob.format = 'percentage';
1150         ci_oob.desired = 0.2;  // Arbitrary.
1151         ci_oob.inverted = true;
1152
1153         let avg_time = 1e-3 * sum_sum_time / (pulls - oob_pulls);
1154         let oob_pct = 100 * oob_pulls / pulls;
1155
1156         let row = document.createElement('tr');
1157         add_3cell(row, '');
1158         add_3cell(row, defenses);
1159         add_3cell(row, pulls);
1160         add_3cell(row, oob_pulls);
1161         add_3cell_ci(row, ci_oob);
1162         if (pulls > oob_pulls) {
1163                 add_3cell(row, avg_time.toFixed(1) + ' sec');
1164         } else {
1165                 add_3cell(row, 'N/A');
1166         }
1167         add_3cell(row, callahans);
1168         add_3cell(row, '');
1169         add_3cell(row, '');
1170         rows.push(row);
1171
1172         return rows;
1173 }
1174
1175 function make_table_playing_time(players) {
1176         let rows = [];
1177         {
1178                 let header = document.createElement('tr');
1179                 add_th(header, 'Player');
1180                 add_th(header, 'Points played');
1181                 add_th(header, 'Time played');
1182                 add_th(header, 'O time');
1183                 add_th(header, 'D time');
1184                 add_th(header, 'Time on field');
1185                 add_th(header, 'O points');
1186                 add_th(header, 'D points');
1187                 rows.push(header);
1188         }
1189
1190         for (const [q,p] of get_sorted_players(players)) {
1191                 if (q === 'globals') continue;
1192                 let row = document.createElement('tr');
1193                 add_3cell(row, p.name, 'name');  // TODO: number?
1194                 add_3cell(row, p.points_played);
1195                 add_3cell(row, Math.floor(p.playing_time_ms / 60000) + ' min');
1196                 add_3cell(row, Math.floor(p.offensive_playing_time_ms / 60000) + ' min');
1197                 add_3cell(row, Math.floor(p.defensive_playing_time_ms / 60000) + ' min');
1198                 add_3cell(row, Math.floor(p.field_time_ms / 60000) + ' min');
1199                 add_3cell(row, p.offensive_points_completed);
1200                 add_3cell(row, p.defensive_points_completed);
1201                 row.dataset.player = q;
1202                 rows.push(row);
1203         }
1204
1205         // Globals.
1206         let globals = players['globals'];
1207         let row = document.createElement('tr');
1208         add_3cell(row, '');
1209         add_3cell(row, globals.points_played);
1210         add_3cell(row, Math.floor(globals.playing_time_ms / 60000) + ' min');
1211         add_3cell(row, Math.floor(globals.offensive_playing_time_ms / 60000) + ' min');
1212         add_3cell(row, Math.floor(globals.defensive_playing_time_ms / 60000) + ' min');
1213         add_3cell(row, Math.floor(globals.field_time_ms / 60000) + ' min');
1214         add_3cell(row, globals.offensive_points_completed);
1215         add_3cell(row, globals.defensive_points_completed);
1216         rows.push(row);
1217
1218         return rows;
1219 }
1220
1221 function make_table_per_point(players) {
1222         let rows = [];
1223         {
1224                 let header = document.createElement('tr');
1225                 add_th(header, 'Player');
1226                 add_th(header, 'Goals');
1227                 add_th(header, 'Assists');
1228                 add_th(header, 'Hockey assists');
1229                 add_th(header, 'Ds');
1230                 add_th(header, 'Throwaways');
1231                 add_th(header, 'Recv. errors');
1232                 add_th(header, 'Touches');
1233                 rows.push(header);
1234         }
1235
1236         let goals = 0;
1237         let assists = 0;
1238         let hockey_assists = 0;
1239         let defenses = 0;
1240         let throwaways = 0;
1241         let receiver_errors = 0;
1242         let touches = 0;
1243         for (const [q,p] of get_sorted_players(players)) {
1244                 if (q === 'globals') continue;
1245
1246                 // Can only happen once per point, so these are binomials.
1247                 let ci_goals = make_binomial_ci(p.goals, p.points_played, z);
1248                 let ci_assists = make_binomial_ci(p.assists, p.points_played, z);
1249                 let ci_hockey_assists = make_binomial_ci(p.hockey_assists, p.points_played, z);
1250                 // Arbitrarily desire at least 10% (not everybody can score or assist).
1251                 ci_goals.desired = 0.1;
1252                 ci_assists.desired = 0.1;
1253                 ci_hockey_assists.desired = 0.1;
1254
1255                 let row = document.createElement('tr');
1256                 add_3cell(row, p.name, 'name');  // TODO: number?
1257                 add_3cell_ci(row, ci_goals);
1258                 add_3cell_ci(row, ci_assists);
1259                 add_3cell_ci(row, ci_hockey_assists);
1260                 add_3cell_ci(row, make_poisson_ci(p.defenses, p.points_played, z));
1261                 add_3cell_ci(row, make_poisson_ci(p.throwaways, p.points_played, z, true));
1262                 add_3cell_ci(row, make_poisson_ci(p.drops + p.was_ds, p.points_played, z, true));
1263                 if (p.points_played > 0) {
1264                         add_3cell(row, p.touches == 0 ? 0 : (p.touches / p.points_played).toFixed(2));
1265                 } else {
1266                         add_3cell(row, 'N/A');
1267                 }
1268                 row.dataset.player = q;
1269                 rows.push(row);
1270
1271                 goals += p.goals;
1272                 assists += p.assists;
1273                 hockey_assists += p.hockey_assists;
1274                 defenses += p.defenses;
1275                 throwaways += p.throwaways;
1276                 receiver_errors += p.drops + p.was_ds;
1277                 touches += p.touches;
1278         }
1279
1280         // Globals.
1281         let globals = players['globals'];
1282         let row = document.createElement('tr');
1283         add_3cell(row, '');
1284         if (globals.points_played > 0) {
1285                 let ci_goals = make_binomial_ci(goals, globals.points_played, z);
1286                 let ci_assists = make_binomial_ci(assists, globals.points_played, z);
1287                 let ci_hockey_assists = make_binomial_ci(hockey_assists, globals.points_played, z);
1288                 ci_goals.desired = 0.5;
1289                 ci_assists.desired = 0.5;
1290                 ci_hockey_assists.desired = 0.5;
1291
1292                 add_3cell_ci(row, ci_goals);
1293                 add_3cell_ci(row, ci_assists);
1294                 add_3cell_ci(row, ci_hockey_assists);
1295
1296                 add_3cell_ci(row, make_poisson_ci(defenses, globals.points_played, z));
1297                 add_3cell_ci(row, make_poisson_ci(throwaways, globals.points_played, z, true));
1298                 add_3cell_ci(row, make_poisson_ci(receiver_errors, globals.points_played, z, true));
1299
1300                 add_3cell(row, touches == 0 ? 0 : (touches / globals.points_played).toFixed(2));
1301         } else {
1302                 add_3cell_with_filler_ci(row, 'N/A');
1303                 add_3cell_with_filler_ci(row, 'N/A');
1304                 add_3cell_with_filler_ci(row, 'N/A');
1305                 add_3cell_with_filler_ci(row, 'N/A');
1306                 add_3cell_with_filler_ci(row, 'N/A');
1307                 add_3cell_with_filler_ci(row, 'N/A');
1308                 add_3cell(row, 'N/A');
1309         }
1310         rows.push(row);
1311
1312         return rows;
1313 }
1314
1315 function open_filter_menu(click_to_add_div) {
1316         document.getElementById('filter-submenu').style.display = 'none';
1317         let filter_div = click_to_add_div.parentElement;
1318         let filter_id = filter_div.dataset.filterId;
1319
1320         let menu = document.getElementById('filter-add-menu');
1321         menu.parentElement.removeChild(menu);
1322         filter_div.appendChild(menu);
1323         menu.style.display = 'block';
1324         menu.replaceChildren();
1325
1326         // Place the menu directly under the “click to add” label;
1327         // we don't anchor it since that label will move around
1328         // and the menu shouldn't.
1329         let rect = click_to_add_div.getBoundingClientRect();
1330         menu.style.left = rect.left + 'px';
1331         menu.style.top = (rect.bottom + 10) + 'px';
1332
1333         let filterset = global_filters[filter_id];
1334         if (filterset === undefined) {
1335                 global_filters[filter_id] = filterset = [];
1336         }
1337
1338         add_menu_item(filter_div, filterset, menu, 0, 'match', 'Match (any)');
1339         add_menu_item(filter_div, filterset, menu, 1, 'player_any', 'Player on field (any)');
1340         add_menu_item(filter_div, filterset, menu, 2, 'player_all', 'Player on field (all)');
1341         add_menu_item(filter_div, filterset, menu, 3, 'formation_offense', 'Offense played (any)');
1342         add_menu_item(filter_div, filterset, menu, 4, 'formation_defense', 'Defense played (any)');
1343         add_menu_item(filter_div, filterset, menu, 5, 'starting_on', 'Starting on');
1344         add_menu_item(filter_div, filterset, menu, 6, 'gender_ratio', 'Gender ratio');
1345 }
1346
1347 function add_menu_item(filter_div, filterset, menu, menu_idx, filter_type, title) {
1348         let item = document.createElement('div');
1349         item.classList.add('option');
1350         item.appendChild(document.createTextNode(title));
1351
1352         let arrow = document.createElement('div');
1353         arrow.classList.add('arrow');
1354         arrow.textContent = '▸';
1355         item.appendChild(arrow);
1356
1357         menu.appendChild(item);
1358
1359         item.addEventListener('click', (e) => { show_submenu(filter_div, filterset, menu_idx, null, filter_type); });
1360 }
1361
1362 function find_all_ratios(json)
1363 {
1364         let ratios = {};
1365         let players = {};
1366         for (const player of json['players']) {
1367                 players[player['player_id']] = {
1368                         'gender': player['gender'],
1369                         'last_point_seen': null,
1370                 };
1371         }
1372         for (const match of json['matches']) {
1373                 for (const [q,p] of Object.entries(players)) {
1374                         p.on_field_since = null;
1375                 }
1376                 for (const e of match['events']) {
1377                         let p = players[e['player']];
1378                         let type = e['type'];
1379                         if (type === 'in') {
1380                                 p.on_field_since = 1;
1381                         } else if (type === 'out') {
1382                                 p.on_field_since = null;
1383                         } else if (type === 'pull' || type == 'their_pull') {  // We assume no cross-gender subs for now.
1384                                 let code = find_gender_ratio_code(players);
1385                                 if (ratios[code] === undefined) {
1386                                         ratios[code] = code;
1387                                         if (code !== '4 F, 3 M' && code !== '4 M, 3 F' && code !== '3 M, 2 F' && code !== '3 F, 2 M') {
1388                                                 console.log('Unexpected gender ratio ' + code + ' first seen at: ' +
1389                                                             match['description'] + ', ' + format_time(e['t']));
1390                                         }
1391                                 }
1392                         }
1393                 }
1394         }
1395         return ratios;
1396 }
1397
1398 function show_submenu(filter_div, filterset, menu_idx, pill, filter_type) {
1399         let submenu = document.getElementById('filter-submenu');
1400
1401         // Move to the same place as the top-level menu.
1402         submenu.parentElement.removeChild(submenu);
1403         document.getElementById('filter-add-menu').parentElement.appendChild(submenu);
1404
1405         let subitems = [];
1406         const filter = find_filter(filterset, filter_type);
1407
1408         let choices = [];
1409         if (filter_type === 'match') {
1410                 for (const match of global_json['matches']) {
1411                         choices.push({
1412                                 'title': match['description'],
1413                                 'id': match['match_id']
1414                         });
1415                 }
1416         } else if (filter_type === 'player_any' || filter_type === 'player_all') {
1417                 for (const player of global_json['players']) {
1418                         choices.push({
1419                                 'title': player['name'],
1420                                 'id': player['player_id']
1421                         });
1422                 }
1423         } else if (filter_type === 'formation_offense') {
1424                 choices.push({
1425                         'title': '(None/unknown)',
1426                         'id': 0,
1427                 });
1428                 for (const formation of global_json['formations']) {
1429                         if (formation['offense']) {
1430                                 choices.push({
1431                                         'title': formation['name'],
1432                                         'id': formation['formation_id']
1433                                 });
1434                         }
1435                 }
1436         } else if (filter_type === 'formation_defense') {
1437                 choices.push({
1438                         'title': '(None/unknown)',
1439                         'id': 0,
1440                 });
1441                 for (const formation of global_json['formations']) {
1442                         if (!formation['offense']) {
1443                                 choices.push({
1444                                         'title': formation['name'],
1445                                         'id': formation['formation_id']
1446                                 });
1447                         }
1448                 }
1449         } else if (filter_type === 'starting_on') {
1450                 choices.push({
1451                         'title': 'Offense',
1452                         'id': false,  // last_pull_was_ours
1453                 });
1454                 choices.push({
1455                         'title': 'Defense',
1456                         'id': true,  // last_pull_was_ours
1457                 });
1458         } else if (filter_type === 'gender_ratio') {
1459                 for (const [title, id] of Object.entries(find_all_ratios(global_json)).sort()) {
1460                         choices.push({
1461                                 'title': title,
1462                                 'id': id,
1463                         });
1464                 }
1465         }
1466
1467         for (const choice of choices) {
1468                 let label = document.createElement('label');
1469
1470                 let subitem = document.createElement('div');
1471                 subitem.classList.add('option');
1472
1473                 let check = document.createElement('input');
1474                 check.setAttribute('type', 'checkbox');
1475                 check.setAttribute('id', 'choice' + choice.id);
1476                 if (filter !== null && filter.elements.has(choice.id)) {
1477                         check.setAttribute('checked', 'checked');
1478                 }
1479                 check.addEventListener('change', (e) => { checkbox_changed(filter_div, filterset, e, filter_type, choice.id); });
1480
1481                 subitem.appendChild(check);
1482                 subitem.appendChild(document.createTextNode(choice.title));
1483
1484                 label.appendChild(subitem);
1485                 subitems.push(label);
1486         }
1487         submenu.replaceChildren(...subitems);
1488         submenu.style.display = 'block';
1489
1490         if (pill !== null) {
1491                 let rect = pill.getBoundingClientRect();
1492                 submenu.style.top = (rect.bottom + 10) + 'px';
1493                 submenu.style.left = rect.left + 'px';
1494         } else {
1495                 // Position just outside the selected menu.
1496                 let rect = document.getElementById('filter-add-menu').getBoundingClientRect();
1497                 submenu.style.top = (rect.top + menu_idx * 35) + 'px';
1498                 submenu.style.left = (rect.right - 1) + 'px';
1499         }
1500 }
1501
1502 // Find the right filter, if it exists.
1503 function find_filter(filterset, filter_type) {
1504         for (let f of filterset) {
1505                 if (f.type === filter_type) {
1506                         return f;
1507                 }
1508         }
1509         return null;
1510 }
1511
1512 // Equivalent to Array.prototype.filter, but in-place.
1513 function inplace_filter(arr, cond) {
1514         let j = 0;
1515         for (let i = 0; i < arr.length; ++i) {
1516                 if (cond(arr[i])) {
1517                         arr[j++] = arr[i];
1518                 }
1519         }
1520         arr.length = j;
1521 }
1522
1523 function add_new_filterset() {
1524         let template = document.querySelector('.filter');  // First one is fine.
1525         let div = template.cloneNode(true);
1526         let add_menu = div.querySelector('#filter-add-menu');
1527         if (add_menu !== null) {
1528                 div.removeChild(add_menu);
1529         }
1530         let submenu = div.querySelector('#filter-submenu');
1531         if (submenu !== null) {
1532                 div.removeChild(submenu);
1533         }
1534         div.querySelector('.filters').replaceChildren();
1535         div.dataset.filterId = next_filterset_id++;
1536         template.parentElement.appendChild(div);
1537 }
1538
1539 function try_gc_filter_menus(cheap) {
1540         let empties = [];
1541         let divs = [];
1542         for (const filter_div of document.querySelectorAll('.filter')) {
1543                 let id = filter_div.dataset.filterId;
1544                 divs.push(filter_div);
1545                 empties.push(global_filters[id] === undefined || global_filters[id].length === 0);
1546         }
1547         let last_div = divs[empties.length - 1];
1548         if (cheap) {
1549                 // See if we have two empty filter lists at the bottom of the list;
1550                 // if so, we can remove one without it looking funny in the UI.
1551                 // If not, we'll have to wait until closing the menu.
1552                 // (The menu is positioned at the second-last one, so we can
1553                 // remove the last one without issue.)
1554                 if (empties.length >= 2 && empties[empties.length - 2] && empties[empties.length - 1]) {
1555                         delete global_filters[last_div.dataset.filterId];
1556                         last_div.parentElement.removeChild(last_div);
1557                 }
1558         } else {
1559                 // This is a different situation from try_cheap_gc(), where we should
1560                 // remove the one _not_ last. We might need to move the menu to the last one,
1561                 // though, so it doesn't get lost.
1562                 for (let i = 0; i < empties.length - 1; ++i) {
1563                         if (!empties[i]) {
1564                                 continue;
1565                         }
1566                         let div = divs[i];
1567                         delete global_filters[div.dataset.filterId];
1568                         let add_menu = div.querySelector('#filter-add-menu');
1569                         if (add_menu !== null) {
1570                                 div.removeChild(add_menu);
1571                                 last_div.appendChild(add_menu);
1572                         }
1573                         let submenu = div.querySelector('#filter-submenu');
1574                         if (submenu !== null) {
1575                                 div.removeChild(submenu);
1576                                 last_div.appendChild(submenu);
1577                         }
1578                         div.parentElement.removeChild(div);
1579                 }
1580         }
1581 }
1582
1583 function checkbox_changed(filter_div, filterset, e, filter_type, id) {
1584         let filter = find_filter(filterset, filter_type);
1585         let pills_div = filter_div.querySelector('.filters');
1586         if (e.target.checked) {
1587                 // See if we must create a new empty filterset (since this went from
1588                 // empty to non-empty).
1589                 if (filterset.length === 0) {
1590                         add_new_filterset();
1591                 }
1592
1593                 // See if we must add a new filter to the list.
1594                 if (filter === null) {
1595                         filter = {
1596                                 'type': filter_type,
1597                                 'elements': new Set([ id ]),
1598                         };
1599                         filter.pill = make_filter_pill(filter_div, filterset, filter);
1600                         filterset.push(filter);
1601                         pills_div.appendChild(filter.pill);
1602                 } else {
1603                         filter.elements.add(id);
1604                         let new_pill = make_filter_pill(filter_div, filterset, filter);
1605                         pills_div.replaceChild(new_pill, filter.pill);
1606                         filter.pill = new_pill;
1607                 }
1608         } else {
1609                 filter.elements.delete(id);
1610                 if (filter.elements.size === 0) {
1611                         pills_div.removeChild(filter.pill);
1612                         inplace_filter(filterset, f => f !== filter);
1613
1614                         if (filterset.length == 0) {
1615                                 try_gc_filter_menus(true);
1616                         }
1617                 } else {
1618                         let new_pill = make_filter_pill(filter_div, filterset, filter);
1619                         pills_div.replaceChild(new_pill, filter.pill);
1620                         filter.pill = new_pill;
1621                 }
1622         }
1623
1624         process_matches(global_json, global_filters);
1625 }
1626
1627 function make_filter_pill(filter_div, filterset, filter) {
1628         let pill = document.createElement('div');
1629         pill.classList.add('filter-pill');
1630         let text;
1631         if (filter.type === 'match') {
1632                 text = 'Match: ';
1633
1634                 let all_names = [];
1635                 for (const match_id of filter.elements) {
1636                         all_names.push(find_match(match_id)['description']);
1637                 }
1638                 let common_prefix = find_common_prefix_of_all(all_names);
1639                 if (common_prefix !== null) {
1640                         text += common_prefix + '(';
1641                 }
1642
1643                 let first = true;
1644                 let sorted_match_id = Array.from(filter.elements).sort((a, b) => a - b);
1645                 for (const match_id of sorted_match_id) {
1646                         if (!first) {
1647                                 text += ', ';
1648                         }
1649                         let desc = find_match(match_id)['description'];
1650                         if (common_prefix === null) {
1651                                 text += desc;
1652                         } else {
1653                                 text += desc.substr(common_prefix.length);
1654                         }
1655                         first = false;
1656                 }
1657
1658                 if (common_prefix !== null) {
1659                         text += ')';
1660                 }
1661         } else if (filter.type === 'player_any') {
1662                 text = 'Player (any): ';
1663                 let sorted_players = Array.from(filter.elements).sort((a, b) => player_pos(a) - player_pos(b));
1664                 let first = true;
1665                 for (const player_id of sorted_players) {
1666                         if (!first) {
1667                                 text += ', ';
1668                         }
1669                         text += find_player(player_id)['name'];
1670                         first = false;
1671                 }
1672         } else if (filter.type === 'player_all') {
1673                 text = 'Players: ';
1674                 let sorted_players = Array.from(filter.elements).sort((a, b) => player_pos(a) - player_pos(b));
1675                 let first = true;
1676                 for (const player_id of sorted_players) {
1677                         if (!first) {
1678                                 text += ' AND ';
1679                         }
1680                         text += find_player(player_id)['name'];
1681                         first = false;
1682                 }
1683         } else if (filter.type === 'formation_offense' || filter.type === 'formation_defense') {
1684                 const offense = (filter.type === 'formation_offense');
1685                 if (offense) {
1686                         text = 'Offense: ';
1687                 } else {
1688                         text = 'Defense: ';
1689                 }
1690
1691                 let all_names = [];
1692                 for (const formation_id of filter.elements) {
1693                         all_names.push(find_formation(formation_id)['name']);
1694                 }
1695                 let common_prefix = find_common_prefix_of_all(all_names);
1696                 if (common_prefix !== null) {
1697                         text += common_prefix + '(';
1698                 }
1699
1700                 let first = true;
1701                 let sorted_formation_id = Array.from(filter.elements).sort((a, b) => a - b);
1702                 for (const formation_id of sorted_formation_id) {
1703                         if (!first) {
1704                                 ktext += ', ';
1705                         }
1706                         let desc = find_formation(formation_id)['name'];
1707                         if (common_prefix === null) {
1708                                 text += desc;
1709                         } else {
1710                                 text += desc.substr(common_prefix.length);
1711                         }
1712                         first = false;
1713                 }
1714
1715                 if (common_prefix !== null) {
1716                         text += ')';
1717                 }
1718         } else if (filter.type === 'starting_on') {
1719                 text = 'Starting on: ';
1720
1721                 if (filter.elements.has(false) && filter.elements.has(true)) {
1722                         text += 'Any';
1723                 } else if (filter.elements.has(false)) {
1724                         text += 'Offense';
1725                 } else {
1726                         text += 'Defense';
1727                 }
1728         } else if (filter.type === 'gender_ratio') {
1729                 text = 'Gender: ';
1730
1731                 let first = true;
1732                 for (const name of Array.from(filter.elements).sort()) {
1733                         if (!first) {
1734                                 text += '; ';
1735                         }
1736                         text += name;  // FIXME
1737                         first = false;
1738                 }
1739         }
1740
1741         let text_node = document.createElement('span');
1742         text_node.innerText = text;
1743         text_node.addEventListener('click', (e) => show_submenu(filter_div, filterset, null, pill, filter.type));
1744         pill.appendChild(text_node);
1745
1746         pill.appendChild(document.createTextNode(' '));
1747
1748         let delete_node = document.createElement('span');
1749         delete_node.innerText = '✖';
1750         delete_node.addEventListener('click', (e) => {
1751                 // Delete this filter entirely.
1752                 pill.parentElement.removeChild(pill);
1753                 inplace_filter(filterset, f => f !== filter);
1754                 if (filterset.length == 0) {
1755                         try_gc_filter_menus(false);
1756                 }
1757                 process_matches(global_json, global_filters);
1758
1759                 let add_menu = document.getElementById('filter-add-menu');
1760                 let add_submenu = document.getElementById('filter-submenu');
1761                 add_menu.style.display = 'none';
1762                 add_submenu.style.display = 'none';
1763         });
1764         pill.appendChild(delete_node);
1765         pill.style.cursor = 'pointer';
1766
1767         return pill;
1768 }
1769
1770 function make_filter_marker(filterset) {
1771         let text = '';
1772         for (const filter of filterset) {
1773                 if (text !== '') {
1774                         text += ',';
1775                 }
1776                 if (filter.type === 'match') {
1777                         let all_names = [];
1778                         for (const match of global_json['matches']) {
1779                                 all_names.push(match['description']);
1780                         }
1781                         let common_prefix = find_common_prefix_of_all(all_names);
1782
1783                         let sorted_match_id = Array.from(filter.elements).sort((a, b) => a - b);
1784                         for (const match_id of sorted_match_id) {
1785                                 let desc = find_match(match_id)['description'];
1786                                 if (common_prefix === null) {
1787                                         text += desc.substr(0, 3);
1788                                 } else {
1789                                         text += desc.substr(common_prefix.length, 3);
1790                                 }
1791                         }
1792                 } else if (filter.type === 'player_any' || filter.type === 'player_all') {
1793                         let sorted_players = Array.from(filter.elements).sort((a, b) => player_pos(a) - player_pos(b));
1794                         for (const player_id of sorted_players) {
1795                                 text += find_player(player_id)['name'].substr(0, 3);
1796                         }
1797                 } else if (filter.type === 'formation_offense' || filter.type === 'formation_defense') {
1798                         let sorted_formation_id = Array.from(filter.elements).sort((a, b) => a - b);
1799                         for (const formation_id of sorted_formation_id) {
1800                                 text += find_formation(formation_id)['name'].substr(0, 3);
1801                         }
1802                 } else if (filter.type === 'starting_on') {
1803                         if (filter.elements.has(false) && filter.elements.has(true)) {
1804                                 // Nothing.
1805                         } else if (filter.elements.has(false)) {
1806                                 text += 'O';
1807                         } else {
1808                                 text += 'D';
1809                         }
1810                 } else if (filter.type === 'gender_ratio') {
1811                         let first = true;
1812                         for (const name of Array.from(filter.elements).sort()) {
1813                                 if (!first) {
1814                                         text += '; ';
1815                                 }
1816                                 text += name.replaceAll(' ', '').substr(0, 2);  // 4 F, 3M -> 4 F.
1817                                 first = false;
1818                         }
1819                 }
1820         }
1821         return text;
1822 }
1823
1824 function find_common_prefix(a, b) {
1825         let ret = '';
1826         for (let i = 0; i < Math.min(a.length, b.length); ++i) {
1827                 if (a[i] === b[i]) {
1828                         ret += a[i];
1829                 } else {
1830                         break;
1831                 }
1832         }
1833         return ret;
1834 }
1835
1836 function find_common_prefix_of_all(values) {
1837         if (values.length < 2) {
1838                 return null;
1839         }
1840         let common_prefix = null;
1841         for (const desc of values) {
1842                 if (common_prefix === null) {
1843                         common_prefix = desc;
1844                 } else {
1845                         common_prefix = find_common_prefix(common_prefix, desc);
1846                 }
1847         }
1848         if (common_prefix.length >= 3) {
1849                 return common_prefix;
1850         } else {
1851                 return null;
1852         }
1853 }
1854
1855 function find_match(match_id) {
1856         for (const match of global_json['matches']) {
1857                 if (match['match_id'] === match_id) {
1858                         return match;
1859                 }
1860         }
1861         return null;
1862 }
1863
1864 function find_formation(formation_id) {
1865         for (const formation of global_json['formations']) {
1866                 if (formation['formation_id'] === formation_id) {
1867                         return formation;
1868                 }
1869         }
1870         return null;
1871 }
1872
1873 function find_player(player_id) {
1874         for (const player of global_json['players']) {
1875                 if (player['player_id'] === player_id) {
1876                         return player;
1877                 }
1878         }
1879         return null;
1880 }
1881
1882 function player_pos(player_id) {
1883         let i = 0;
1884         for (const player of global_json['players']) {
1885                 if (player['player_id'] === player_id) {
1886                         return i;
1887                 }
1888                 ++i;
1889         }
1890         return null;
1891 }
1892
1893 function keep_match(match_id, filters) {
1894         for (const filter of filters) {
1895                 if (filter.type === 'match') {
1896                         return filter.elements.has(match_id);
1897                 }
1898         }
1899         return true;
1900 }
1901
1902 // Returns a map of e.g. F => 4, M => 3.
1903 function find_gender_ratio(players) {
1904         let map = {};
1905         for (const [q,p] of Object.entries(players)) {
1906                 if (p.on_field_since === null) {
1907                         continue;
1908                 }
1909                 let gender = p.gender;
1910                 if (gender === '' || gender === undefined || gender === null) {
1911                         gender = '?';
1912                 }
1913                 if (map[gender] === undefined) {
1914                         map[gender] = 1;
1915 q               } else {
1916                         ++map[gender];
1917                 }
1918         }
1919         return map;
1920 }
1921
1922 function find_gender_ratio_code(players) {
1923         let map = find_gender_ratio(players);
1924         let all_genders = Array.from(Object.keys(map)).sort(
1925                 (a,b) => {
1926                         if (map[a] !== map[b]) {
1927                                 return map[b] - map[a];  // Reverse numeric.
1928                         } else if (a < b) {
1929                                 return -1;
1930                         } else if (a > b) {
1931                                 return 1;
1932                         } else {
1933                                 return 0;
1934                         }
1935                 });
1936         let code = '';
1937         for (const g of all_genders) {
1938                 if (code !== '') {
1939                         code += ', ';
1940                 }
1941                 code += map[g];
1942                 code += ' ';
1943                 code += g;
1944         }
1945         return code;
1946 }
1947
1948 // null if none (e.g., if playing 3–3).
1949 function find_predominant_gender(players) {
1950         let max = 0;
1951         let predominant_gender = null;
1952         for (const [gender, num] of Object.entries(find_gender_ratio(players))) {
1953                 if (num > max) {
1954                         max = num;
1955                         predominant_gender = gender;
1956                 } else if (num == max) {
1957                         predominant_gender = null;  // At least two have the same.
1958                 }
1959         }
1960         return predominant_gender;
1961 }
1962
1963 function find_num_players_on_field(players) {
1964         let num = 0;
1965         for (const [q,p] of Object.entries(players)) {
1966                 if (p.on_field_since !== null) {
1967                         ++num;
1968                 }
1969         }
1970         return num;
1971 }
1972
1973 function filter_passes(players, formations_used_this_point, last_pull_was_ours, filter) {
1974         if (filter.type === 'player_any') {
1975                 for (const p of Array.from(filter.elements)) {
1976                         if (players[p].on_field_since !== null) {
1977                                 return true;
1978                         }
1979                 }
1980                 return false;
1981         } else if (filter.type === 'player_all') {
1982                 for (const p of Array.from(filter.elements)) {
1983                         if (players[p].on_field_since === null) {
1984                                 return false;
1985                         }
1986                 }
1987                 return true;
1988         } else if (filter.type === 'formation_offense' || filter.type === 'formation_defense') {
1989                 for (const f of Array.from(filter.elements)) {
1990                         if (formations_used_this_point.has(f)) {
1991                                 return true;
1992                         }
1993                 }
1994                 return false;
1995         } else if (filter.type === 'starting_on') {
1996                 return filter.elements.has(last_pull_was_ours);
1997         } else if (filter.type === 'gender_ratio') {
1998                 return filter.elements.has(find_gender_ratio_code(players));
1999         }
2000         return true;
2001 }
2002
2003 function keep_event(players, formations_used_this_point, last_pull_was_ours, filters) {
2004         for (const filter of filters) {
2005                 if (!filter_passes(players, formations_used_this_point, last_pull_was_ours, filter)) {
2006                         return false;
2007                 }
2008         }
2009         return true;
2010 }
2011
2012 // Heuristic: If we go at least ten seconds without the possession changing
2013 // or the operator specifying some other formation, we probably play the
2014 // same formation as the last point.
2015 function should_reuse_last_formation(events, t) {
2016         for (const e of events) {
2017                 if (e.t <= t) {
2018                         continue;
2019                 }
2020                 if (e.t > t + 10000) {
2021                         break;
2022                 }
2023                 const type = e.type;
2024                 if (type === 'their_goal' || type === 'goal' ||
2025                     type === 'set_defense' || type === 'set_offense' ||
2026                     type === 'throwaway' || type === 'their_throwaway' ||
2027                     type === 'drop' || type === 'was_d' || type === 'stallout' || type === 'defense' || type === 'interception' ||
2028                     type === 'pull' || type === 'pull_landed' || type === 'pull_oob' || type === 'their_pull' ||
2029                     type === 'formation_offense' || type === 'formation_defense') {
2030                         return false;
2031                 }
2032         }
2033         return true;
2034 }
2035
2036 function possibly_close_menu(e) {
2037         if (e.target.closest('.filter-click-to-add') === null &&
2038             e.target.closest('#filter-add-menu') === null &&
2039             e.target.closest('#filter-submenu') === null &&
2040             e.target.closest('.filter-pill') === null) {
2041                 let add_menu = document.getElementById('filter-add-menu');
2042                 let add_submenu = document.getElementById('filter-submenu');
2043                 add_menu.style.display = 'none';
2044                 add_submenu.style.display = 'none';
2045                 try_gc_filter_menus(false);
2046         }
2047 }
2048
2049 let global_sort = {};
2050
2051 function sort_by(th) {
2052         let tr = th.parentElement;
2053         let child_idx = 0;
2054         for (let column_idx = 0; column_idx < tr.children.length; ++column_idx) {
2055                 let element = tr.children[column_idx];
2056                 if (element === th) {
2057                         ++child_idx;  // Pad.
2058                         break;
2059                 }
2060                 if (element.hasAttribute('colspan')) {
2061                         child_idx += parseInt(element.getAttribute('colspan'));
2062                 } else {
2063                         ++child_idx;
2064                 }
2065         }
2066
2067         global_sort = {};
2068         let table = tr.parentElement;
2069         for (let row_idx = 1; row_idx < table.children.length - 1; ++row_idx) {  // Skip header and globals.
2070                 let row = table.children[row_idx];
2071                 let player = parseInt(row.dataset.player);
2072                 let value = row.children[child_idx].textContent;
2073                 global_sort[player] = value;
2074         }
2075 }
2076
2077 function get_sorted_players(players)
2078 {
2079         let p = Object.entries(players);
2080         if (global_sort.length !== 0) {
2081                 p.sort((a,b) => {
2082                         let ai = parseFloat(global_sort[a[0]]);
2083                         let bi = parseFloat(global_sort[b[0]]);
2084                         if (ai == ai && bi == bi) {
2085                                 return bi - ai;  // Reverse numeric.
2086                         } else if (global_sort[a[0]] < global_sort[b[0]]) {
2087                                 return -1;
2088                         } else if (global_sort[a[0]] > global_sort[b[0]]) {
2089                                 return 1;
2090                         } else {
2091                                 return 0;
2092                         }
2093                 });
2094         }
2095         return p;
2096 }