+ let avg_time = 1e-3 * sum_time / (p.pulls - p.oob_pulls);
+ let oob_pct = 100 * p.oob_pulls / p.pulls;
+
+ let ci_oob = make_binomial_ci(p.oob_pulls, p.pulls, z);
+ ci_oob.format = 'percentage';
+ ci_oob.desired = 0.2; // Arbitrary.
+ ci_oob.inverted = true;
+
+ let row = document.createElement('tr');
+ add_3cell(row, p.name, 'name'); // TODO: number?
+ add_3cell(row, p.defenses);
+ add_3cell(row, p.pulls);
+ add_3cell(row, p.oob_pulls);
+ add_3cell_ci(row, ci_oob);
+ if (p.pulls > p.oob_pulls) {
+ add_3cell(row, avg_time.toFixed(1) + ' sec');
+ } else {
+ add_3cell(row, 'N/A');
+ }
+ add_3cell(row, '+' + p.defensive_soft_plus);
+ add_3cell(row, '-' + p.defensive_soft_minus);
+ row.dataset.player = q;
+ rows.push(row);
+ }
+ return rows;
+}
+
+function make_table_playing_time(players) {
+ let rows = [];
+ {
+ let header = document.createElement('tr');
+ add_th(header, 'Player');
+ add_th(header, 'Points played');
+ add_th(header, 'Time played');
+ add_th(header, 'O time');
+ add_th(header, 'D time');
+ add_th(header, 'Time on field');
+ add_th(header, 'O points');
+ add_th(header, 'D points');
+ rows.push(header);
+ }
+
+ for (const [q,p] of get_sorted_players(players)) {
+ if (q === 'globals') continue;
+ let row = document.createElement('tr');
+ add_3cell(row, p.name, 'name'); // TODO: number?
+ add_3cell(row, p.points_played);
+ add_3cell(row, Math.floor(p.playing_time_ms / 60000) + ' min');
+ add_3cell(row, Math.floor(p.offensive_playing_time_ms / 60000) + ' min');
+ add_3cell(row, Math.floor(p.defensive_playing_time_ms / 60000) + ' min');
+ add_3cell(row, Math.floor(p.field_time_ms / 60000) + ' min');
+ add_3cell(row, p.offensive_points_completed);
+ add_3cell(row, p.defensive_points_completed);
+ row.dataset.player = q;
+ rows.push(row);
+ }
+
+ // Globals.
+ let globals = players['globals'];
+ let row = document.createElement('tr');
+ add_3cell(row, '');
+ add_3cell(row, globals.points_played);
+ add_3cell(row, Math.floor(globals.playing_time_ms / 60000) + ' min');
+ add_3cell(row, Math.floor(globals.offensive_playing_time_ms / 60000) + ' min');
+ add_3cell(row, Math.floor(globals.defensive_playing_time_ms / 60000) + ' min');
+ add_3cell(row, Math.floor(globals.field_time_ms / 60000) + ' min');
+ add_3cell(row, globals.offensive_points_completed);
+ add_3cell(row, globals.defensive_points_completed);
+ rows.push(row);
+
+ return rows;
+}
+
+function make_table_per_point(players) {
+ let rows = [];
+ {
+ let header = document.createElement('tr');
+ add_th(header, 'Player');
+ add_th(header, 'Goals');
+ add_th(header, 'Assists');
+ add_th(header, 'Hockey assists');
+ add_th(header, 'Ds');
+ add_th(header, 'Throwaways');
+ add_th(header, 'Recv. errors');
+ add_th(header, 'Touches');
+ rows.push(header);
+ }
+
+ let goals = 0;
+ let assists = 0;
+ let hockey_assists = 0;
+ let defenses = 0;
+ let throwaways = 0;
+ let receiver_errors = 0;
+ let touches = 0;
+ for (const [q,p] of get_sorted_players(players)) {
+ if (q === 'globals') continue;
+
+ // Can only happen once per point, so these are binomials.
+ let ci_goals = make_binomial_ci(p.goals, p.points_played, z);
+ let ci_assists = make_binomial_ci(p.assists, p.points_played, z);
+ let ci_hockey_assists = make_binomial_ci(p.hockey_assists, p.points_played, z);
+ // Arbitrarily desire at least 10% (not everybody can score or assist).
+ ci_goals.desired = 0.1;
+ ci_assists.desired = 0.1;
+ ci_hockey_assists.desired = 0.1;
+
+ let row = document.createElement('tr');
+ add_3cell(row, p.name, 'name'); // TODO: number?
+ add_3cell_ci(row, ci_goals);
+ add_3cell_ci(row, ci_assists);
+ add_3cell_ci(row, ci_hockey_assists);
+ add_3cell_ci(row, make_poisson_ci(p.defenses, p.points_played, z));
+ add_3cell_ci(row, make_poisson_ci(p.throwaways, p.points_played, z, true));
+ add_3cell_ci(row, make_poisson_ci(p.drops + p.was_ds, p.points_played, z, true));
+ if (p.points_played > 0) {
+ add_3cell(row, p.touches == 0 ? 0 : (p.touches / p.points_played).toFixed(2));
+ } else {
+ add_3cell(row, 'N/A');
+ }
+ row.dataset.player = q;
+ rows.push(row);
+
+ goals += p.goals;
+ assists += p.assists;
+ hockey_assists += p.hockey_assists;
+ defenses += p.defenses;
+ throwaways += p.throwaways;
+ receiver_errors += p.drops + p.was_ds;
+ touches += p.touches;
+ }
+
+ // Globals.
+ let globals = players['globals'];
+ let row = document.createElement('tr');
+ add_3cell(row, '');
+ if (globals.points_played > 0) {
+ add_3cell_with_filler_ci(row, goals == 0 ? 0 : (goals / globals.points_played).toFixed(2));
+ add_3cell_with_filler_ci(row, assists == 0 ? 0 : (assists / globals.points_played).toFixed(2));
+ add_3cell_with_filler_ci(row, hockey_assists == 0 ? 0 : (hockey_assists / globals.points_played).toFixed(2));
+ add_3cell_with_filler_ci(row, defenses == 0 ? 0 : (defenses / globals.points_played).toFixed(2));
+ add_3cell_with_filler_ci(row, throwaways == 0 ? 0 : (throwaways / globals.points_played).toFixed(2));
+ add_3cell_with_filler_ci(row, receiver_errors == 0 ? 0 : (receiver_errors / globals.points_played).toFixed(2));
+ add_3cell(row, touches == 0 ? 0 : (touches / globals.points_played).toFixed(2));
+ } else {
+ add_3cell_with_filler_ci(row, 'N/A');
+ add_3cell_with_filler_ci(row, 'N/A');
+ add_3cell_with_filler_ci(row, 'N/A');
+ add_3cell_with_filler_ci(row, 'N/A');
+ add_3cell_with_filler_ci(row, 'N/A');
+ add_3cell_with_filler_ci(row, 'N/A');
+ add_3cell(row, 'N/A');
+ }
+ rows.push(row);
+
+ return rows;
+}
+
+function open_filter_menu() {
+ document.getElementById('filter-submenu').style.display = 'none';
+
+ let menu = document.getElementById('filter-add-menu');
+ menu.style.display = 'block';
+ menu.replaceChildren();
+
+ // Place the menu directly under the “click to add” label;
+ // we don't anchor it since that label will move around
+ // and the menu shouldn't.
+ let rect = document.getElementById('filter-click-to-add').getBoundingClientRect();
+ menu.style.left = rect.left + 'px';
+ menu.style.top = (rect.bottom + 10) + 'px';
+
+ add_menu_item(menu, 0, 'match', 'Match (any)');
+ add_menu_item(menu, 1, 'player_any', 'Player on field (any)');
+ add_menu_item(menu, 2, 'player_all', 'Player on field (all)');
+ add_menu_item(menu, 3, 'formation_offense', 'Offense played (any)');
+ add_menu_item(menu, 4, 'formation_defense', 'Defense played (any)');
+}
+
+function add_menu_item(menu, menu_idx, filter_type, title) {
+ let item = document.createElement('div');
+ item.classList.add('option');
+ item.appendChild(document.createTextNode(title));
+
+ let arrow = document.createElement('div');
+ arrow.classList.add('arrow');
+ arrow.textContent = '▸';
+ item.appendChild(arrow);
+
+ menu.appendChild(item);
+
+ item.addEventListener('click', (e) => { show_submenu(menu_idx, null, filter_type); });
+}
+
+function show_submenu(menu_idx, pill, filter_type) {
+ let submenu = document.getElementById('filter-submenu');
+ let subitems = [];
+ const filter = find_filter(filter_type);
+
+ let choices = [];
+ if (filter_type === 'match') {
+ for (const match of global_json['matches']) {
+ choices.push({
+ 'title': match['description'],
+ 'id': match['match_id']
+ });
+ }
+ } else if (filter_type === 'player_any' || filter_type === 'player_all') {
+ for (const player of global_json['players']) {
+ choices.push({
+ 'title': player['name'],
+ 'id': player['player_id']
+ });
+ }
+ } else if (filter_type === 'formation_offense') {
+ choices.push({
+ 'title': '(None/unknown)',
+ 'id': 0,
+ });
+ for (const formation of global_json['formations']) {
+ if (formation['offense']) {
+ choices.push({
+ 'title': formation['name'],
+ 'id': formation['formation_id']
+ });
+ }
+ }
+ } else if (filter_type === 'formation_defense') {
+ choices.push({
+ 'title': '(None/unknown)',
+ 'id': 0,
+ });
+ for (const formation of global_json['formations']) {
+ if (!formation['offense']) {
+ choices.push({
+ 'title': formation['name'],
+ 'id': formation['formation_id']
+ });
+ }
+ }
+ }
+
+ for (const choice of choices) {
+ let label = document.createElement('label');
+
+ let subitem = document.createElement('div');
+ subitem.classList.add('option');
+
+ let check = document.createElement('input');
+ check.setAttribute('type', 'checkbox');
+ check.setAttribute('id', 'choice' + choice.id);
+ if (filter !== null && filter.elements.has(choice.id)) {
+ check.setAttribute('checked', 'checked');
+ }
+ check.addEventListener('change', (e) => { checkbox_changed(e, filter_type, choice.id); });
+
+ subitem.appendChild(check);
+ subitem.appendChild(document.createTextNode(choice.title));
+
+ label.appendChild(subitem);
+ subitems.push(label);
+ }
+ submenu.replaceChildren(...subitems);
+ submenu.style.display = 'block';
+
+ if (pill !== null) {
+ let rect = pill.getBoundingClientRect();
+ submenu.style.top = (rect.bottom + 10) + 'px';
+ submenu.style.left = rect.left + 'px';
+ } else {
+ // Position just outside the selected menu.
+ let rect = document.getElementById('filter-add-menu').getBoundingClientRect();
+ submenu.style.top = (rect.top + menu_idx * 35) + 'px';
+ submenu.style.left = (rect.right - 1) + 'px';
+ }
+}
+
+// Find the right filter, if it exists.
+function find_filter(filter_type) {
+ for (let f of global_filters) {
+ if (f.type === filter_type) {
+ return f;
+ }
+ }
+ return null;
+}
+
+function checkbox_changed(e, filter_type, id) {
+ let filter = find_filter(filter_type);
+ if (e.target.checked) {
+ // See if we must add a new filter to the list.
+ if (filter === null) {
+ filter = {
+ 'type': filter_type,
+ 'elements': new Set([ id ]),
+ };
+ filter.pill = make_filter_pill(filter);
+ global_filters.push(filter);
+ document.getElementById('filters').appendChild(filter.pill);
+ } else {
+ filter.elements.add(id);
+ let new_pill = make_filter_pill(filter);
+ document.getElementById('filters').replaceChild(new_pill, filter.pill);
+ filter.pill = new_pill;
+ }
+ } else {
+ filter.elements.delete(id);
+ if (filter.elements.size === 0) {
+ document.getElementById('filters').removeChild(filter.pill);
+ global_filters = global_filters.filter(f => f !== filter);
+ } else {
+ let new_pill = make_filter_pill(filter);
+ document.getElementById('filters').replaceChild(new_pill, filter.pill);
+ filter.pill = new_pill;
+ }
+ }
+
+ process_matches(global_json, global_filters);
+}
+
+function make_filter_pill(filter) {
+ let pill = document.createElement('div');
+ pill.classList.add('filter-pill');
+ let text;
+ if (filter.type === 'match') {
+ text = 'Match: ';
+
+ let all_names = [];
+ for (const match_id of filter.elements) {
+ all_names.push(find_match(match_id)['description']);
+ }
+ let common_prefix = find_common_prefix_of_all(all_names);
+ if (common_prefix !== null) {
+ text += common_prefix + '(';
+ }
+
+ let first = true;
+ let sorted_match_id = Array.from(filter.elements).sort((a, b) => a - b);
+ for (const match_id of sorted_match_id) {
+ if (!first) {
+ text += ', ';
+ }
+ let desc = find_match(match_id)['description'];
+ if (common_prefix === null) {
+ text += desc;
+ } else {
+ text += desc.substr(common_prefix.length);
+ }
+ first = false;
+ }
+
+ if (common_prefix !== null) {
+ text += ')';
+ }
+ } else if (filter.type === 'player_any') {
+ text = 'Player (any): ';
+ let sorted_players = Array.from(filter.elements).sort((a, b) => player_pos(a) - player_pos(b));
+ let first = true;
+ for (const player_id of sorted_players) {
+ if (!first) {
+ text += ', ';
+ }
+ text += find_player(player_id)['name'];
+ first = false;
+ }
+ } else if (filter.type === 'player_all') {
+ text = 'Players: ';
+ let sorted_players = Array.from(filter.elements).sort((a, b) => player_pos(a) - player_pos(b));
+ let first = true;
+ for (const player_id of sorted_players) {
+ if (!first) {
+ text += ' AND ';
+ }
+ text += find_player(player_id)['name'];
+ first = false;
+ }
+ } else if (filter.type === 'formation_offense' || filter.type === 'formation_defense') {
+ const offense = (filter.type === 'formation_offense');
+ if (offense) {
+ text = 'Offense: ';
+ } else {
+ text = 'Defense: ';
+ }
+
+ let all_names = [];
+ for (const formation_id of filter.elements) {
+ all_names.push(find_formation(formation_id)['name']);
+ }
+ let common_prefix = find_common_prefix_of_all(all_names);
+ if (common_prefix !== null) {
+ text += common_prefix + '(';
+ }
+
+ let first = true;
+ let sorted_formation_id = Array.from(filter.elements).sort((a, b) => a - b);
+ for (const formation_id of sorted_formation_id) {
+ if (!first) {
+ text += ', ';
+ }
+ let desc = find_formation(formation_id)['name'];
+ if (common_prefix === null) {
+ text += desc;
+ } else {
+ text += desc.substr(common_prefix.length);
+ }
+ first = false;
+ }
+
+ if (common_prefix !== null) {
+ text += ')';
+ }
+ }
+
+ let text_node = document.createElement('span');
+ text_node.innerText = text;
+ text_node.addEventListener('click', (e) => show_submenu(null, pill, filter.type));
+ pill.appendChild(text_node);
+
+ pill.appendChild(document.createTextNode(' '));
+
+ let delete_node = document.createElement('span');
+ delete_node.innerText = '✖';
+ delete_node.addEventListener('click', (e) => {
+ // Delete this filter entirely.
+ document.getElementById('filters').removeChild(pill);
+ global_filters = global_filters.filter(f => f !== filter);
+ process_matches(global_json, global_filters);
+
+ let add_menu = document.getElementById('filter-add-menu');
+ let add_submenu = document.getElementById('filter-submenu');
+ add_menu.style.display = 'none';
+ add_submenu.style.display = 'none';
+ });
+ pill.appendChild(delete_node);
+ pill.style.cursor = 'pointer';
+
+ return pill;
+}
+
+function find_common_prefix(a, b) {
+ let ret = '';
+ for (let i = 0; i < Math.min(a.length, b.length); ++i) {
+ if (a[i] === b[i]) {
+ ret += a[i];
+ } else {
+ break;
+ }
+ }
+ return ret;
+}
+
+function find_common_prefix_of_all(values) {
+ if (values.length < 2) {
+ return null;
+ }
+ let common_prefix = null;
+ for (const desc of values) {
+ if (common_prefix === null) {
+ common_prefix = desc;
+ } else {
+ common_prefix = find_common_prefix(common_prefix, desc);
+ }
+ }
+ if (common_prefix.length >= 3) {
+ return common_prefix;
+ } else {
+ return null;
+ }
+}
+
+function find_match(match_id) {
+ for (const match of global_json['matches']) {
+ if (match['match_id'] === match_id) {
+ return match;
+ }
+ }
+ return null;
+}
+
+function find_formation(formation_id) {
+ for (const formation of global_json['formations']) {
+ if (formation['formation_id'] === formation_id) {
+ return formation;
+ }
+ }
+ return null;
+}
+
+function find_player(player_id) {
+ for (const player of global_json['players']) {
+ if (player['player_id'] === player_id) {
+ return player;
+ }
+ }
+ return null;
+}
+
+function player_pos(player_id) {
+ let i = 0;
+ for (const player of global_json['players']) {
+ if (player['player_id'] === player_id) {
+ return i;
+ }
+ ++i;
+ }
+ return null;
+}
+
+function keep_match(match_id, filters) {
+ for (const filter of filters) {
+ if (filter.type === 'match') {
+ return filter.elements.has(match_id);