]> git.sesse.net Git - pkanalytics/blob - ultimate.js
Support filtering on formation.
[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
8 addEventListener('hashchange', () => { process_matches(global_json, global_filters); });
9 addEventListener('click', possibly_close_menu);
10 fetch('ultimate.json')
11    .then(response => response.json())
12    .then(response => { global_json = response; process_matches(global_json, global_filters); });
13
14 function attribute_player_time(player, to, from, offense) {
15         let delta_time;
16         if (player.on_field_since > from) {
17                 // Player came in while play happened (without a stoppage!?).
18                 delta_time = to - player.on_field_since;
19         } else {
20                 delta_time = to - from;
21         }
22         player.playing_time_ms += delta_time;
23         if (offense === true) {
24                 player.offensive_playing_time_ms += delta_time;
25         } else if (offense === false) {
26                 player.defensive_playing_time_ms += delta_time;
27         }
28 }
29
30 function take_off_field(player, t, live_since, offense, keep) {
31         if (keep) {
32                 if (live_since === null) {
33                         // Play isn't live, so nothing to do.
34                 } else {
35                         attribute_player_time(player, t, live_since, offense);
36                 }
37                 if (player.on_field_since !== null) {  // Just a safeguard; out without in should never happen.
38                         player.field_time_ms += t - player.on_field_since;
39                 }
40         }
41         player.on_field_since = null;
42 }
43
44 function add_cell(tr, element_type, text) {
45         let element = document.createElement(element_type);
46         element.textContent = text;
47         tr.appendChild(element);
48         return element;
49 }
50
51 function add_th(tr, text, colspan) {
52         let element = add_cell(tr, 'th', text);
53         if (colspan > 0) {
54                 element.setAttribute('colspan', colspan);
55         } else {
56                 element.setAttribute('colspan', '3');
57         }
58         return element;
59 }
60
61 function add_3cell(tr, text, cls) {
62         let p1 = add_cell(tr, 'td', '');
63         let element = add_cell(tr, 'td', text);
64         let p2 = add_cell(tr, 'td', '');
65
66         p1.classList.add('pad');
67         p2.classList.add('pad');
68         if (cls === undefined) {
69                 element.classList.add('num');
70         } else {
71                 element.classList.add(cls);
72         }
73         return element;
74 }
75
76 function add_3cell_with_filler_ci(tr, text, cls) {
77         let element = add_3cell(tr, text, cls);
78
79         let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
80         svg.classList.add('fillerci');
81         svg.setAttribute('width', ci_width);
82         svg.setAttribute('height', ci_height);
83         element.appendChild(svg);
84
85         return element;
86 }
87
88 function add_3cell_ci(tr, ci) {
89         if (isNaN(ci.val)) {
90                 add_3cell_with_filler_ci(tr, 'N/A');
91                 return;
92         }
93
94         let text;
95         if (ci.format === 'percentage') {
96                 text = (100 * ci.val).toFixed(0) + '%';
97         } else {
98                 text = ci.val.toFixed(2);
99         }
100         let element = add_3cell(tr, text);
101         let to_x = (val) => { return ci_width * (val - ci.min) / (ci.max - ci.min); };
102
103         // Container.
104         let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
105         if (ci.inverted === true) {
106                 svg.classList.add('invertedci');
107         } else {
108                 svg.classList.add('ci');
109         }
110         svg.setAttribute('width', ci_width);
111         svg.setAttribute('height', ci_height);
112
113         // The good (green) and red (bad) ranges.
114         let s0 = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
115         s0.classList.add('range');
116         s0.classList.add('s0');
117         s0.setAttribute('width', to_x(ci.desired));
118         s0.setAttribute('height', ci_height);
119         s0.setAttribute('x', '0');
120
121         let s1 = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
122         s1.classList.add('range');
123         s1.classList.add('s1');
124         s1.setAttribute('width', ci_width - to_x(ci.desired));
125         s1.setAttribute('height', ci_height);
126         s1.setAttribute('x', to_x(ci.desired));
127
128         // Confidence bar.
129         let bar = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
130         bar.classList.add('bar');
131         bar.setAttribute('width', to_x(ci.upper_ci) - to_x(ci.lower_ci));
132         bar.setAttribute('height', ci_height / 3);
133         bar.setAttribute('x', to_x(ci.lower_ci));
134         bar.setAttribute('y', ci_height / 3);
135
136         // Marker line for average.
137         let marker = document.createElementNS('http://www.w3.org/2000/svg', 'line');
138         marker.classList.add('marker');
139         marker.setAttribute('x1', to_x(ci.val));
140         marker.setAttribute('x2', to_x(ci.val));
141         marker.setAttribute('y1', ci_height / 6);
142         marker.setAttribute('y2', ci_height * 5 / 6);
143
144         svg.appendChild(s0);
145         svg.appendChild(s1);
146         svg.appendChild(bar);
147         svg.appendChild(marker);
148
149         element.appendChild(svg);
150 }
151
152 function process_matches(json, filters) {
153         let players = {};
154         for (const player of json['players']) {
155                 players[player['player_id']] = {
156                         'name': player['name'],
157                         'number': player['number'],
158
159                         'goals': 0,
160                         'assists': 0,
161                         'hockey_assists': 0,
162                         'catches': 0,
163                         'touches': 0,
164                         'num_throws': 0,
165                         'throwaways': 0,
166                         'drops': 0,
167
168                         'defenses': 0,
169                         'interceptions': 0,
170                         'points_played': 0,
171                         'playing_time_ms': 0,
172                         'offensive_playing_time_ms': 0,
173                         'defensive_playing_time_ms': 0,
174                         'field_time_ms': 0,
175
176                         // For efficiency.
177                         'offensive_points_completed': 0,
178                         'offensive_points_won': 0,
179                         'defensive_points_completed': 0,
180                         'defensive_points_won': 0,
181
182                         'offensive_soft_plus': 0,
183                         'offensive_soft_minus': 0,
184                         'defensive_soft_plus': 0,
185                         'defensive_soft_minus': 0,
186
187                         'pulls': 0,
188                         'pull_times': [],
189                         'oob_pulls': 0,
190
191                         // Internal.
192                         'last_point_seen': null,
193                         'on_field_since': null,
194                 };
195         }
196
197         // Globals.
198         players['globals'] = {
199                 'points_played': 0,
200                 'playing_time_ms': 0,
201                 'offensive_playing_time_ms': 0,
202                 'defensive_playing_time_ms': 0,
203                 'field_time_ms': 0,
204
205                 'offensive_points_completed': 0,
206                 'offensive_points_won': 0,
207                 'defensive_points_completed': 0,
208                 'defensive_points_won': 0,
209         };
210         let globals = players['globals'];
211
212         for (const match of json['matches']) {
213                 if (!keep_match(match['match_id'], filters)) {
214                         continue;
215                 }
216
217                 let our_score = 0;
218                 let their_score = 0;
219                 let handler = null;
220                 let prev_handler = null;
221                 let live_since = null;
222                 let offense = null;  // True/false/null (unknown).
223                 let puller = null;
224                 let pull_started = null;
225                 let last_pull_was_ours = null;  // Effectively whether we're playing an O or D point (not affected by turnovers).
226                 let point_num = 0;
227                 let game_started = null;
228                 let last_goal = null;
229
230                 // The last used formations of the given kind, if any; they may be reused
231                 // when the point starts, if nothing else is set.
232                 let last_offensive_formation = null;
233                 let last_defensive_formation = null;
234
235                 // Formations we've set, but haven't really had the chance to use yet
236                 // (e.g., we get a “use zone defense” event while we're still on offense).
237                 let pending_offensive_formation = null;
238                 let pending_defensive_formation = null;
239
240                 // Formations that we have played at least once this point, after all
241                 // heuristics and similar.
242                 let formations_used_this_point = new Set();
243
244                 for (const [q,p] of Object.entries(players)) {
245                         p.on_field_since = null;
246                         p.last_point_seen = null;
247                 }
248                 for (const e of match['events']) {
249                         let t = e['t'];
250                         let type = e['type'];
251                         let p = players[e['player']];
252
253                         // Sub management
254                         let keep = keep_event(players, formations_used_this_point, filters);
255                         if (type === 'in' && p.on_field_since === null) {
256                                 p.on_field_since = t;
257                                 if (!keep && keep_event(players, formations_used_this_point, filters)) {
258                                         // A player needed for the filters went onto the field,
259                                         // so pretend people walked on right now (to start their
260                                         // counting time).
261                                         for (const [q,p2] of Object.entries(players)) {
262                                                 if (p2.on_field_since !== null) {
263                                                         p2.on_field_since = t;
264                                                 }
265                                         }
266                                 }
267                         } else if (type === 'out') {
268                                 take_off_field(p, t, live_since, offense, keep);
269                                 if (keep && !keep_event(players, formations_used_this_point, filters)) {
270                                         // A player needed for the filters went off the field,
271                                         // so we need to attribute time for all the others.
272                                         // Pretend they walked off and then immediately on again.
273                                         //
274                                         // TODO: We also need to take care of this to get the globals right.
275                                         for (const [q,p2] of Object.entries(players)) {
276                                                 if (p2.on_field_since !== null) {
277                                                         take_off_field(p2, t, live_since, offense, keep);
278                                                         p2.on_field_since = t;
279                                                 }
280                                         }
281                                 }
282                         }
283
284                         keep = keep_event(players, formations_used_this_point, filters);  // Recompute after in/out.
285
286                         // Liveness management
287                         if (type === 'pull' || type === 'their_pull' || type === 'restart') {
288                                 live_since = t;
289                         } else if (type === 'catch' && last_pull_was_ours === null) {
290                                 // Someone forgot to add the pull, so we'll need to wing it.
291                                 console.log('Missing pull on ' + our_score + '\u2013' + their_score + ' in ' + match['description'] + '; pretending to have one.');
292                                 live_since = t;
293                                 last_pull_was_ours = !offense;
294                         } else if (type === 'goal' || type === 'their_goal' || type === 'stoppage') {
295                                 for (const [q,p] of Object.entries(players)) {
296                                         if (p.on_field_since === null) {
297                                                 continue;
298                                         }
299                                         if (type !== 'stoppage' && p.last_point_seen !== point_num) {
300                                                 if (keep) {
301                                                         // In case the player did nothing this point,
302                                                         // not even subbing in.
303                                                         p.last_point_seen = point_num;
304                                                         ++p.points_played;
305                                                 }
306                                         }
307                                         if (keep) attribute_player_time(p, t, live_since, offense);
308
309                                         if (type !== 'stoppage') {
310                                                 if (keep) {
311                                                         if (last_pull_was_ours === true) {  // D point.
312                                                                 ++p.defensive_points_completed;
313                                                                 if (type === 'goal') {
314                                                                         ++p.defensive_points_won;
315                                                                 }
316                                                         } else if (last_pull_was_ours === false) {  // O point.
317                                                                 ++p.offensive_points_completed;
318                                                                 if (type === 'goal') {
319                                                                         ++p.offensive_points_won;
320                                                                 }
321                                                         }
322                                                 }
323                                         }
324                                 }
325
326                                 if (keep) {
327                                         if (type !== 'stoppage') {
328                                                 // Update globals.
329                                                 ++globals.points_played;
330                                                 if (last_pull_was_ours === true) {  // D point.
331                                                         ++globals.defensive_points_completed;
332                                                         if (type === 'goal') {
333                                                                 ++globals.defensive_points_won;
334                                                         }
335                                                 } else if (last_pull_was_ours === false) {  // O point.
336                                                         ++globals.offensive_points_completed;
337                                                         if (type === 'goal') {
338                                                                 ++globals.offensive_points_won;
339                                                         }
340                                                 }
341                                         }
342                                         if (live_since !== null) {
343                                                 globals.playing_time_ms += t - live_since;
344                                                 if (offense === true) {
345                                                         globals.offensive_playing_time_ms += t - live_since;
346                                                 } else if (offense === false) {
347                                                         globals.defensive_playing_time_ms += t - live_since;
348                                                 }
349                                         }
350                                 }
351
352                                 live_since = null;
353                         }
354
355                         // Score management
356                         if (type === 'goal') {
357                                 ++our_score;
358                         } else if (type === 'their_goal') {
359                                 ++their_score;
360                         }
361
362                         // Point count management
363                         if (p !== undefined && type !== 'out' && p.last_point_seen !== point_num) {
364                                 if (keep) {
365                                         p.last_point_seen = point_num;
366                                         ++p.points_played;
367                                 }
368                         }
369                         if (type === 'goal' || type === 'their_goal') {
370                                 ++point_num;
371                                 last_goal = t;
372                         }
373                         if (type !== 'out' && game_started === null) {
374                                 game_started = t;
375                         }
376
377                         // Pull management
378                         if (type === 'pull') {
379                                 puller = e['player'];
380                                 pull_started = t;
381                                 if (keep) ++p.pulls;
382                         } else if (type === 'in' || type === 'out' || type === 'stoppage' || type === 'restart' || type === 'unknown' || type === 'set_defense' || type === 'set_offense') {
383                                 // No effect on pull.
384                         } else if (type === 'pull_landed' && puller !== null) {
385                                 if (keep) players[puller].pull_times.push(t - pull_started);
386                         } else if (type === 'pull_oob' && puller !== null) {
387                                 if (keep) ++players[puller].oob_pulls;
388                         } else {
389                                 // Not pulling (if there was one, we never recorded its outcome, but still count it).
390                                 puller = pull_started = null;
391                         }
392
393                         // Offense/defense management
394                         let last_offense = offense;
395                         if (type === 'set_defense' || type === 'goal' || type === 'throwaway' || type === 'drop') {
396                                 offense = false;
397                         } else if (type === 'set_offense' || type === 'their_goal' || type === 'their_throwaway' || type === 'defense' || type === 'interception') {
398                                 offense = true;
399                         }
400                         if (last_offense !== offense && live_since !== null) {
401                                 // Switched offense/defense status, so attribute this drive as needed,
402                                 // and update live_since to take that into account.
403                                 if (keep) {
404                                         for (const [q,p] of Object.entries(players)) {
405                                                 if (p.on_field_since === null) {
406                                                         continue;
407                                                 }
408                                                 attribute_player_time(p, t, live_since, last_offense);
409                                         }
410                                         globals.playing_time_ms += t - live_since;
411                                         if (offense === true) {
412                                                 globals.offensive_playing_time_ms += t - live_since;
413                                         } else if (offense === false) {
414                                                 globals.defensive_playing_time_ms += t - live_since;
415                                         }
416                                 }
417                                 live_since = t;
418                         }
419
420                         if (type === 'pull') {
421                                 last_pull_was_ours = true;
422                         } else if (type === 'their_pull') {
423                                 last_pull_was_ours = false;
424                         } else if (type === 'set_offense' && last_pull_was_ours === null) {
425                                 // set_offense could either be “changed to offense for some reason
426                                 // we could not express”, or “we started in the middle of a point,
427                                 // and we are offense”. We assume that if we already saw the pull,
428                                 // it's the former, and if not, it's the latter; thus, the === null
429                                 // test above. (It could also be “we set offense before the pull,
430                                 // so that we get the right button enabled”, in which case it will
431                                 // be overwritten by the next pull/their_pull event anyway.)
432                                 last_pull_was_ours = false;
433                         } else if (type === 'set_defense' && last_pull_was_ours === null) {
434                                 // Similar.
435                                 last_pull_was_ours = true;
436                         } else if (type === 'goal' || type === 'their_goal') {
437                                 last_pull_was_ours = null;
438                         }
439
440                         // Formation management
441                         if (type === 'formation_offense' || type === 'formation_defense') {
442                                 let id = e.formation === null ? 0 : e.formation;
443                                 let for_offense = (type === 'formation_offense');
444                                 if (offense === for_offense) {
445                                         formations_used_this_point.add(id);
446                                 } else if (for_offense) {
447                                         pending_offensive_formation = id;
448                                 } else {
449                                         pending_defensive_formation = id;
450                                 }
451                                 if (for_offense) {
452                                         last_offensive_formation = id;
453                                 } else {
454                                         last_defensive_formation = id;
455                                 }
456                         } else if (last_offense !== offense) {
457                                 if (offense === true && pending_offensive_formation !== null) {
458                                         formations_used_this_point.add(pending_offensive_formation);
459                                         pending_offensive_formation = null;
460                                 } else if (offense === false && pending_defensive_formation !== null) {
461                                         formations_used_this_point.add(pending_defensive_formation);
462                                         pending_defensive_formation = null;
463                                 } else if (offense === true && last_defensive_formation !== null) {
464                                         if (should_reuse_last_formation(match['events'], t)) {
465                                                 formations_used_this_point.add(last_defensive_formation);
466                                         }
467                                 } else if (offense === false && last_offensive_formation !== null) {
468                                         if (should_reuse_last_formation(match['events'], t)) {
469                                                 formations_used_this_point.add(last_offensive_formation);
470                                         }
471                                 }
472                         }
473
474                         // Event management
475                         if (type === 'catch' || type === 'goal') {
476                                 if (handler !== null) {
477                                         if (keep) {
478                                                 ++players[handler].num_throws;
479                                                 ++p.catches;
480                                         }
481                                 }
482
483                                 if (keep) ++p.touches;
484                                 if (type === 'goal') {
485                                         if (keep) {
486                                                 if (prev_handler !== null) {
487                                                         ++players[prev_handler].hockey_assists;
488                                                 }
489                                                 if (handler !== null) {
490                                                         ++players[handler].assists;
491                                                 }
492                                                 ++p.goals;
493                                         }
494                                         handler = prev_handler = null;
495                                 } else {
496                                         // Update hold history.
497                                         prev_handler = handler;
498                                         handler = e['player'];
499                                 }
500                         } else if (type === 'throwaway') {
501                                 if (keep) {
502                                         ++p.num_throws;
503                                         ++p.throwaways;
504                                 }
505                                 handler = prev_handler = null;
506                         } else if (type === 'drop') {
507                                 if (keep) ++p.drops;
508                                 handler = prev_handler = null;
509                         } else if (type === 'defense') {
510                                 if (keep) ++p.defenses;
511                         } else if (type === 'interception') {
512                                 if (keep) {
513                                         ++p.interceptions;
514                                         ++p.defenses;
515                                         ++p.touches;
516                                 }
517                                 prev_handler = null;
518                                 handler = e['player'];
519                         } else if (type === 'offensive_soft_plus' || type === 'offensive_soft_minus' || type === 'defensive_soft_plus' || type === 'defensive_soft_minus') {
520                                 if (keep) ++p[type];
521                         } else if (type !== 'in' && type !== 'out' && type !== 'pull' &&
522                                    type !== 'their_goal' && type !== 'stoppage' && type !== 'restart' && type !== 'unknown' &&
523                                    type !== 'set_defense' && type !== 'goal' && type !== 'throwaway' &&
524                                    type !== 'drop' && type !== 'set_offense' && type !== 'their_goal' &&
525                                    type !== 'pull' && type !== 'pull_landed' && type !== 'pull_oob' && type !== 'their_pull' &&
526                                    type !== 'their_throwaway' && type !== 'defense' && type !== 'interception' &&
527                                    type !== 'formation_offense' && type !== 'formation_defense') {
528                                 console.log("Unknown event:", e);
529                         }
530
531                         if (type === 'goal' || type === 'their_goal') {
532                                 formations_used_this_point.clear();
533                         }
534                 }
535
536                 // Add field time for all players still left at match end.
537                 const keep = keep_event(players, formations_used_this_point, filters);
538                 if (keep) {
539                         for (const [q,p] of Object.entries(players)) {
540                                 if (p.on_field_since !== null && last_goal !== null) {
541                                         p.field_time_ms += last_goal - p.on_field_since;
542                                 }
543                         }
544                         if (game_started !== null && last_goal !== null) {
545                                 globals.field_time_ms += last_goal - game_started;
546                         }
547                         if (live_since !== null && last_goal !== null) {
548                                 globals.playing_time_ms += last_goal - live_since;
549                                 if (offense === true) {
550                                         globals.offensive_playing_time_ms += last_goal - live_since;
551                                 } else if (offense === false) {
552                                         globals.defensive_playing_time_ms += last_goal - live_since;
553                                 }
554                         }
555                 }
556         }
557
558         let chosen_category = get_chosen_category();
559         write_main_menu(chosen_category);
560
561         let rows = [];
562         if (chosen_category === 'general') {
563                 rows = make_table_general(players);
564         } else if (chosen_category === 'offense') {
565                 rows = make_table_offense(players);
566         } else if (chosen_category === 'defense') {
567                 rows = make_table_defense(players);
568         } else if (chosen_category === 'playing_time') {
569                 rows = make_table_playing_time(players);
570         } else if (chosen_category === 'per_point') {
571                 rows = make_table_per_point(players);
572         }
573         document.getElementById('stats').replaceChildren(...rows);
574 }
575
576 function get_chosen_category() {
577         if (window.location.hash === '#offense') {
578                 return 'offense';
579         } else if (window.location.hash === '#defense') {
580                 return 'defense';
581         } else if (window.location.hash === '#playing_time') {
582                 return 'playing_time';
583         } else if (window.location.hash === '#per_point') {
584                 return 'per_point';
585         } else {
586                 return 'general';
587         }
588 }
589
590 function write_main_menu(chosen_category) {
591         let elems = [];
592         if (chosen_category === 'general') {
593                 let span = document.createElement('span');
594                 span.innerText = 'General';
595                 elems.push(span);
596         } else {
597                 let a = document.createElement('a');
598                 a.appendChild(document.createTextNode('General'));
599                 a.setAttribute('href', '#general');
600                 elems.push(a);
601         }
602
603         if (chosen_category === 'offense') {
604                 let span = document.createElement('span');
605                 span.innerText = 'Offense';
606                 elems.push(span);
607         } else {
608                 let a = document.createElement('a');
609                 a.appendChild(document.createTextNode('Offense'));
610                 a.setAttribute('href', '#offense');
611                 elems.push(a);
612         }
613
614         if (chosen_category === 'defense') {
615                 let span = document.createElement('span');
616                 span.innerText = 'Defense';
617                 elems.push(span);
618         } else {
619                 let a = document.createElement('a');
620                 a.appendChild(document.createTextNode('Defense'));
621                 a.setAttribute('href', '#defense');
622                 elems.push(a);
623         }
624
625         if (chosen_category === 'playing_time') {
626                 let span = document.createElement('span');
627                 span.innerText = 'Playing time';
628                 elems.push(span);
629         } else {
630                 let a = document.createElement('a');
631                 a.appendChild(document.createTextNode('Playing time'));
632                 a.setAttribute('href', '#playing_time');
633                 elems.push(a);
634         }
635
636         if (chosen_category === 'per_point') {
637                 let span = document.createElement('span');
638                 span.innerText = 'Per point';
639                 elems.push(span);
640         } else {
641                 let a = document.createElement('a');
642                 a.appendChild(document.createTextNode('Per point'));
643                 a.setAttribute('href', '#per_point');
644                 elems.push(a);
645         }
646
647         document.getElementById('mainmenu').replaceChildren(...elems);
648 }
649
650 // https://en.wikipedia.org/wiki/1.96#History
651 const z = 1.959964;
652
653 const ci_width = 100;
654 const ci_height = 20;
655
656 function make_binomial_ci(val, num, z) {
657         let avg = val / num;
658
659         // https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Wilson_score_interval
660         let low  = (avg + z*z/(2*num) - z * Math.sqrt(avg * (1.0 - avg) / num + z*z/(4*num*num))) / (1 + z*z/num);   
661         let high = (avg + z*z/(2*num) + z * Math.sqrt(avg * (1.0 - avg) / num + z*z/(4*num*num))) / (1 + z*z/num); 
662
663         // Fix the signs so that we don't get -0.00.
664         low = Math.max(low, 0.0);
665         return {
666                 'val': avg,
667                 'lower_ci': low,
668                 'upper_ci': high,
669                 'min': 0.0,
670                 'max': 1.0,
671         };
672 }
673
674 // These can only happen once per point, but you get -1 and +1
675 // instead of 0 and +1. After we rewrite to 0 and 1, it's a binomial,
676 // and then we can rewrite back.
677 function make_efficiency_ci(points_won, points_completed, z)
678 {
679         let ci = make_binomial_ci(points_won, points_completed, z);
680         ci.val = 2.0 * ci.val - 1.0;
681         ci.lower_ci = 2.0 * ci.lower_ci - 1.0;
682         ci.upper_ci = 2.0 * ci.upper_ci - 1.0;
683         ci.min = -1.0;
684         ci.max = 1.0;
685         ci.desired = 0.0;  // Desired = positive efficiency.
686         return ci;
687 }
688
689 // Ds, throwaways and drops can happen multiple times per point,
690 // so they are Poisson distributed.
691 //
692 // Modified Wald (recommended by http://www.ine.pt/revstat/pdf/rs120203.pdf
693 // since our rates are definitely below 2 per point).
694 function make_poisson_ci(val, num, z, inverted)
695 {
696         let low  = (val == 0) ? 0.0 : ((val - 0.5) - Math.sqrt(val - 0.5)) / num;
697         let high = (val == 0) ? -Math.log(0.025) / num : ((val + 0.5) + Math.sqrt(val + 0.5)) / num;
698
699         // Fix the signs so that we don't get -0.00.
700         low = Math.max(low, 0.0);
701
702         // The display range of 0 to 0.25 is fairly arbitrary. So is the desired 0.05 per point.
703         let avg = val / num;
704         return {
705                 'val': avg,
706                 'lower_ci': low,
707                 'upper_ci': high,
708                 'min': 0.0,
709                 'max': 0.25,
710                 'desired': 0.05,
711                 'inverted': inverted,
712         };
713 }
714
715 function make_table_general(players) {
716         let rows = [];
717         {
718                 let header = document.createElement('tr');
719                 add_th(header, 'Player');
720                 add_th(header, '+/-');
721                 add_th(header, 'Soft +/-');
722                 add_th(header, 'O efficiency');
723                 add_th(header, 'D efficiency');
724                 add_th(header, 'Points played');
725                 rows.push(header);
726         }
727
728         for (const [q,p] of Object.entries(players)) {
729                 if (q === 'globals') continue;
730                 let row = document.createElement('tr');
731                 let pm = p.goals + p.assists + p.hockey_assists + p.defenses - p.throwaways - p.drops;
732                 let soft_pm = p.offensive_soft_plus + p.defensive_soft_plus - p.offensive_soft_minus - p.defensive_soft_minus;
733                 let o_efficiency = make_efficiency_ci(p.offensive_points_won, p.offensive_points_completed, z);
734                 let d_efficiency = make_efficiency_ci(p.defensive_points_won, p.defensive_points_completed, z);
735                 add_3cell(row, p.name, 'name');  // TODO: number?
736                 add_3cell(row, pm > 0 ? ('+' + pm) : pm);
737                 add_3cell(row, soft_pm > 0 ? ('+' + soft_pm) : soft_pm);
738                 add_3cell_ci(row, o_efficiency);
739                 add_3cell_ci(row, d_efficiency);
740                 add_3cell(row, p.points_played);
741                 rows.push(row);
742         }
743
744         // Globals.
745         let globals = players['globals'];
746         let o_efficiency = make_efficiency_ci(globals.offensive_points_won, globals.offensive_points_completed, z);
747         let d_efficiency = make_efficiency_ci(globals.defensive_points_won, globals.defensive_points_completed, z);
748         let row = document.createElement('tr');
749         add_3cell(row, '');
750         add_3cell(row, '');
751         add_3cell(row, '');
752         add_3cell_ci(row, o_efficiency);
753         add_3cell_ci(row, d_efficiency);
754         add_3cell(row, globals.points_played);
755         rows.push(row);
756
757         return rows;
758 }
759
760 function make_table_offense(players) {
761         let rows = [];
762         {
763                 let header = document.createElement('tr');
764                 add_th(header, 'Player');
765                 add_th(header, 'Goals');
766                 add_th(header, 'Assists');
767                 add_th(header, 'Hockey assists');
768                 add_th(header, 'Throws');
769                 add_th(header, 'Throwaways');
770                 add_th(header, '%OK');
771                 add_th(header, 'Catches');
772                 add_th(header, 'Drops');
773                 add_th(header, '%OK');
774                 add_th(header, 'Soft +/-', 6);
775                 rows.push(header);
776         }
777
778         let num_throws = 0;
779         let throwaways = 0;
780         let catches = 0;
781         let drops = 0;
782         for (const [q,p] of Object.entries(players)) {
783                 if (q === 'globals') continue;
784                 let throw_ok = make_binomial_ci(p.num_throws - p.throwaways, p.num_throws, z);
785                 let catch_ok = make_binomial_ci(p.catches, p.catches + p.drops, z);
786
787                 throw_ok.format = 'percentage';
788                 catch_ok.format = 'percentage';
789
790                 // Desire at least 90% percentage. Fairly arbitrary.
791                 throw_ok.desired = 0.9;
792                 catch_ok.desired = 0.9;
793
794                 let row = document.createElement('tr');
795                 add_3cell(row, p.name, 'name');  // TODO: number?
796                 add_3cell(row, p.goals);
797                 add_3cell(row, p.assists);
798                 add_3cell(row, p.hockey_assists);
799                 add_3cell(row, p.num_throws);
800                 add_3cell(row, p.throwaways);
801                 add_3cell_ci(row, throw_ok);
802                 add_3cell(row, p.catches);
803                 add_3cell(row, p.drops);
804                 add_3cell_ci(row, catch_ok);
805                 add_3cell(row, '+' + p.offensive_soft_plus);
806                 add_3cell(row, '-' + p.offensive_soft_minus);
807                 rows.push(row);
808
809                 num_throws += p.num_throws;
810                 throwaways += p.throwaways;
811                 catches += p.catches;
812                 drops += p.drops;
813         }
814
815         // Globals.
816         let throw_ok = make_binomial_ci(num_throws - throwaways, num_throws, z);
817         let catch_ok = make_binomial_ci(catches, catches + drops, z);
818         throw_ok.format = 'percentage';
819         catch_ok.format = 'percentage';
820         throw_ok.desired = 0.9;
821         catch_ok.desired = 0.9;
822
823         let row = document.createElement('tr');
824         add_3cell(row, '');
825         add_3cell(row, '');
826         add_3cell(row, '');
827         add_3cell(row, '');
828         add_3cell(row, num_throws);
829         add_3cell(row, throwaways);
830         add_3cell_ci(row, throw_ok);
831         add_3cell(row, catches);
832         add_3cell(row, drops);
833         add_3cell_ci(row, catch_ok);
834         add_3cell(row, '');
835         add_3cell(row, '');
836         rows.push(row);
837
838         return rows;
839 }
840
841 function make_table_defense(players) {
842         let rows = [];
843         {
844                 let header = document.createElement('tr');
845                 add_th(header, 'Player');
846                 add_th(header, 'Ds');
847                 add_th(header, 'Pulls');
848                 add_th(header, 'OOB pulls');
849                 add_th(header, 'OOB%');
850                 add_th(header, 'Avg. hang time (IB)');
851                 add_th(header, 'Soft +/-', 6);
852                 rows.push(header);
853         }
854         for (const [q,p] of Object.entries(players)) {
855                 if (q === 'globals') continue;
856                 let sum_time = 0;
857                 for (const t of p.pull_times) {
858                         sum_time += t;
859                 }
860                 let avg_time = 1e-3 * sum_time / p.pulls;
861                 let oob_pct = 100 * p.oob_pulls / p.pulls;
862
863                 let ci_oob = make_binomial_ci(p.oob_pulls, p.pulls, z);
864                 ci_oob.format = 'percentage';
865                 ci_oob.desired = 0.2;  // Arbitrary.
866                 ci_oob.inverted = true;
867
868                 let row = document.createElement('tr');
869                 add_3cell(row, p.name, 'name');  // TODO: number?
870                 add_3cell(row, p.defenses);
871                 add_3cell(row, p.pulls);
872                 add_3cell(row, p.oob_pulls);
873                 add_3cell_ci(row, ci_oob);
874                 if (p.pulls > p.oob_pulls) {
875                         add_3cell(row, avg_time.toFixed(1) + ' sec');
876                 } else {
877                         add_3cell(row, 'N/A');
878                 }
879                 add_3cell(row, '+' + p.defensive_soft_plus);
880                 add_3cell(row, '-' + p.defensive_soft_minus);
881                 rows.push(row);
882         }
883         return rows;
884 }
885
886 function make_table_playing_time(players) {
887         let rows = [];
888         {
889                 let header = document.createElement('tr');
890                 add_th(header, 'Player');
891                 add_th(header, 'Points played');
892                 add_th(header, 'Time played');
893                 add_th(header, 'O time');
894                 add_th(header, 'D time');
895                 add_th(header, 'Time on field');
896                 add_th(header, 'O points');
897                 add_th(header, 'D points');
898                 rows.push(header);
899         }
900
901         for (const [q,p] of Object.entries(players)) {
902                 if (q === 'globals') continue;
903                 let row = document.createElement('tr');
904                 add_3cell(row, p.name, 'name');  // TODO: number?
905                 add_3cell(row, p.points_played);
906                 add_3cell(row, Math.floor(p.playing_time_ms / 60000) + ' min');
907                 add_3cell(row, Math.floor(p.offensive_playing_time_ms / 60000) + ' min');
908                 add_3cell(row, Math.floor(p.defensive_playing_time_ms / 60000) + ' min');
909                 add_3cell(row, Math.floor(p.field_time_ms / 60000) + ' min');
910                 add_3cell(row, p.offensive_points_completed);
911                 add_3cell(row, p.defensive_points_completed);
912                 rows.push(row);
913         }
914
915         // Globals.
916         let globals = players['globals'];
917         let row = document.createElement('tr');
918         add_3cell(row, '');
919         add_3cell(row, globals.points_played);
920         add_3cell(row, Math.floor(globals.playing_time_ms / 60000) + ' min');
921         add_3cell(row, Math.floor(globals.offensive_playing_time_ms / 60000) + ' min');
922         add_3cell(row, Math.floor(globals.defensive_playing_time_ms / 60000) + ' min');
923         add_3cell(row, Math.floor(globals.field_time_ms / 60000) + ' min');
924         add_3cell(row, globals.offensive_points_completed);
925         add_3cell(row, globals.defensive_points_completed);
926         rows.push(row);
927
928         return rows;
929 }
930
931 function make_table_per_point(players) {
932         let rows = [];
933         {
934                 let header = document.createElement('tr');
935                 add_th(header, 'Player');
936                 add_th(header, 'Goals');
937                 add_th(header, 'Assists');
938                 add_th(header, 'Hockey assists');
939                 add_th(header, 'Ds');
940                 add_th(header, 'Throwaways');
941                 add_th(header, 'Drops');
942                 add_th(header, 'Touches');
943                 rows.push(header);
944         }
945
946         let goals = 0;
947         let assists = 0;
948         let hockey_assists = 0;
949         let defenses = 0;
950         let throwaways = 0;
951         let drops = 0;
952         let touches = 0;
953         for (const [q,p] of Object.entries(players)) {
954                 if (q === 'globals') continue;
955
956                 // Can only happen once per point, so these are binomials.
957                 let ci_goals = make_binomial_ci(p.goals, p.points_played, z);
958                 let ci_assists = make_binomial_ci(p.assists, p.points_played, z);
959                 let ci_hockey_assists = make_binomial_ci(p.hockey_assists, p.points_played, z);
960                 // Arbitrarily desire at least 10% (not everybody can score or assist).
961                 ci_goals.desired = 0.1;
962                 ci_assists.desired = 0.1;
963                 ci_hockey_assists.desired = 0.1;
964
965                 let row = document.createElement('tr');
966                 add_3cell(row, p.name, 'name');  // TODO: number?
967                 add_3cell_ci(row, ci_goals);
968                 add_3cell_ci(row, ci_assists);
969                 add_3cell_ci(row, ci_hockey_assists);
970                 add_3cell_ci(row, make_poisson_ci(p.defenses, p.points_played, z));
971                 add_3cell_ci(row, make_poisson_ci(p.throwaways, p.points_played, z, true));
972                 add_3cell_ci(row, make_poisson_ci(p.drops, p.points_played, z, true));
973                 if (p.points_played > 0) {
974                         add_3cell(row, p.touches == 0 ? 0 : (p.touches / p.points_played).toFixed(2));
975                 } else {
976                         add_3cell(row, 'N/A');
977                 }
978                 rows.push(row);
979
980                 goals += p.goals;
981                 assists += p.assists;
982                 hockey_assists += p.hockey_assists;
983                 defenses += p.defenses;
984                 throwaways += p.throwaways;
985                 drops += p.drops;
986                 touches += p.touches;
987         }
988
989         // Globals.
990         let globals = players['globals'];
991         let row = document.createElement('tr');
992         add_3cell(row, '');
993         if (globals.points_played > 0) {
994                 add_3cell_with_filler_ci(row, goals == 0 ? 0 : (goals / globals.points_played).toFixed(2));
995                 add_3cell_with_filler_ci(row, assists == 0 ? 0 : (assists / globals.points_played).toFixed(2));
996                 add_3cell_with_filler_ci(row, hockey_assists == 0 ? 0 : (hockey_assists / globals.points_played).toFixed(2));
997                 add_3cell_with_filler_ci(row, defenses == 0 ? 0 : (defenses / globals.points_played).toFixed(2));
998                 add_3cell_with_filler_ci(row, throwaways == 0 ? 0 : (throwaways / globals.points_played).toFixed(2));
999                 add_3cell_with_filler_ci(row, drops == 0 ? 0 : (drops / globals.points_played).toFixed(2));
1000                 add_3cell(row, touches == 0 ? 0 : (touches / globals.points_played).toFixed(2));
1001         } else {
1002                 add_3cell_with_filler_ci(row, 'N/A');
1003                 add_3cell_with_filler_ci(row, 'N/A');
1004                 add_3cell_with_filler_ci(row, 'N/A');
1005                 add_3cell_with_filler_ci(row, 'N/A');
1006                 add_3cell_with_filler_ci(row, 'N/A');
1007                 add_3cell_with_filler_ci(row, 'N/A');
1008                 add_3cell(row, 'N/A');
1009         }
1010         rows.push(row);
1011
1012         return rows;
1013 }
1014
1015 function open_filter_menu() {
1016         document.getElementById('filter-submenu').style.display = 'none';
1017
1018         let menu = document.getElementById('filter-add-menu');
1019         menu.style.display = 'block';
1020         menu.replaceChildren();
1021
1022         // Place the menu directly under the “click to add” label;
1023         // we don't anchor it since that label will move around
1024         // and the menu shouldn't.
1025         let rect = document.getElementById('filter-click-to-add').getBoundingClientRect();
1026         menu.style.left = rect.left + 'px';
1027         menu.style.top = (rect.bottom + 10) + 'px';
1028
1029         add_menu_item(menu, 0, 'match', 'Match (any)');
1030         add_menu_item(menu, 1, 'player_any', 'Player on field (any)');
1031         add_menu_item(menu, 2, 'player_all', 'Player on field (all)');
1032         add_menu_item(menu, 3, 'formation_offense', 'Offense played (any)');
1033         add_menu_item(menu, 4, 'formation_defense', 'Defense played (any)');
1034 }
1035
1036 function add_menu_item(menu, menu_idx, filter_type, title) {
1037         let item = document.createElement('div');
1038         item.classList.add('option');
1039         item.appendChild(document.createTextNode(title));
1040
1041         let arrow = document.createElement('div');
1042         arrow.classList.add('arrow');
1043         arrow.textContent = '▸';
1044         item.appendChild(arrow);
1045
1046         menu.appendChild(item);
1047
1048         item.addEventListener('click', (e) => { show_submenu(menu_idx, null, filter_type); });
1049 }
1050
1051 function show_submenu(menu_idx, pill, filter_type) {
1052         let submenu = document.getElementById('filter-submenu');
1053         let subitems = [];
1054         const filter = find_filter(filter_type);
1055
1056         let choices = [];
1057         if (filter_type === 'match') {
1058                 for (const match of global_json['matches']) {
1059                         choices.push({
1060                                 'title': match['description'],
1061                                 'id': match['match_id']
1062                         });
1063                 }
1064         } else if (filter_type === 'player_any' || filter_type === 'player_all') {
1065                 for (const player of global_json['players']) {
1066                         choices.push({
1067                                 'title': player['name'],
1068                                 'id': player['player_id']
1069                         });
1070                 }
1071         } else if (filter_type === 'formation_offense') {
1072                 choices.push({
1073                         'title': '(None/unknown)',
1074                         'id': 0,
1075                 });
1076                 for (const formation of global_json['formations']) {
1077                         if (formation['offense']) {
1078                                 choices.push({
1079                                         'title': formation['name'],
1080                                         'id': formation['formation_id']
1081                                 });
1082                         }
1083                 }
1084         } else if (filter_type === 'formation_defense') {
1085                 choices.push({
1086                         'title': '(None/unknown)',
1087                         'id': 0,
1088                 });
1089                 for (const formation of global_json['formations']) {
1090                         if (!formation['offense']) {
1091                                 choices.push({
1092                                         'title': formation['name'],
1093                                         'id': formation['formation_id']
1094                                 });
1095                         }
1096                 }
1097         }
1098
1099         for (const choice of choices) {
1100                 let label = document.createElement('label');
1101
1102                 let subitem = document.createElement('div');
1103                 subitem.classList.add('option');
1104
1105                 let check = document.createElement('input');
1106                 check.setAttribute('type', 'checkbox');
1107                 check.setAttribute('id', 'choice' + choice.id);
1108                 if (filter !== null && filter.elements.has(choice.id)) {
1109                         check.setAttribute('checked', 'checked');
1110                 }
1111                 check.addEventListener('change', (e) => { checkbox_changed(e, filter_type, choice.id); });
1112
1113                 subitem.appendChild(check);
1114                 subitem.appendChild(document.createTextNode(choice.title));
1115
1116                 label.appendChild(subitem);
1117                 subitems.push(label);
1118         }
1119         submenu.replaceChildren(...subitems);
1120         submenu.style.display = 'block';
1121
1122         if (pill !== null) {
1123                 let rect = pill.getBoundingClientRect();
1124                 submenu.style.top = (rect.bottom + 10) + 'px';
1125                 submenu.style.left = rect.left + 'px';
1126         } else {
1127                 // Position just outside the selected menu.
1128                 let rect = document.getElementById('filter-add-menu').getBoundingClientRect();
1129                 submenu.style.top = (rect.top + menu_idx * 35) + 'px';
1130                 submenu.style.left = (rect.right - 1) + 'px';
1131         }
1132 }
1133
1134 // Find the right filter, if it exists.
1135 function find_filter(filter_type) {
1136         for (let f of global_filters) {
1137                 if (f.type === filter_type) {
1138                         return f;
1139                 }
1140         }
1141         return null;
1142 }
1143
1144 function checkbox_changed(e, filter_type, id) {
1145         let filter = find_filter(filter_type);
1146         if (e.target.checked) {
1147                 // See if we must add a new filter to the list.
1148                 if (filter === null) {
1149                         filter = {
1150                                 'type': filter_type,
1151                                 'elements': new Set([ id ]),
1152                         };
1153                         filter.pill = make_filter_pill(filter);
1154                         global_filters.push(filter);
1155                         document.getElementById('filters').appendChild(filter.pill);
1156                 } else {
1157                         filter.elements.add(id);
1158                         let new_pill = make_filter_pill(filter);
1159                         document.getElementById('filters').replaceChild(new_pill, filter.pill);
1160                         filter.pill = new_pill;
1161                 }
1162         } else {
1163                 filter.elements.delete(id);
1164                 if (filter.elements.size === 0) {
1165                         document.getElementById('filters').removeChild(filter.pill);
1166                         global_filters = global_filters.filter(f => f !== filter);
1167                 } else {
1168                         let new_pill = make_filter_pill(filter);
1169                         document.getElementById('filters').replaceChild(new_pill, filter.pill);
1170                         filter.pill = new_pill;
1171                 }
1172         }
1173
1174         process_matches(global_json, global_filters);
1175 }
1176
1177 function make_filter_pill(filter) {
1178         let pill = document.createElement('div');
1179         pill.classList.add('filter-pill');
1180         let text;
1181         if (filter.type === 'match') {
1182                 text = 'Match: ';
1183
1184                 let all_names = [];
1185                 for (const match_id of filter.elements) {
1186                         all_names.push(find_match(match_id)['description']);
1187                 }
1188                 let common_prefix = find_common_prefix_of_all(all_names);
1189                 if (common_prefix !== null) {
1190                         text += common_prefix + '(';
1191                 }
1192
1193                 let first = true;
1194                 let sorted_match_id = Array.from(filter.elements).sort((a, b) => a - b);
1195                 for (const match_id of sorted_match_id) {
1196                         if (!first) {
1197                                 text += ', ';
1198                         }
1199                         let desc = find_match(match_id)['description'];
1200                         if (common_prefix === null) {
1201                                 text += desc;
1202                         } else {
1203                                 text += desc.substr(common_prefix.length);
1204                         }
1205                         first = false;
1206                 }
1207
1208                 if (common_prefix !== null) {
1209                         text += ')';
1210                 }
1211         } else if (filter.type === 'player_any') {
1212                 text = 'Player (any): ';
1213                 let sorted_players = Array.from(filter.elements).sort((a, b) => player_pos(a) - player_pos(b));
1214                 let first = true;
1215                 for (const player_id of sorted_players) {
1216                         if (!first) {
1217                                 text += ', ';
1218                         }
1219                         text += find_player(player_id)['name'];
1220                         first = false;
1221                 }
1222         } else if (filter.type === 'player_all') {
1223                 text = 'Players: ';
1224                 let sorted_players = Array.from(filter.elements).sort((a, b) => player_pos(a) - player_pos(b));
1225                 let first = true;
1226                 for (const player_id of sorted_players) {
1227                         if (!first) {
1228                                 text += ' AND ';
1229                         }
1230                         text += find_player(player_id)['name'];
1231                         first = false;
1232                 }
1233         } else if (filter.type === 'formation_offense' || filter.type === 'formation_defense') {
1234                 const offense = (filter.type === 'formation_offense');
1235                 if (offense) {
1236                         text = 'Offense: ';
1237                 } else {
1238                         text = 'Defense: ';
1239                 }
1240
1241                 let all_names = [];
1242                 for (const formation_id of filter.elements) {
1243                         all_names.push(find_formation(formation_id)['name']);
1244                 }
1245                 let common_prefix = find_common_prefix_of_all(all_names);
1246                 if (common_prefix !== null) {
1247                         text += common_prefix + '(';
1248                 }
1249
1250                 let first = true;
1251                 let sorted_formation_id = Array.from(filter.elements).sort((a, b) => a - b);
1252                 for (const formation_id of sorted_formation_id) {
1253                         if (!first) {
1254                                 text += ', ';
1255                         }
1256                         let desc = find_formation(formation_id)['name'];
1257                         if (common_prefix === null) {
1258                                 text += desc;
1259                         } else {
1260                                 text += desc.substr(common_prefix.length);
1261                         }
1262                         first = false;
1263                 }
1264
1265                 if (common_prefix !== null) {
1266                         text += ')';
1267                 }
1268         }
1269
1270         let text_node = document.createElement('span');
1271         text_node.innerText = text;
1272         text_node.addEventListener('click', (e) => show_submenu(null, pill, filter.type));
1273         pill.appendChild(text_node);
1274
1275         pill.appendChild(document.createTextNode(' '));
1276
1277         let delete_node = document.createElement('span');
1278         delete_node.innerText = '✖';
1279         delete_node.addEventListener('click', (e) => {
1280                 // Delete this filter entirely.
1281                 document.getElementById('filters').removeChild(pill);
1282                 global_filters = global_filters.filter(f => f !== filter);
1283                 process_matches(global_json, global_filters);
1284
1285                 let add_menu = document.getElementById('filter-add-menu');
1286                 let add_submenu = document.getElementById('filter-submenu');
1287                 add_menu.style.display = 'none';
1288                 add_submenu.style.display = 'none';
1289         });
1290         pill.appendChild(delete_node);
1291         pill.style.cursor = 'pointer';
1292
1293         return pill;
1294 }
1295
1296 function find_common_prefix(a, b) {
1297         let ret = '';
1298         for (let i = 0; i < Math.min(a.length, b.length); ++i) {
1299                 if (a[i] === b[i]) {
1300                         ret += a[i];
1301                 } else {
1302                         break;
1303                 }
1304         }
1305         return ret;
1306 }
1307
1308 function find_common_prefix_of_all(values) {
1309         if (values.length < 2) {
1310                 return null;
1311         }
1312         let common_prefix = null;
1313         for (const desc of values) {
1314                 if (common_prefix === null) {
1315                         common_prefix = desc;
1316                 } else {
1317                         common_prefix = find_common_prefix(common_prefix, desc);
1318                 }
1319         }
1320         if (common_prefix.length >= 3) {
1321                 return common_prefix;
1322         } else {
1323                 return null;
1324         }
1325 }
1326
1327 function find_match(match_id) {
1328         for (const match of global_json['matches']) {
1329                 if (match['match_id'] === match_id) {
1330                         return match;
1331                 }
1332         }
1333         return null;
1334 }
1335
1336 function find_formation(formation_id) {
1337         for (const formation of global_json['formations']) {
1338                 if (formation['formation_id'] === formation_id) {
1339                         return formation;
1340                 }
1341         }
1342         return null;
1343 }
1344
1345 function find_player(player_id) {
1346         for (const player of global_json['players']) {
1347                 if (player['player_id'] === player_id) {
1348                         return player;
1349                 }
1350         }
1351         return null;
1352 }
1353
1354 function player_pos(player_id) {
1355         let i = 0;
1356         for (const player of global_json['players']) {
1357                 if (player['player_id'] === player_id) {
1358                         return i;
1359                 }
1360                 ++i;
1361         }
1362         return null;
1363 }
1364
1365 function keep_match(match_id, filters) {
1366         for (const filter of filters) {
1367                 if (filter.type === 'match') {
1368                         return filter.elements.has(match_id);
1369                 }
1370         }
1371         return true;
1372 }
1373
1374 function filter_passes(players, formations_used_this_point, filter) {
1375         if (filter.type === 'player_any') {
1376                 for (const p of Array.from(filter.elements)) {
1377                         if (players[p].on_field_since !== null) {
1378                                 return true;
1379                         }
1380                 }
1381                 return false;
1382         } else if (filter.type === 'player_all') {
1383                 for (const p of Array.from(filter.elements)) {
1384                         if (players[p].on_field_since === null) {
1385                                 return false;
1386                         }
1387                 }
1388                 return true;
1389         } else if (filter.type === 'formation_offense' || filter.type === 'formation_defense') {
1390                 for (const f of Array.from(filter.elements)) {
1391                         if (formations_used_this_point.has(f)) {
1392                                 return true;
1393                         }
1394                 }
1395                 return false;
1396         }
1397         return true;
1398 }
1399
1400 function keep_event(players, formations_used_this_point, filters) {
1401         for (const filter of filters) {
1402                 if (!filter_passes(players, formations_used_this_point, filter)) {
1403                         return false;
1404                 }
1405         }
1406         return true;
1407 }
1408
1409 // Heuristic: If we go at least ten seconds without the possession changing
1410 // or the operator specifying some other formation, we probably play the
1411 // same formation as the last point.
1412 function should_reuse_last_formation(events, t) {
1413         for (const e of events) {
1414                 if (e.t <= t) {
1415                         continue;
1416                 }
1417                 if (e.t > t + 10000) {
1418                         break;
1419                 }
1420                 const type = e.type;
1421                 if (type === 'their_goal' || type === 'goal' ||
1422                     type === 'set_defense' || type === 'set_offense' ||
1423                     type === 'throwaway' || type === 'their_throwaway' ||
1424                     type === 'drop' || type === 'defense' || type === 'interception' ||
1425                     type === 'pull' || type === 'pull_landed' || type === 'pull_oob' || type === 'their_pull' ||
1426                     type === 'formation_offense' || type === 'formation_defense') {
1427                         return false;
1428                 }
1429         }
1430         return true;
1431 }
1432
1433 function possibly_close_menu(e) {
1434         if (e.target.closest('#filter-click-to-add') === null &&
1435             e.target.closest('#filter-add-menu') === null &&
1436             e.target.closest('#filter-submenu') === null &&
1437             e.target.closest('.filter-pill') === null) {
1438                 let add_menu = document.getElementById('filter-add-menu');
1439                 let add_submenu = document.getElementById('filter-submenu');
1440                 add_menu.style.display = 'none';
1441                 add_submenu.style.display = 'none';
1442         }
1443 }