From 8b766d6cd0cb033a9929adc18eda0679cbd6ac39 Mon Sep 17 00:00:00 2001 From: "Steinar H. Gunderson" Date: Sun, 28 May 2023 00:43:09 +0200 Subject: [PATCH] Begin supporting filters. This took forever, but now you can at least filter on matches. There's also UI to filter on people on the field, but it's not hooked up to anything yet. --- ultimate.css | 79 ++++++++++++- ultimate.js | 307 ++++++++++++++++++++++++++++++++++++++++++++++++++- viewer.html | 15 ++- 3 files changed, 395 insertions(+), 6 deletions(-) diff --git a/ultimate.css b/ultimate.css index 65ce163..856d839 100644 --- a/ultimate.css +++ b/ultimate.css @@ -9,7 +9,7 @@ font-weight: bold; padding-top: 10px; padding-bottom: 10px; - margin-bottom: 20px; + margin-bottom: 10px; font-size: 90%; border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; @@ -26,6 +26,83 @@ color: rgba(0, 0, 0, 0.6); text-decoration: none; } +#filter { + padding-left: 10px; + margin-top: 0; + padding-top: 0; + border-bottom: 1px solid #ddd; + padding-bottom: 10px; + margin-bottom: 20px; +} +#filter-icon { + vertical-align: middle; +} +#filter-legend { + vertical-align: bottom; + font-size: 14px; + font-weight: bold; +} +.filter-pill { + display: inline-block; + vertical-align: bottom; + margin-left: 5px; + margin-top: 0; + font-size: 13px; + background-color: #35f; + color: white; + border-radius: 6px; + padding-left: 6px; + padding-right: 6px; +} +#filter-click-to-add { + margin-left: 10px; + vertical-align: bottom; + font-size: 14px; + font-style: italic; + color: rgba(0, 0, 0, 0.4); +} +#filter-add-container { + display: inline-block; + top: 2px; +} +#filter-add-menu { + display: none; + /* display: block; */ + border: 1px solid black; + position: absolute; + top: 27px; + left: 10px; + background: white; + width: 250px; +} +#filter-add-menu div.option, #filter-submenu div.option { + padding-left: 10px; + padding-top: 8px; + padding-bottom: 8px; + white-space: nowrap; +} +#filter-add-menu div.option:hover, #filter-submenu div.option:hover { + background-color: #eee; +} +#filter-add-menu div.arrow { + display: inline-block; + position: absolute; + right: 10px; +} +#filter-submenu { + display: none; + border: 1px solid black; + position: absolute; + top: 27px; /* JavaScript will override with 35px extra for each line the submenu should be moved down. */ + left: 261px; + background: white; +} +#filter-submenu div.option { + padding-right: 12px; +} +div.option input[type="checkbox"] { + margin-right: 0.5em; +} table { border-collapse: collapse; } diff --git a/ultimate.js b/ultimate.js index 5c176e0..0c44658 100644 --- a/ultimate.js +++ b/ultimate.js @@ -3,11 +3,13 @@ // No frameworks, no compilers, no npm, just JavaScript. :-) let global_json; +let global_filters = []; -addEventListener('hashchange', () => { process_matches(global_json); }); +addEventListener('hashchange', () => { process_matches(global_json, global_filters); }); +addEventListener('click', possibly_close_menu); fetch('ultimate.json') .then(response => response.json()) - .then(response => { global_json = response; process_matches(global_json); }); + .then(response => { global_json = response; process_matches(global_json, global_filters); }); function attribute_player_time(player, to, from, offense) { let delta_time; @@ -145,7 +147,7 @@ function add_3cell_ci(tr, ci) { element.appendChild(svg); } -function process_matches(json) { +function process_matches(json, filters) { let players = {}; for (const player of json['players']) { players[player['player_id']] = { @@ -206,6 +208,10 @@ function process_matches(json) { let globals = players['globals']; for (const match of json['matches']) { + if (!keep_match(match['match_id'], filters)) { + continue; + } + let our_score = 0; let their_score = 0; let handler = null; @@ -223,6 +229,8 @@ function process_matches(json) { p.last_point_seen = null; } for (const e of match['events']) { + // TODO: filter events + let t = e['t']; let type = e['type']; let p = players[e['player']]; @@ -902,3 +910,296 @@ function make_table_per_point(players) { 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, 'Formation 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'] + }); + } + } + + 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: '; + + // See if there's a common prefix. + let num_matches = filter.elements.size; + let common_prefix = null; + if (num_matches > 1) { + for (const match_id of filter.elements) { + let desc = find_match(match_id)['description']; + if (common_prefix === null) { + common_prefix = desc; + } else { + common_prefix = find_common_prefix(common_prefix, desc); + } + } + if (common_prefix.length < 3) { + common_prefix = null; + } + } + + 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; + } + } + + 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_match(match_id) { + for (const match of global_json['matches']) { + if (match['match_id'] === match_id) { + return match; + } + } + 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); + } + } + return true; +} + +function possibly_close_menu(e) { + if (e.target.closest('#filter-click-to-add') === null && + e.target.closest('#filter-add-menu') === null && + e.target.closest('#filter-submenu') === null && + e.target.closest('.filter-pill') === null) { + 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'; + } +} diff --git a/viewer.html b/viewer.html index 0a20d88..3ac59b7 100644 --- a/viewer.html +++ b/viewer.html @@ -2,13 +2,24 @@ + Plastkast Analytics - + +
+ + + Filter + + Click to add… +
+
+
+
+
-- 2.39.2