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