diff --git a/vasl_templates/webapp/main.py b/vasl_templates/webapp/main.py index 99cb059..b9b26ff 100644 --- a/vasl_templates/webapp/main.py +++ b/vasl_templates/webapp/main.py @@ -76,6 +76,7 @@ _APP_CONFIG_DEFAULTS = { # Bodhgaya, India (APR/19) "ONLINE_EXTN_COUNTER_IMAGES_URL_TEMPLATE": "http://vasl-templates.org/services/counter/{{EXTN_ID}}/{{PATH}}", "ASA_UPLOAD_URL": "https://aslscenarioarchive.com/rest/update/{ID}?user={USER}&token={TOKEN}", "TURN_TRACK_SHADING_COLORS": [ "#e0e0e0", "#c0c0c0" ], + "SCENARIO_SEARCH_LOAD_BLOCK_SIZE": 1000, "SCENARIO_SEARCH_QUERY_INPUT_DELAY": 20, } @app.route( "/app-config" ) diff --git a/vasl_templates/webapp/scenarios.py b/vasl_templates/webapp/scenarios.py index 19f519d..ec5d943 100644 --- a/vasl_templates/webapp/scenarios.py +++ b/vasl_templates/webapp/scenarios.py @@ -10,7 +10,6 @@ import base64 import re import time import math -import hashlib import logging from flask import request, render_template, make_response, jsonify, abort @@ -131,7 +130,15 @@ def get_scenario_index(): return _make_not_available_response( "Please wait, the scenario index is still downloading.", None ) - etag = hashlib.md5( json.dumps( _asa_scenarios.index ).encode( "utf-8" ) ).hexdigest() + # NOTE: We used to calculate the ETag like this: + # etag = hashlib.md5( json.dumps( _asa_scenarios.index ).encode( "utf-8" ) ).hexdigest() + # but this is slow, and is done *every* time i.e. even when nothing's changed and we return a 304 :-/ + # So, we take advantage of the fact that the only time the index changes is when we've downloaded + # a new version, and the *entire* dict changes i.e. we can use id() to detect if anything's changed. + # The only exception to this is when we temporarily modify the index ourself after uploading + # a scenario to the ASL Scenario Archive, so in that case, we clone the dict to ensure that + # id() returns something different. + etag = str( id( _asa_scenarios.index ) ) if request.headers.get( "If-None-Match" ) == etag: return "Not Modified", 304 resp = make_response( jsonify( [ @@ -154,7 +161,8 @@ def get_roar_scenario_index(): return _make_not_available_response( "Please wait, the ROAR scenarios are still downloading.", None ) - etag = hashlib.md5( json.dumps( _roar_scenarios.index ).encode( "utf-8" ) ).hexdigest() + # NOTE: See get_scenario_index() for why we calculate the ETag in this way. + etag = str( id( _roar_scenarios.index ) ) if request.headers.get( "If-None-Match" ) == etag: return "Not Modified", 304 resp = make_response( jsonify( _roar_scenarios.index ) ) @@ -598,6 +606,8 @@ def on_successful_asa_upload( scenario_id ): # update the in-memory scenario index with _asa_scenarios: + # NOTE: We clone the index so that it will get a new ETag in get_scenario_index(). + _asa_scenarios.index = dict( _asa_scenarios.index ) _asa_scenarios.index[ scenario_id ] = new_scenario return jsonify( { "status": "ok" } ) diff --git a/vasl_templates/webapp/static/css/scenario-search-dialog.css b/vasl_templates/webapp/static/css/scenario-search-dialog.css index b0cbaa2..85249a1 100644 --- a/vasl_templates/webapp/static/css/scenario-search-dialog.css +++ b/vasl_templates/webapp/static/css/scenario-search-dialog.css @@ -35,6 +35,7 @@ /* footer */ #scenario-search .footer { + position: relative ; margin-bottom: -0.5em ; padding-top: 0.5em ; font-size: 70% ; font-style: italic ; @@ -45,6 +46,7 @@ #scenario-search .footer a { color: #666 ; } #scenario-search .footer a:link { text-decoration: none ; } #scenario-search .footer a:focus { outline: 0 ; } +#scenario-search .footer img.loader { width: 15px ; height: 15px ; position: absolute ; bottom: 0 ; left: 0 ; } /* import control */ #scenario-search .import-control { margin-top: 0.5em ; padding-top: 0.75em ; border-top: 1px dotted #666 ; } diff --git a/vasl_templates/webapp/static/scenarios.js b/vasl_templates/webapp/static/scenarios.js index 02f73a4..459b25e 100644 --- a/vasl_templates/webapp/static/scenarios.js +++ b/vasl_templates/webapp/static/scenarios.js @@ -2,7 +2,7 @@ ( function() { // nb: put the entire file into its own local namespace, global stuff gets added to window. -var gIsFirstSearch ; +var gIsFirstSearch, gSearchQueryInputPending=0 ; var $gDialog, $gScenariosSelect, $gSearchQueryInputBox, $gScenarioCard, $gFooter ; var $gImportControl, $gDownloadsButton, $gImportScenarioButton, $gConfirmImportButton, $gCancelImportButton, $gImportWarnings ; @@ -60,9 +60,7 @@ window.searchForScenario = function() $gDialog.find( ".select2-results__option" ).remove() ; updateForSearchResults() ; $gScenarioCard.empty() ; - // NOTE: We don't hide the footer since we want it to take up its space in the layout, - // so that the list of search results is the correct height. - $gFooter.css( "opacity", 0 ) ; + $gFooter.find( ".asa" ).css( "opacity", 0 ) ; $gImportWarnings.empty().hide() ; $gDownloadsButton.button( "disable" ) ; $gImportScenarioButton.show() ; @@ -132,16 +130,20 @@ function initDialog( $dlg, scenarios ) function initSearchResults( $dlg, scenarios ) { // initialize the search results + $gFooter.find( ".loader" ).show() ; initPrefixIndex( scenarios ) ; + // FUDGE! Loading nearly 10k entries into the select2 is slow (and barely acceptable), + // so instead, we initialize it with an empty list, and load the scenarios asynchronously. + // Of course, if the user does a search while this is happening, they will only see results + // from those scenarios that have been loaded, but the user will usually be typing multiple + // characters, and since the loading process only takes around a second, it won't be long + // before they are searching over the complete set of scenarios. + // Note that this only an issue when we get a new scenario index i.e. the first time + // the user does a scenario search, or if the backend has downloaded an updated index (rare). + // Otherwise, the code will re-use what was previously loaded into the UI, which will be fast. + // NOTE: The dialog is still a bit sluggish when being opened for the second time (even though + // everything is already in the UI), but this seems to be a Chrome thing... :-/ var options = [] ; - scenarios.forEach( function( scenario ) { - options.push( { - id: scenario.scenario_id, - text: scenario.scenario_name, // nb: this will always have something - scenario: scenario, - } ) ; - } ) ; - sortScenarios( options ) ; // load the search results if ( $gScenariosSelect ) { @@ -157,6 +159,25 @@ function initSearchResults( $dlg, scenarios ) dropdownParent: $dlg.find( ".scenarios" ), } ) ; + // load the scenarios + var pageSize = gAppConfig.SCENARIO_SEARCH_LOAD_BLOCK_SIZE ; + function loadScenarios( pageNo ) { + for ( var i=0 ; i < pageSize ; ++i ) { + var scenario = scenarios[ pageSize * pageNo + i ] ; + if ( ! scenario ) { + $gScenariosSelect.trigger( "change" ) ; + $gFooter.find( ".loader" ).fadeOut( 500 ) ; + return ; + } + opt = new Option( scenario.scenario_name, scenario.scenario_id ) ; + $(opt).data( "scenario", scenario ) ; + $gScenariosSelect.append( opt ) ; + } + $gScenariosSelect.trigger( "change" ) ; + setTimeout( function () { loadScenarios( pageNo+1 ) ; }, 20 ) ; + } + setTimeout( function () { loadScenarios( 0 ) ; }, 0 ) ; + // stop the select2 droplist from closing up $gScenariosSelect.select2( "open" ) ; $gScenariosSelect.on( "select2:closing", function( evt ) { @@ -173,11 +194,17 @@ function initSearchResults( $dlg, scenarios ) $gSearchQueryInputBox.on( "input", function() { // FUDGE! select2 rebuilds the list of matching items, and selects the first one, // but doesn't send us a "select" event for it - we do things manually here :-/ - var $elem = $( ".select2-results__option--highlighted .search-result" ) ; - onItemSelected( $elem.attr( "data-id" ) ) ; - updateForSearchResults() ; - // FUDGE! Undo the positioning hack we did in the "create" handler. - $dlg.find( ".select2-dropdown" ).css( "left", 0 ) ; + // NOTE: We wait for the user to stop typing before doing anything. + ++ gSearchQueryInputPending ; + setTimeout( function() { + if ( -- gSearchQueryInputPending !== 0 ) + return ; + var $elem = $( ".select2-results__option--highlighted .search-result" ) ; + onItemSelected( $elem.attr( "data-id" ) ) ; + updateForSearchResults() ; + // FUDGE! Undo the positioning hack we did in the "create" handler. + $dlg.find( ".select2-dropdown" ).css( "left", 0 ) ; + }, gAppConfig.SCENARIO_SEARCH_QUERY_INPUT_DELAY ) ; } ) ; // handle Up and Down key-presses @@ -241,7 +268,8 @@ function isMatchingItem( params, item ) return item ; } else { // search for a matching substring - if ( item.scenario._searchText.indexOf( termLC ) !== -1 ) + var scenario = $( item.element ).data( "scenario" ) ; + if ( scenario._searchText.indexOf( termLC ) !== -1 ) return item ; } return null ; @@ -261,36 +289,27 @@ function sortItems( items ) { // NOTE: This function is called by the select2 to sort the items being shown. - // NOTE: We used to sort the items alphabetically here, but this could cause a new item to appear - // at the top of the list, which we want to be selected. It was ridiculously difficult to figure out - // how to select an item: - // $gScenariosSelect.select2( "trigger", "select", { - // data: { id: items[0].id } - // } ) ; - // but unfortunately, this slows things down a lot in Chrome (everything flies in Firefox). - // We really need to present the scenarios in alphabetical order (so that scenarios with the same name - // are grouped together), but we can achieve that same effect by loading them into the select2 - // in alphabetical order. - - return items ; -} - -function sortScenarios( options ) -{ - // NOTE See sortItems() for why we load the scenarios in alphabetical order. - function getSortVal( text ) { - text = text.trim().toLowerCase() ; - if ( text[0] == '"' || text[0] == "'" ) - text = text.substring( 1 ) ; - if ( text[0] == "\u00a1" || text[0] == "\u00bf" ) // nb: inverted ! and ? - text = text.substring( 1 ) ; - if ( text.substring( 0, 3 ) == "..." ) - text = text.substring( 3 ) ; - return text ; + function getSortVal( item ) { + var sortVal = $(item).data( "sortVal" ) ; + if ( ! sortVal ) { + sortVal = item.text.trim().toLowerCase() ; + if ( sortVal[0] == '"' || sortVal[0] == "'" ) + sortVal = sortVal.substring( 1 ) ; + if ( sortVal[0] == "\u00a1" || sortVal[0] == "\u00bf" ) // nb: inverted ! and ? + sortVal = sortVal.substring( 1 ) ; + if ( sortVal.substring( 0, 3 ) == "..." ) + sortVal = sortVal.substring( 3 ) ; + $(item).data( "sortVal", sortVal ) ; + } + return sortVal ; } - options.sort( function( lhs, rhs ) { - return getSortVal( lhs.text ).localeCompare( getSortVal( rhs.text ) ) ; + + // sort the items + items.sort( function( lhs, rhs ) { + return getSortVal( lhs ).localeCompare( getSortVal( rhs ) ) ; } ) ; + + return items ; } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -302,7 +321,7 @@ function formatItem( opt ) // initialize if ( ! opt.id ) return opt.text ; - var scenario = opt.scenario ; + var scenario = $( opt.element ).data( "scenario" ) ; function addVal( val, className, prefix, postfix ) { if ( val ) { @@ -344,7 +363,7 @@ function formatItem( opt ) // check if this is the first time we're showing search results if ( gIsFirstSearch ) { - $gFooter.fadeTo( 5*1000, 0.8 ) ; + $gFooter.find( ".asa" ).fadeTo( 5*1000, 0.8 ) ; gIsFirstSearch = false ; } diff --git a/vasl_templates/webapp/templates/scenario-search-dialog.html b/vasl_templates/webapp/templates/scenario-search-dialog.html index 1d2e2df..b2a0358 100644 --- a/vasl_templates/webapp/templates/scenario-search-dialog.html +++ b/vasl_templates/webapp/templates/scenario-search-dialog.html @@ -5,10 +5,11 @@