]> git.sesse.net Git - pkanalytics/commitdiff
Begin supporting filters.
authorSteinar H. Gunderson <sgunderson@bigfoot.com>
Sat, 27 May 2023 22:43:09 +0000 (00:43 +0200)
committerSteinar H. Gunderson <sgunderson@bigfoot.com>
Sat, 27 May 2023 22:43:09 +0000 (00:43 +0200)
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
ultimate.js
viewer.html

index 65ce16375d9a57cc8751a70fc21404e61307422c..856d83936291ca2fee4c29373356d500b2f4c0ae 100644 (file)
@@ -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;
        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;
 }
index 5c176e0deeb7309369e69b0c151942f0c7f4d000..0c44658adcdd30a1b99ca2b45b696e5c2600aa12 100644 (file)
@@ -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';
+       }
+}
index 0a20d883d5331f7e5648868c161278b2a25f6b08..3ac59b7cfadbc2959805ab45f40756003e8e92a0 100644 (file)
@@ -2,13 +2,24 @@
 <!-- TODO: make something nice to package up the ultimate.json and everything into one file -->
 <html>
   <head>
+    <meta charset="utf-8">
     <title>Plastkast Analytics</title>
     <link rel="stylesheet" type="text/css" href="ultimate.css">
     <script src="ultimate.js"></script>
   </head>
   <body>
-    <p id="mainmenu">
-    </p>
+    <p id="mainmenu"></p>
+    <div id="filter">
+      <!-- SVG icon: MIT license, jtblabs, https://github.com/jtblabs/jtb-icons?ref=svgrepo.com -->
+      <svg id="filter-icon" width="18" height="18" viewBox="0 0 32 32"><path d="M30 6.749h-28c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25v0h28c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25v0zM24 14.75h-16c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25v0h16c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25v0zM19 22.75h-6.053c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25v0h6.053c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25v0z"></path></svg>
+      <span id="filter-legend">Filter</span>
+      <span id="filters"></span>
+      <span id="filter-click-to-add" onclick="open_filter_menu()">Click to add…</span>
+        <div id="filter-add-menu">
+        </div>
+        <div id="filter-submenu">
+        </div>
+    </div>
     <table id="stats">
     </table>
   </body>