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