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