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