From 08d86e32889b29239fc31d718825a328810b54c4 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 17 Jan 2025 17:54:56 +0100 Subject: [PATCH 01/55] Added ability to read yaml queries file for example_queries command --- src/qlever/commands/example_queries.py | 144 ++++++++++++++++++++----- 1 file changed, 115 insertions(+), 29 deletions(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 6db29a1b..e39a2043 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -7,6 +7,7 @@ import traceback from pathlib import Path +import yaml from termcolor import colored from qlever.command import QleverCommand @@ -52,8 +53,18 @@ def additional_arguments(self, subparser) -> None: subparser.add_argument( "--get-queries-cmd", type=str, - help="Command to get example queries as TSV " - "(description, query)", + help="Command to get example queries as TSV (description, query)", + ) + subparser.add_argument( + "--queries-file", + type=str, + help=( + "Path to a YAML file containing queries. " + "The YAML file should have a top-level " + "key called 'queries', which is a list of dictionaries. " + "Each dictionary should contain 'query' for the query name " + "and 'sparql' for the SPARQL query." + ), ) subparser.add_argument( "--query-ids", @@ -116,7 +127,7 @@ def additional_arguments(self, subparser) -> None: "--width-error-message", type=int, default=80, - help="Width for printing the error message " "(0 = no limit)", + help="Width for printing the error message (0 = no limit)", ) subparser.add_argument( "--width-result-size", @@ -143,6 +154,12 @@ def additional_arguments(self, subparser) -> None: default=False, help="When showing the query, also show the prefixes", ) + subparser.add_argument( + "--generate-output-file", + action="store_true", + default=False, + help="Generate output file in the 'output' directory", + ) def pretty_printed_query(self, query: str, show_prefixes: bool) -> str: remove_prefixes_cmd = ( @@ -174,6 +191,72 @@ def sparql_query_type(self, query: str) -> str: else: return "UNKNOWN" + @staticmethod + def parse_queries_file(queries_file: str) -> dict: + """ + Parse a YAML file and validate its structure. + """ + with open(queries_file, "r", encoding="utf-8") as file: + try: + data = yaml.safe_load(file) # Load YAML safely + except yaml.YAMLError as exc: + log.error(f"Error parsing {queries_file} file: {exc}") + + error_msg = ( + "Error: YAML file must contain a top-level 'queries' key." + "Error: 'queries' must be a list." + "Error: Each item in 'queries' must contain 'query' and 'sparql' keys." + ) + # Validate the structure + if not isinstance(data, dict) or "queries" not in data: + log.error(error_msg) + + if not isinstance(data["queries"], list): + log.error(error_msg) + + for item in data["queries"]: + if ( + not isinstance(item, dict) + or "query" not in item + or "sparql" not in item + ): + log.error(error_msg) + + return data + + def get_example_queries( + self, + queries_file: str | None = None, + get_queries_cmd: str | None = None, + ) -> list[str]: + """ + Get example queries from get_queries_cmd or by reading the yaml file + """ + # yaml file case -> convert to tsv (description \t query) + if queries_file is not None: + queries_data = self.parse_queries_file(queries_file) + queries = queries_data["queries"] + example_query_lines = [ + f"{query['query']}\t{query['sparql']}" for query in queries + ] + return example_query_lines + + # get_queries_cmd case -> run the command + if get_queries_cmd is not None: + # Get the example queries. + try: + example_query_lines = run_command( + get_queries_cmd, return_output=True + ) + if len(example_query_lines) == 0: + return [] + example_query_lines = example_query_lines.splitlines() + return example_query_lines + except Exception as e: + log.error(f"Failed to get example queries: {e}") + return [] + return [] + def execute(self, args) -> bool: # We can't have both `--remove-offset-and-limit` and `--limit`. if args.remove_offset_and_limit and args.limit: @@ -247,18 +330,14 @@ def execute(self, args) -> bool: if args.show: return True - # Get the example queries. - try: - example_query_lines = run_command( - get_queries_cmd, return_output=True - ) - if len(example_query_lines) == 0: - log.error("No example queries matching the criteria found") - return False - example_query_lines = example_query_lines.splitlines() - except Exception as e: - log.error(f"Failed to get example queries: {e}") - return False + example_query_lines = ( + self.get_example_queries(get_queries_cmd=get_queries_cmd) + if args.queries_file is None + else self.get_example_queries(queries_file=args.queries_file) + ) + + if len(example_query_lines) == 0: + log.error("No example queries matching the criteria found") # We want the width of the query description to be an uneven number (in # case we have to truncated it, in which case we want to have a " ... " @@ -401,6 +480,15 @@ def execute(self, args) -> bool: "long": re.sub(r"\s+", " ", str(e)), } + def get_json_error_msg(e: Exception) -> dict[str, str]: + error_msg = { + "short": "Malformed JSON", + "long": "curl returned with code 200, " + "but the JSON is malformed: " + + re.sub(r"\s+", " ", str(e)), + } + return error_msg + # Get result size (via the command line, in order to avoid loading # a potentially large JSON file into Python, which is slow). if error_msg is None: @@ -423,6 +511,16 @@ def execute(self, args) -> bool: result_size = run_command( f"sed 1d {result_file}", return_output=True ) + elif args.accept == "application/qlever-results+json": + try: + # sed cmd to get the number between 2nd and 3rd double_quotes + result_size = run_command( + f"jq '.res[0]' {result_file}" + " | sed 's/[^0-9]*\\([0-9]*\\).*/\\1/'", + return_output=True, + ) + except Exception as e: + error_msg = get_json_error_msg(e) else: try: result_size = run_command( @@ -432,12 +530,7 @@ def execute(self, args) -> bool: return_output=True, ) except Exception as e: - error_msg = { - "short": "Malformed JSON", - "long": "curl returned with code 200, " - "but the JSON is malformed: " - + re.sub(r"\s+", " ", str(e)), - } + error_msg = get_json_error_msg(e) # CASE 2: Downloading the full result (TSV, CSV, Turtle, JSON). else: @@ -467,14 +560,7 @@ def execute(self, args) -> bool: return_output=True, ) except Exception as e: - error_msg = { - "short": "Malformed JSON", - "long": re.sub(r"\s+", " ", str(e)), - } - - # Remove the result file (unless in debug mode). - if args.log_level != "DEBUG": - Path(result_file).unlink(missing_ok=True) + error_msg = get_json_error_msg(e) # Print description, time, result in tabular form. if len(description) > width_query_description: From c08f90ccefa4be24a1aa088da37a63c8dcec0432 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sun, 19 Jan 2025 23:18:49 +0100 Subject: [PATCH 02/55] Replaced PyYaml with ruamel.yaml and added function to write to yaml file in example-queries --- pyproject.toml | 2 +- src/qlever/commands/example_queries.py | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 87238c2c..470a3c2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Database :: Front-Ends" ] -dependencies = [ "psutil", "termcolor", "argcomplete" ] +dependencies = [ "psutil", "termcolor", "argcomplete", "ruamel.yaml" ] [project.urls] Github = "https://github.com/ad-freiburg/qlever" diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index e39a2043..a951dc79 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -7,7 +7,8 @@ import traceback from pathlib import Path -import yaml +from ruamel.yaml import YAML +from ruamel.yaml.scalarstring import LiteralScalarString from termcolor import colored from qlever.command import QleverCommand @@ -196,9 +197,10 @@ def parse_queries_file(queries_file: str) -> dict: """ Parse a YAML file and validate its structure. """ + yaml = YAML(typ="safe") with open(queries_file, "r", encoding="utf-8") as file: try: - data = yaml.safe_load(file) # Load YAML safely + data = yaml.load(file) # Load YAML safely except yaml.YAMLError as exc: log.error(f"Error parsing {queries_file} file: {exc}") @@ -350,6 +352,7 @@ def execute(self, args) -> bool: # processing time (seconds). query_times = [] result_sizes = [] + yaml_records = {"queries": []} num_failed = 0 for example_query_line in example_query_lines: # Parse description and query, and determine query type. @@ -662,3 +665,15 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: # Return success (has nothing to do with how many queries failed). return True + + @staticmethod + def write_query_data_to_yaml( + query_data: dict[str, list[dict[str, Any]]], out_file: str + ) -> None: + """ + Write yaml record for all queries to output yaml file + """ + yaml = YAML() + yaml.default_flow_style = False + with open(out_file, "wb") as yaml_file: + yaml.dump(query_data, yaml_file) From 7e3e40371f2e48fa3b0a0ec586f071774a82a771 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sun, 19 Jan 2025 23:28:35 +0100 Subject: [PATCH 03/55] Generate yaml records by reading the result_file for qlever(qlever-results+json) and not qlever(tsv) in example-queries --- src/qlever/commands/example_queries.py | 114 +++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index a951dc79..79bd5e48 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -1,11 +1,13 @@ from __future__ import annotations +import json import re import shlex import subprocess import time import traceback from pathlib import Path +from typing import Any from ruamel.yaml import YAML from ruamel.yaml.scalarstring import LiteralScalarString @@ -16,6 +18,8 @@ from qlever.log import log, mute_log from qlever.util import run_command, run_curl_command +MAX_RESULT_SIZE = 1000 + class ExampleQueriesCommand(QleverCommand): """ @@ -161,6 +165,16 @@ def additional_arguments(self, subparser) -> None: default=False, help="Generate output file in the 'output' directory", ) + subparser.add_argument( + "--backend-name", + default=None, + help="Name for the backend that would be used in performance comparison", + ) + subparser.add_argument( + "--output-basename", + default=None, + help="Name for the dataset that would be used in performance comparison", + ) def pretty_printed_query(self, query: str, show_prefixes: bool) -> str: remove_prefixes_cmd = ( @@ -340,6 +354,7 @@ def execute(self, args) -> bool: if len(example_query_lines) == 0: log.error("No example queries matching the criteria found") + return False # We want the width of the query description to be an uneven number (in # case we have to truncated it, in which case we want to have a " ... " @@ -614,10 +629,45 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: ) log.info("") + # Get the yaml record if output file needs to be generated + if args.generate_output_file: + result_length = None if error_msg is not None else 1 + result_length = ( + result_size + if args.download_or_count == "download" + and result_length is not None + else result_length + ) + results_for_yaml = ( + error_msg if error_msg is not None else result_file + ) + yaml_record = self.get_record_for_yaml( + query=description, + sparql=self.get_pretty_printed_query(query, True), + client_time=time_seconds, + result=results_for_yaml, + result_size=result_length, + is_qlever=is_qlever, + ) + yaml_records["queries"].append(yaml_record) + + # Remove the result file (unless in debug mode). + if args.log_level != "DEBUG": + Path(result_file).unlink(missing_ok=True) + # Check that each query has a time and a result size, or it failed. assert len(result_sizes) == len(query_times) assert len(query_times) + num_failed == len(example_query_lines) + if len(yaml_records["queries"]) != 0: + outfile = ( + f"{args.output_basename}.{args.backend_name}.results.yaml" + ) + self.write_query_data_to_yaml( + query_data=yaml_records, + out_file=outfile, + ) + # Show statistics. if len(query_times) > 0: n = len(query_times) @@ -666,6 +716,70 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: # Return success (has nothing to do with how many queries failed). return True + def get_record_for_yaml( + self, + query: str, + sparql: str, + client_time: float, + result: str | dict[str, str], + result_size: int | None, + is_qlever: bool, + ) -> dict[str, Any]: + """ + Construct a dictionary with query information for yaml file + """ + record = { + "query": query, + "sparql": LiteralScalarString(sparql), + "runtime_info": {}, + } + if result_size is None: + results = f"{result['short']}: {result['long']}" + headers = [] + else: + result_size = ( + MAX_RESULT_SIZE + if result_size > MAX_RESULT_SIZE + else result_size + ) + headers, results = self.get_query_results( + result, result_size, is_qlever + ) + if is_qlever: + runtime_info_cmd = ( + f"jq 'if .runtimeInformation then" + f" .runtimeInformation else" + f' "null" end\' {result}' + ) + runtime_info_str = run_command( + runtime_info_cmd, return_output=True + ) + if runtime_info_str != "null": + record["runtime_info"] = json.loads(runtime_info_str) + record["runtime_info"]["client_time"] = client_time + record["headers"] = headers + record["results"] = results + return record + + def get_query_results( + self, result_file: str, result_size: int, is_qlever: bool + ) -> tuple[list[str], list[list[str]]]: + """ + Return headers and results as a tuple + """ + if not is_qlever: + get_result_cmd = f"sed -n '1,{result_size + 1}p' {result_file}" + results_str = run_command(get_result_cmd, return_output=True) + results = results_str.splitlines() + headers = [header for header in results[0].split("\t")] + results = [result.split("\t") for result in results[1:]] + return headers, results + else: + get_result_cmd = f"jq '{{headers: .selected, results: .res[0:{result_size}]}}' {result_file}" + results_str = run_command(get_result_cmd, return_output=True) + results_json = json.loads(results_str) + return results_json["headers"], results_json["results"] + @staticmethod def write_query_data_to_yaml( query_data: dict[str, list[dict[str, Any]]], out_file: str From 20ba9ba0cc215a7a073cc79378bc74182fb1867b Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sat, 25 Jan 2025 01:37:28 +0100 Subject: [PATCH 04/55] Incorporate changes to example-queries from extract-queries commit --- src/qlever/commands/example_queries.py | 80 +++++++++++++++++++------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 79bd5e48..17eaadd8 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any +from rdflib import Graph from ruamel.yaml import YAML from ruamel.yaml.scalarstring import LiteralScalarString from termcolor import colored @@ -192,8 +193,7 @@ def pretty_printed_query(self, query: str, show_prefixes: bool) -> str: return query_pretty_printed.rstrip() except Exception: log.error( - "Failed to pretty-print query, " - "returning original query: {e}" + "Failed to pretty-print query, returning original query: {e}" ) return query.rstrip() @@ -207,7 +207,7 @@ def sparql_query_type(self, query: str) -> str: return "UNKNOWN" @staticmethod - def parse_queries_file(queries_file: str) -> dict: + def parse_queries_file(queries_file: str) -> dict[str, list[str, str]]: """ Parse a YAML file and validate its structure. """ @@ -226,9 +226,11 @@ def parse_queries_file(queries_file: str) -> dict: # Validate the structure if not isinstance(data, dict) or "queries" not in data: log.error(error_msg) + return {} if not isinstance(data["queries"], list): log.error(error_msg) + return {} for item in data["queries"]: if ( @@ -237,6 +239,7 @@ def parse_queries_file(queries_file: str) -> dict: or "sparql" not in item ): log.error(error_msg) + return {} return data @@ -251,7 +254,9 @@ def get_example_queries( # yaml file case -> convert to tsv (description \t query) if queries_file is not None: queries_data = self.parse_queries_file(queries_file) - queries = queries_data["queries"] + queries = queries_data.get("queries") + if queries is None: + return [] example_query_lines = [ f"{query['query']}\t{query['sparql']}" for query in queries ] @@ -279,12 +284,21 @@ def execute(self, args) -> bool: log.error("Cannot have both --remove-offset-and-limit and --limit") return False + if args.generate_output_file: + if args.output_basename is None or args.backend_name is None: + log.error( + "Both --output-basename and --backend-name parameters" + " must be passed when --generate-output-file is passed" + ) + return False + args.accept = "AUTO" + # If `args.accept` is `application/sparql-results+json` or # `application/qlever-results+json` or `AUTO`, we need `jq`. - if ( - args.accept == "application/sparql-results+json" - or args.accept == "application/qlever-results+json" - or args.accept == "AUTO" + if args.accept in ( + "application/sparql-results+json", + "application/qlever-results+json", + "AUTO", ): try: subprocess.run( @@ -312,6 +326,8 @@ def execute(self, args) -> bool: not args.sparql_endpoint or args.sparql_endpoint.startswith("https://qlever") ) + if args.generate_output_file: + is_qlever = is_qlever or "qlever" in args.backend_name.lower() if args.clear_cache == "yes" and not is_qlever: log.warning("Clearing the cache only works for QLever") args.clear_cache = "no" @@ -346,6 +362,7 @@ def execute(self, args) -> bool: if args.show: return True + # Get the example queries either from queries_file or get_queries_cmd example_query_lines = ( self.get_example_queries(get_queries_cmd=get_queries_cmd) if args.queries_file is None @@ -455,10 +472,22 @@ def execute(self, args) -> bool: # queries and `application/sparql-results+json` for all others. accept_header = args.accept if accept_header == "AUTO": - if query_type == "CONSTRUCT" or query_type == "DESCRIBE": + if query_type == "DESCRIBE": accept_header = "text/turtle" + elif query_type == "CONSTRUCT": + accept_header = ( + "application/qlever-results+json" + if is_qlever and args.generate_output_file + else "text/turtle" + ) else: accept_header = "application/sparql-results+json" + if args.generate_output_file: + accept_header = ( + "application/qlever-results+json" + if is_qlever + else "text/tab-separated-values" + ) # Launch query. try: @@ -470,8 +499,7 @@ def execute(self, args) -> bool: ) log.debug(curl_cmd) result_file = ( - f"qlever.example_queries.result." - f"{abs(hash(curl_cmd))}.tmp" + f"qlever.example_queries.result.{abs(hash(curl_cmd))}.tmp" ) start_time = time.time() http_code = run_curl_command( @@ -529,7 +557,7 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: result_size = run_command( f"sed 1d {result_file}", return_output=True ) - elif args.accept == "application/qlever-results+json": + elif accept_header == "application/qlever-results+json": try: # sed cmd to get the number between 2nd and 3rd double_quotes result_size = run_command( @@ -643,11 +671,13 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: ) yaml_record = self.get_record_for_yaml( query=description, - sparql=self.get_pretty_printed_query(query, True), + sparql=self.pretty_printed_query( + query, args.show_prefixes + ), client_time=time_seconds, result=results_for_yaml, result_size=result_length, - is_qlever=is_qlever, + accept_header=accept_header, ) yaml_records["queries"].append(yaml_record) @@ -723,7 +753,7 @@ def get_record_for_yaml( client_time: float, result: str | dict[str, str], result_size: int | None, - is_qlever: bool, + accept_header: str, ) -> dict[str, Any]: """ Construct a dictionary with query information for yaml file @@ -743,9 +773,9 @@ def get_record_for_yaml( else result_size ) headers, results = self.get_query_results( - result, result_size, is_qlever + result, result_size, accept_header ) - if is_qlever: + if accept_header == "application/qlever-results+json": runtime_info_cmd = ( f"jq 'if .runtimeInformation then" f" .runtimeInformation else" @@ -762,23 +792,33 @@ def get_record_for_yaml( return record def get_query_results( - self, result_file: str, result_size: int, is_qlever: bool + self, result_file: str, result_size: int, accept_header: str ) -> tuple[list[str], list[list[str]]]: """ Return headers and results as a tuple """ - if not is_qlever: + if accept_header == "text/tab-separated-values": get_result_cmd = f"sed -n '1,{result_size + 1}p' {result_file}" results_str = run_command(get_result_cmd, return_output=True) results = results_str.splitlines() headers = [header for header in results[0].split("\t")] results = [result.split("\t") for result in results[1:]] return headers, results - else: + elif accept_header == "application/qlever-results+json": get_result_cmd = f"jq '{{headers: .selected, results: .res[0:{result_size}]}}' {result_file}" results_str = run_command(get_result_cmd, return_output=True) results_json = json.loads(results_str) return results_json["headers"], results_json["results"] + else: # text/turtle + graph = Graph() + graph.parse(result_file, format="turtle") + headers = ["?subject", "?predicate", "?object"] + results = [] + for i, (s, p, o) in enumerate(graph): + if i >= result_size: + break + results.append([str(s), str(p), str(o)]) + return headers, results @staticmethod def write_query_data_to_yaml( From 714b2d3eec249df160b32960235cd4c676aef756 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Tue, 21 Jan 2025 00:29:30 +0100 Subject: [PATCH 05/55] Add the generated yaml file in example-queries to evaluation/output folder (gitignored) --- .gitignore | 1 + src/qlever/commands/example_queries.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 762a39c5..b15e372d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__ build/ dist/ +src/qlever/evaluation/output/ *.egg-info *.swp diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 17eaadd8..6fa875e0 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -829,5 +829,8 @@ def write_query_data_to_yaml( """ yaml = YAML() yaml.default_flow_style = False - with open(out_file, "wb") as yaml_file: + output_dir = Path(__file__).parent.parent / "evaluation" / "output" + output_dir.mkdir(parents=True, exist_ok=True) + yaml_file_path = output_dir / out_file + with open(yaml_file_path, "wb") as yaml_file: yaml.dump(query_data, yaml_file) From 6a3675f452af7241b58e8192af48fd0c7c9ceecb Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Tue, 21 Jan 2025 00:32:39 +0100 Subject: [PATCH 06/55] Add www_queries_mode folder from qlever-evaluation as www --- src/qlever/evaluation/www/card-template.html | 31 + .../evaluation/www/compare-exec-trees.js | 425 ++++ .../evaluation/www/engines-comparison.js | 394 +++ src/qlever/evaluation/www/helper.js | 238 ++ src/qlever/evaluation/www/index.html | 246 ++ src/qlever/evaluation/www/main.js | 345 +++ src/qlever/evaluation/www/query-details.js | 412 ++++ src/qlever/evaluation/www/treant.css | 56 + src/qlever/evaluation/www/treant.js | 2171 +++++++++++++++++ 9 files changed, 4318 insertions(+) create mode 100644 src/qlever/evaluation/www/card-template.html create mode 100644 src/qlever/evaluation/www/compare-exec-trees.js create mode 100644 src/qlever/evaluation/www/engines-comparison.js create mode 100644 src/qlever/evaluation/www/helper.js create mode 100644 src/qlever/evaluation/www/index.html create mode 100644 src/qlever/evaluation/www/main.js create mode 100644 src/qlever/evaluation/www/query-details.js create mode 100644 src/qlever/evaluation/www/treant.css create mode 100644 src/qlever/evaluation/www/treant.js diff --git a/src/qlever/evaluation/www/card-template.html b/src/qlever/evaluation/www/card-template.html new file mode 100644 index 00000000..3b10385f --- /dev/null +++ b/src/qlever/evaluation/www/card-template.html @@ -0,0 +1,31 @@ + diff --git a/src/qlever/evaluation/www/compare-exec-trees.js b/src/qlever/evaluation/www/compare-exec-trees.js new file mode 100644 index 00000000..7f82f6b8 --- /dev/null +++ b/src/qlever/evaluation/www/compare-exec-trees.js @@ -0,0 +1,425 @@ +// Zoom settings +const baseTreeTextFontSize = 80; +const minimumZoomPercent = 30; +const maximumZoomPercent = 80; +const zoomChange = 10; + +/** + * Sets up event listeners for the execution tree comparison modal. + * - Handles clicks on the "Compare Execution Trees" button to display the comparison modal. + * - Ensures smooth reopening of the query comparison modal, scrolling to the last selected query. + * - Listens for actions to display and zoom in/out of the execution trees. + */ +function setListenersForCompareExecModal() { + // When the modal is hidden + document.querySelector("#compareExecTreeModal").addEventListener("hidden.bs.modal", function () { + // Don't execute any url or state based code when back/forward button clicked + if (document.querySelector("#compareExecTreeModal").getAttribute("pop-triggered")) { + document.querySelector("#compareExecTreeModal").removeAttribute("pop-triggered"); + return; + } + // Display query Comparison modal again and scroll automatically to the last selected query + const kb = document.querySelector("#compareExecTreeModal").getAttribute("data-kb"); + document.querySelector("#comparisonModal").setAttribute("data-kb", kb); + showModal(document.querySelector("#comparisonModal")); + }); + + // Event to create and draw 2 execution trees side-by-side for comparison + document.querySelector("#compareExecTreeModal").addEventListener("shown.bs.modal", function () { + handleCompareExecTrees("modalShow"); + }); + + // Event to handle zoom in/out of execution trees + document.querySelector("#compExecTreeTabContent").addEventListener("click", function (event) { + if (event.target.tagName === "BUTTON") { + const buttonId = event.target.id; + const purpose = buttonId.slice(0, -1); + const buttonClicked = document.querySelector("#" + buttonId); + const tree = buttonClicked.parentNode.parentNode.nextElementSibling; + const treeId = "#" + tree.id; + const currentFontSize = tree.querySelector(".node[class*=font-size-]").className.match(/font-size-(\d+)/)[1]; + // Zoom in and out for both trees when sync option enabled + if (document.getElementById("syncScrollCheck").checked) { + for (let treeId of ["#tree1", "#tree2"]) { + handleCompareExecTrees(purpose, treeId, Number.parseInt(currentFontSize)); + } + } else { + handleCompareExecTrees(purpose, treeId, Number.parseInt(currentFontSize)); + } + } + }); + + // Events to handle drag and scroll horizontally on compareExecTrees page + for (const treeDiv of ["#result-tree", "#tree1", "#tree2"]) { + var isDragging = false; + var initialX = 0; + var initialY = 0; + var currentTreeDiv = null; + + document.querySelector(treeDiv).addEventListener("mousedown", (e) => { + currentTreeDiv = treeDiv; + document.querySelector(currentTreeDiv).style.cursor = "grabbing"; + isDragging = true; + initialX = e.clientX; + initialY = e.clientY; + e.preventDefault(); + }); + document.querySelector(treeDiv).addEventListener("mousemove", (e) => { + if (isDragging) { + const deltaX = e.clientX - initialX; + const deltaY = e.clientY - initialY; + document.querySelector(currentTreeDiv).scrollLeft -= deltaX; + document.querySelector(currentTreeDiv).scrollTop -= deltaY; + if (document.getElementById("syncScrollCheck").checked && treeDiv !== "#result-tree") { + syncScroll(currentTreeDiv); + } + initialX = e.clientX; + initialY = e.clientY; + } + }); + // Sync scrolling and zooming if the option is selected + document.querySelector(treeDiv).addEventListener("scroll", () => { + if (document.getElementById("syncScrollCheck").checked && treeDiv !== "#result-tree") { + syncScroll(treeDiv); + } + }); + document.addEventListener("mouseup", () => { + isDragging = false; + if (document.querySelector(currentTreeDiv)) document.querySelector(currentTreeDiv).style.cursor = "grab"; + currentTreeDiv = null; + }); + } +} + +/** + * Synchronize the scrolling between the 2 compare exec trees if enabled + * @param {string} sourceTree - ID of the tree where the scroll is performed. Can be #tree1 or #tree2 + */ +function syncScroll(sourceTree) { + const sourceDiv = document.querySelector(sourceTree); + + for (const targetTree of ["#tree1", "#tree2"]) { + if (targetTree !== sourceTree) { + const targetDiv = document.querySelector(targetTree); + + // Match scroll position + targetDiv.scrollLeft = sourceDiv.scrollLeft; + targetDiv.scrollTop = sourceDiv.scrollTop; + } + } +} + +/** + * Updates the url with kb and pushes the page to history stack + * Calls the function to display the execution trees for comparison, with options to zoom in, zoom out, or reset zoom. + * @param {string} purpose - Purpose of the display (e.g., "modalShow", "zoomIn", "zoomOut"). + * @param {string} idOfTreeToZoom - ID of the tree element to zoom in/out. + * @param {number} currentFontSize - Current font size of the tree nodes. + */ +async function handleCompareExecTrees(purpose, idOfTreeToZoom, currentFontSize) { + const modalNode = document.querySelector("#compareExecTreeModal"); + const select1 = modalNode.getAttribute("data-s1"); + const select2 = modalNode.getAttribute("data-s2"); + const kb = modalNode.getAttribute("data-kb"); + const queryIndex = Number.parseInt(modalNode.getAttribute("data-qid")); + // Only when modal is shown and not when zoom buttons clicked + if (purpose === "modalShow") { + // If back/forward button, do nothing + if (modalNode.getAttribute("pop-triggered")) { + modalNode.removeAttribute("pop-triggered"); + } + // Else Update the url params and push the page to history stack + else { + const url = new URL(window.location); + url.searchParams.set("page", "compareExecTrees"); + url.searchParams.set("kb", kb); + url.searchParams.set("s1", select1); + url.searchParams.set("s2", select2); + url.searchParams.set("qid", queryIndex); + + const state = { page: "compareExecTrees", kb: kb, s1: select1, s2: select2, qid: queryIndex }; + // If this page is directly opened from url, replace the null state in history stack + if (window.history.state === null) { + window.history.replaceState(state, "", url); + } else { + window.history.pushState(state, "", url); + } + } + } + showCompareExecTrees(purpose, select1, select2, kb, queryIndex, idOfTreeToZoom, currentFontSize); +} + +/** + * Display the execution trees for comparison, with options to zoom in, zoom out, or reset zoom. + * @param {string} select1 - qlever engine version selected in first dropdown + * @param {string} select2 - qlever engine version selected in second dropdown + * @param {string} kb - selected Knowledge Base + * @param {number} queryIndex - array index of the selected query + * @param {string} purpose - Purpose of the display (e.g., "modalShow", "zoomIn", "zoomOut"). + * @param {string} idOfTreeToZoom - ID of the tree element to zoom in/out. + * @param {number} currentFontSize - Current font size of the tree nodes. + */ +function showCompareExecTrees(purpose, select1, select2, kb, queryIndex, idOfTreeToZoom, currentFontSize) { + const qlevers = [select1, select2]; + divIds = ["#engineTree1", "#engineTree2"]; + if (purpose === "modalShow") { + for (let i = 0; i < 2; i++) { + document.querySelector(divIds[i]).innerHTML = qlevers[i]; + } + } + document.querySelector("#runtimeQuery").textContent = + "Query: " + performanceDataPerKb[kb][qlevers[0].toLowerCase()][queryIndex]["Query ID"]; + document.querySelector("#runtimeQuery").title = + performanceDataPerKb[kb][qlevers[0].toLowerCase()][queryIndex]["Query"]; + qlevers.forEach((engine, index) => { + let runtime = performanceDataPerKb[kb][engine.toLowerCase()][queryIndex]["Runtime"].query_execution_tree; + let treeid = "#tree" + (index + 1).toString(); + if (purpose === "modalShow" || idOfTreeToZoom === treeid) { + document.querySelector(treeid).replaceChildren(); + let tree = createExecTree(runtime, treeid); + drawExecTree(tree, treeid, purpose, currentFontSize); + } + }); +} + +/** + * Calculates the depth of a tree structure, where depth is the longest path from the root to any leaf node. + * @param {Object} obj - The tree node or root object. + * @returns {number} - The depth of the tree. + */ +function calculateTreeDepth(obj) { + // Base case: if the object has no children, return 1 + if (!obj.children || obj.children.length === 0) { + return 1; + } + // Initialize maxDepth to track the maximum depth + let maxDepth = 0; + // Calculate depth for each child and find the maximum depth + obj.children.forEach((child) => { + const depth = calculateTreeDepth(child); + maxDepth = Math.max(maxDepth, depth); + }); + // Return maximum depth + 1 (to account for the current node) + return maxDepth + 1; +} + +/** + * Determines the font size for a tree visualization based on its depth, ensuring text is appropriately sized. + * @param {number} fontSize - The base font size. + * @param {number} depth - The depth of the tree. + * @returns {number} - The adjusted font size. + */ +function getFontSizeForDepth(fontSize, depth) { + // If depth is greater than 4, reduce font size by 10 for each increment beyond 4 + if (depth > 4) { + fontSize -= (depth - 4) * zoomChange; + } + // Ensure font size doesn't go below 30 + fontSize = Math.max(fontSize, minimumZoomPercent); + return fontSize; +} + +/** + * Calculates the new font size for tree nodes based on the purpose (zoom in/out or modal show) and current size. + * @param {Object} tree - The tree structure object. + * @param {string} purpose - The purpose of font size adjustment (e.g., "modalShow", "zoomIn", "zoomOut"). + * @param {number} currentFontSize - The current font size of the tree nodes. + * @returns {number} - The new font size. + */ +function getNewFontSizeForTree(tree, purpose, currentFontSize) { + let treeDepth; + let newFontSize = currentFontSize ? currentFontSize : maximumZoomPercent; + if (purpose === "modalShow") { + treeDepth = calculateTreeDepth(tree.nodeStructure); + newFontSize = getFontSizeForDepth(baseTreeTextFontSize, treeDepth); + } else if (purpose === "zoomIn" && currentFontSize < maximumZoomPercent) { + newFontSize += zoomChange; + } else if (purpose === "zoomOut" && currentFontSize > minimumZoomPercent) { + newFontSize -= zoomChange; + } + return newFontSize; +} + +/** + * Generates an execution tree structure for visualization based on the runtime information. + * @param {Object} runtime - The runtime information containing the tree structure. + * @param {string} treeid - The ID of the HTML element where the tree will be rendered. + * @returns {Object} The tree structure ready for rendering with Treant.js. + */ +function createExecTree(runtime, treeid) { + try { + runtimeInfoForTreant(runtime); + let tree = treeid; + let treant_compare_tree = { + chart: { + container: tree, + rootOrientation: "NORTH", + connectors: { type: "step" }, + node: { HTMLclass: "font-size-" + maximumZoomPercent }, + }, + nodeStructure: runtime, + }; + return treant_compare_tree; + } catch (error) { + console.error("CreateExecTree error: ", error); + return {}; + } +} + +/** + * Draws the execution tree in the specified HTML container, applying zoom settings and highlighting nodes based on performance. + * + * @param {Object} treant_tree - The tree structure generated by Treant.js. + * @param {string} treeid - The ID of the HTML container where the tree is displayed. + * @param {string} purpose - The reason for drawing the tree ('modalShow', 'zoomIn', 'zoomOut'). + * @param {number} [currentFontSize] - The current font size of the tree nodes (optional). + */ +function drawExecTree(treant_tree, treeid, purpose, currentFontSize) { + if (treant_tree && Object.keys(treant_tree).length !== 0) { + const newFontSize = getNewFontSizeForTree(treant_tree, purpose, currentFontSize); + treant_tree.chart.node.HTMLclass = "font-size-" + newFontSize.toString(); + new Treant(treant_tree); + // Highlight node with high query times: cached -> yellow or light yellow, not + // cached -> red or light red. Also grey out cached nodes. + $("p.node-time") + .filter(function () { + return $(this).html() >= high_query_time_ms; + }) + .parent() + .addClass("high"); + $("p.node-time") + .filter(function () { + return $(this).html() >= very_high_query_time_ms; + }) + .parent() + .addClass("veryhigh"); + $("p.node-cached") + .filter(function () { + return $(this).html() == "true"; + }) + .parent() + .addClass("cached"); + document.querySelector(treeid).lastChild.scrollIntoView({ block: "nearest", inline: "center" }); + } +} + +/** + * Transforms runtime information into a format compatible with Treant.js for creating hierarchical execution trees. + * Propagates cached status through the tree nodes. + * + * @param {Object} runtime_info - The runtime information containing the query execution tree details. + * @param {boolean} [parent_cached=false] - Whether the parent node was cached (used to propagate caching status). + */ +function runtimeInfoForTreant(runtime_info, parent_cached = false) { + // Create text child with the information we want to see in the tree. + if (runtime_info["text"] == undefined) { + var text = {}; + if (runtime_info["column_names"] == undefined) { + runtime_info["column_names"] = ["not yet available"]; + } + text["name"] = runtime_info["description"] + .replace(/<.*[#\/\.](.*)>/, "<$1>") + .replace(/qlc_/g, "") + .replace(/\?[A-Z_]*/g, function (match) { + return match.toLowerCase(); + }) + .replace(/([a-z])([A-Z])/g, "$1-$2") + .replace(/^([a-zA-Z-])*/, function (match) { + return match.toUpperCase(); + }) + .replace(/([A-Z])-([A-Z])/g, "$1 $2") + .replace(/AVAILABLE /, "") + .replace(/a all/, "all"); + text["size"] = format(runtime_info["result_rows"]) + " x " + format(runtime_info["result_cols"]); + text["cols"] = runtime_info["column_names"] + .join(", ") + .replace(/qlc_/g, "") + .replace(/\?[A-Z_]*/g, function (match) { + return match.toLowerCase(); + }); + text["time"] = runtime_info["was_cached"] + ? runtime_info["details"]["original_operation_time"] + : runtime_info["operation_time"]; + text["total"] = text["time"]; + text["cached"] = parent_cached == true ? true : runtime_info["was_cached"]; + if (typeof text["cached"] != "boolean") { + text["cached"] = false; + } + // Save the original was_cached flag, before it's deleted, for use below. + for (var key in runtime_info) { + if (key != "children") { + delete runtime_info[key]; + } + } + runtime_info["text"] = text; + runtime_info["stackChildren"] = true; + + // Recurse over all children, propagating the was_cached flag from the + // original runtime_info to all nodes in the subtree. + runtime_info["children"].map((child) => runtimeInfoForTreant(child, text["cached"])); + // If result is cached, subtract time from children, to get the original + // operation time (instead of the original time for the whole subtree). + if (text["cached"]) { + runtime_info["children"].forEach(function (child) { + // text["time"] -= child["text"]["total"]; + }); + } + } +} + +/** + * Generates and displays the execution tree for a given query or retrieves it based on the selected engine and knowledge base. + * Handles rendering the tree in the modal's content. + * + * @param {Object} queryRow - The selected query's runtime information. + * @param {string} [purpose] - The reason for generating the tree ('modalShow', 'zoomIn', 'zoomOut'). + * @param {string} [treeid] - The ID of the tree container (optional). + * @param {number} [currentFontSize] - The current font size of the tree nodes (optional). + */ +function generateExecutionTree(queryRow, purpose, treeid, currentFontSize) { + if (queryRow === null && purpose !== undefined) { + const kb = document + .querySelector("#queryDetailsModal") + .querySelector("#runtimes-tab-pane") + .querySelector(".card-title") + .textContent.substring("Knowledge Graph - ".length) + .toLowerCase(); + const engine = document + .querySelector("#queryDetailsModal") + .querySelector(".modal-title") + .textContent.substring("SPARQL Engine - ".length) + .toLowerCase(); + const queryIndex = document.querySelector("#queryList").querySelector(".table-active").rowIndex - 1; + let runtime = performanceDataPerKb[kb][engine][queryIndex]["Runtime"].query_execution_tree; + document.querySelector(treeid).replaceChildren(); + let tree = createExecTree(runtime, treeid); + drawExecTree(tree, treeid, purpose, currentFontSize); + return; + } + if (!queryRow.runtime_info || !Object.hasOwn(queryRow.runtime_info, "query_execution_tree")) { + document.getElementById("tab3Content").replaceChildren(); + document.getElementById("result-tree").replaceChildren(); + if (queryRow.result) { + document + .querySelector("#tab3Content") + .replaceChildren(document.createTextNode("Execution tree not available for this engine!")); + } else { + document + .querySelector("#tab3Content") + .replaceChildren(document.createTextNode("No SPARQL results available for this query!")); + } + return; + } + document.getElementById("tab3Content").replaceChildren(); + document.getElementById("result-tree").replaceChildren(); + const exec_tree_tab = document.querySelector("#exec-tree-tab"); + exec_tree_tab.addEventListener( + "shown.bs.tab", + function () { + const runtime = queryRow.runtime_info.query_execution_tree; + const treant_tree = createExecTree(runtime, "#result-tree"); + drawExecTree(treant_tree, "#result-tree", "modalShow"); + }, + { once: true } + ); +} diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js new file mode 100644 index 00000000..e221f245 --- /dev/null +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -0,0 +1,394 @@ +/** + * Sets event listeners for the Engines Comparison Modal + * + * - Listens for click events to hide selected queries, reset them or open CompareExecTrees Modal + * - Selects the previously selected row again and scrolls to it when user comes back + */ +function setListenersForEnginesComparison() { + document.getElementById("hideSelectedButton").addEventListener("click", hideSelectedButtonClicked); + document.getElementById("resetButton").addEventListener("click", resetButtonClicked); + document.getElementById("comparisonModal").addEventListener("hide.bs.modal", comparisonModalHidden); + + // Event to display compareExecTree modal with Exec tree comparison for selected engines + document.querySelector("#compareExecTrees").addEventListener("click", (event) => { + event.preventDefault(); + compareExecutionTreesClicked(); + }); + + // Before the modal is shown, update the url and history Stack and remove the previous table + document.querySelector("#comparisonModal").addEventListener("show.bs.modal", async function () { + const kb = document.querySelector("#comparisonModal").getAttribute("data-kb"); + if (kb) { + // If back/forward button, do nothing + if (document.querySelector("#comparisonModal").getAttribute("pop-triggered")) { + document.querySelector("#comparisonModal").removeAttribute("pop-triggered"); + } + // Else Update the url params and push the page to history stack + else { + const url = new URL(window.location); + url.search = ""; + console.log(url.searchParams); + url.searchParams.set("page", "comparison"); + url.searchParams.set("kb", kb); + const state = { page: "comparison", kb: kb }; + // If this page is directly opened from url, replace the null state in history stack + if (window.history.state === null) { + window.history.replaceState(state, "", url); + } else { + window.history.pushState(state, "", url); + } + } + + // Open the previously opened modal without any processing overhead if kb is the same + if (document.querySelector("#comparisonKb").innerHTML === kb) { + return; + } + // Else clear the table with queries and engines runtimes before the modal is shown + else { + const tableContainer = document.getElementById("table-container"); + tableContainer.replaceChildren(); + document.querySelector("#comparisonKb").innerHTML = ""; + } + } + }); + + // After the modal is shown, populate the modal based on the selected kb + document.querySelector("#comparisonModal").addEventListener("shown.bs.modal", async function () { + const kb = document.querySelector("#comparisonModal").getAttribute("data-kb"); + if (kb) { + await openComparisonModal(kb); + } + // Scroll to the previously selected row if user is coming back to this modal + const activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); + if (activeRow) { + activeRow.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "center", + }); + } + }); + + // Handle the modal's `hidden.bs.modal` event + document.querySelector("#comparisonModal").addEventListener("hidden.bs.modal", function () { + // Don't execute any url or state based code when back/forward button clicked + if (document.querySelector("#comparisonModal").getAttribute("pop-triggered")) { + document.querySelector("#comparisonModal").removeAttribute("pop-triggered"); + return; + } + // Case: Modal was hidden as a result of clicking on compare execution trees button + if (document.querySelector("#comparisonModal").getAttribute("compare-exec-clicked")) { + const modalNode = document.querySelector("#compareExecTreeModal"); + + // Set kb, selected sparql engines and query attributes and show compareExecTreeModal + const kb = document.querySelector("#comparisonModal").getAttribute("data-kb"); + const select1 = document.querySelector("#select1").value; + const select2 = document.querySelector("#select2").value; + const queryIndex = document.querySelector("#comparisonModal .table-active").rowIndex - 1; + + modalNode.setAttribute("data-kb", kb); + modalNode.setAttribute("data-s1", select1); + modalNode.setAttribute("data-s2", select2); + modalNode.setAttribute("data-qid", queryIndex); + + document.querySelector("#comparisonModal").removeAttribute("compare-exec-clicked"); + showModal(document.querySelector("#compareExecTreeModal")); + } + // Case: Modal was closed as result of clicking on the close button + else { + // Navigate to the main page and update the url + const url = new URL(window.location); + url.searchParams.delete("page"); + url.searchParams.delete("kb"); + window.history.pushState({ page: "main" }, "", url); + } + }); +} + +/** + * Displays the execution tree comparison modal for the selected query. + * Alerts the user if no query is selected. + */ +function compareExecutionTreesClicked() { + const activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); + if (!activeRow) { + alert("Please select a query from the table!"); + return; + } + document.querySelector("#comparisonModal").setAttribute("compare-exec-clicked", true); + const compareResultsmodal = bootstrap.Modal.getInstance(document.querySelector("#comparisonModal")); + compareResultsmodal.hide(); +} + +/** + * Handle event when compare results button is clicked on the main page + * @param kb name of the knowledge base + * Display the modal page where all the engine runtimes are compared against each other on a per query basis + */ +function handleCompareResultsClick(kb) { + document.querySelector("#comparisonModal").setAttribute("data-kb", kb); + showModal(document.querySelector("#comparisonModal")); +} + +/** + * - Updates the url with kb and pushes the page to history stack + * - Fetches the query log and results based on the selected knowledge base (KB) and engine. + * - Updates the modal content and displays the query details. + * - Manages the state of the query execution tree and tab content. + * + * @async + * @param {string} kb - The selected knowledge base + */ +async function openComparisonModal(kb) { + // Open the previously opened modal without any processing overhead if kb is the same + if (document.querySelector("#comparisonKb").innerHTML === kb) { + return; + } + + showSpinner(); + document.querySelector("#comparisonKb").innerHTML = kb; + const tableContainer = document.getElementById("table-container"); + // Populate the dropdowns with qlever engines for execution tree comparison + let select1 = document.querySelector("#select1"); + let select2 = document.querySelector("#select2"); + select1.innerHTML = ""; + select2.innerHTML = ""; + for (let engine of sparqlEngines) { + await addRuntimeToPerformanceDataPerKb(kb, engine); + if (performanceDataPerKb[kb].hasOwnProperty(engine) && execTreeEngines.includes(engine)) { + select1.add(new Option(engine)); + select2.add(new Option(engine)); + } + } + // If only 1 or less qlever engine, hide compare execution trees button + if (select1.options.length <= 1) { + document.querySelector("#compareExecDiv").classList.add("d-none"); + } else { + document.querySelector("#compareExecDiv").classList.remove("d-none"); + // By default show the first and second options when 2 or more options available + select1.selectedIndex = 0; + select2.selectedIndex = 1; + } + + // If there is a previously saved state for the runtimes comparison table, fetch that and show it. + // Also re-attach the click event listener to all the rows that were lost when the table state was saved + if (comparisonTables.hasOwnProperty(kb) && comparisonTables[kb]) { + tableContainer.innerHTML = comparisonTables[kb]; + const rows = tableContainer.querySelectorAll("tbody tr"); + for (const row of rows) { + row.addEventListener("click", function () { + let activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); + if (activeRow) activeRow.classList.remove("table-active"); + row.classList.add("table-active"); + }); + } + } else { + // Create a DocumentFragment to build the table + const fragment = document.createDocumentFragment(); + const table = createCompareResultsTable(kb); + + fragment.appendChild(table); + + // Append the table to the container in a single operation + tableContainer.appendChild(fragment); + } + + $("#table-container table").tablesorter({ + theme: "bootstrap", + sortStable: true, + sortInitialOrder: "desc", + }); + hideSpinner(); +} + +/** + * - Fetch the yaml file query logs for the selected kb and engine + * - Add full sparql query and runtime info (including execution tree) to PerformanceDataPerKb + * + * @async + * @param {string} kb - The selected knowledge base + * @param {string} engine - The selected sparql engine + */ +async function addRuntimeToPerformanceDataPerKb(kb, engine) { + let queryResult = null; + if ( + performanceDataPerKb[kb][engine.toLowerCase()]?.length && + !performanceDataPerKb[kb][engine.toLowerCase()][0].hasOwnProperty("Query") + ) { + const queryLog = getQueryLog(engine.toLowerCase(), kb); + queryResult = await getYamlData(queryLog); + if (!queryResult) queryResult = []; + if (queryResult && !execTreeEngines.includes(engine)) { + for (query of queryResult) { + if (Array.isArray(query.result) && query.result.length > 0) { + if (query.runtime_info.hasOwnProperty("query_execution_tree") && !execTreeEngines.includes(engine)) { + execTreeEngines.push(engine); + } + break; + } + } + } + for (i = 0; i < queryResult.length; i++) { + performanceDataPerKb[kb][engine.toLowerCase()][i]["Query"] = queryResult[i].sparql; + performanceDataPerKb[kb][engine.toLowerCase()][i]["Runtime"] = queryResult[i].runtime_info; + } + } +} + +/** + * Uses performanceDataPerKb object to create the engine runtime for each query comparison table + * Gives the user the ability to selectively hide queries to reduce the clutter + * @param kb Name of the knowledge base for which to get engine runtimes + * @return HTML table with queries as rows and engine runtimes as columns + */ +function createCompareResultsTable(kb) { + let queryCount = 0; + const table = document.createElement("table"); + table.classList.add("table", "table-hover", "table-bordered", "w-auto"); + + // Create the table header row + const thead = document.createElement("thead"); + const headerRow = document.createElement("tr"); + headerRow.title = + "Click on a column to sort it in descending or ascending order. Sort multiple columns simultaneously by holding down the Shift key and clicking a second, third or even fourth column header!"; + + headerRow.innerHTML = ` + + + + `; + // Create dynamic headers and add them to the header row + headerRow.innerHTML += "Query"; + const engines = Object.keys(performanceDataPerKb[kb]); + let engineIndexForQueriesList = 0; + for (let i = 0; i < engines.length; i++) { + if (performanceDataPerKb[kb][engines[i]].length > queryCount) { + queryCount = performanceDataPerKb[kb][engines[i]].length; + engineIndexForQueriesList = i; + } + headerRow.innerHTML += `${engines[i]}`; + } + + thead.appendChild(headerRow); + table.appendChild(thead); + + // Create the table body and add rows and cells + const tbody = document.createElement("tbody"); + for (let i = 0; i < queryCount; i++) { + const row = document.createElement("tr"); + row.innerHTML = ` + + + + `; + row.style.cursor = "pointer"; + const title = performanceDataPerKb[kb][engines[engineIndexForQueriesList]][i]["Query"]; + row.innerHTML += `${ + performanceDataPerKb[kb][engines[engineIndexForQueriesList]][i]["Query ID"] + }`; + for (let engine of engines) { + const result = performanceDataPerKb[kb][engine][i]; + if (!result) { + row.innerHTML += "N/A"; + continue; + } + let runtime = result["Total Client Time (ms)"]; + let resultClass = result["Query Failed"] === "True" ? "bg-danger bg-opacity-25" : ""; + let runtimeText = runtime != "" ? `${formatNumber(parseFloat(runtime) / 1000)} s` : "Failed"; + row.innerHTML += `${runtimeText}`; + } + row.addEventListener("click", function () { + let activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); + if (activeRow) activeRow.classList.remove("table-active"); + row.classList.add("table-active"); + }); + tbody.appendChild(row); + } + table.appendChild(tbody); + + // Add event listeners for header and row checkboxes + + // If the header is checked, all the rows must be checked and vice-versa + thead.addEventListener("change", function (event) { + const headerCheckbox = event.target; + const rowCheckboxes = tbody.querySelectorAll(".row-checkbox"); + rowCheckboxes.forEach((checkbox) => { + checkbox.checked = headerCheckbox.checked; + }); + }); + + // Update the checker status of header based on if all rows are selected or not + tbody.addEventListener("change", function () { + const headerCheckbox = thead.querySelector("#selectAll"); + const rowCheckboxes = tbody.querySelectorAll(".row-checkbox"); + const allChecked = Array.from(rowCheckboxes).every((checkbox) => checkbox.checked); + const noneChecked = Array.from(rowCheckboxes).every((checkbox) => !checkbox.checked); + headerCheckbox.checked = allChecked; + headerCheckbox.indeterminate = !allChecked && !noneChecked; + }); + + return table; +} + +/** + * Hide rows in the table where checkboxes are selected. + * Ensures the "select all" header checkbox is not checked and prevents hiding all rows. + * Alerts the user if an attempt is made to hide all rows of the table. + */ +function hideSelectedButtonClicked() { + const table = document.querySelector("#table-container table"); + const checkboxes = table.querySelectorAll("tbody .row-checkbox:checked"); // Selected checkboxes + const headerCheckbox = table.querySelector("#selectAll"); // Header selectAll checkbox + + if (headerCheckbox.checked) { + alert("Cannot hide all the rows of the table!"); + return; + } + + // Hide rows with checked checkboxes + checkboxes.forEach((checkbox) => { + const row = checkbox.closest("tr"); + row.style.display = "none"; // Hide the row + row.classList.remove("table-active"); + }); + + headerCheckbox.checked = false; + headerCheckbox.indeterminate = false; +} + +/** + * Reset the table to its default state. + * Makes all rows visible, unchecks all checkboxes, and ensures the "select all" header checkbox is cleared. + */ +function resetButtonClicked() { + const table = document.querySelector("#table-container table"); + const allRows = table.querySelectorAll("tbody tr"); + const allCheckboxes = table.querySelectorAll('input[type="checkbox"]'); + + // Reset all rows to be visible + allRows.forEach((row) => { + row.style.display = ""; // Make the row visible + }); + + // Uncheck all checkboxes + allCheckboxes.forEach((checkbox) => { + checkbox.checked = false; + }); + + // Reset the header checkbox + const headerCheckbox = table.querySelector("#selectAll"); + if (headerCheckbox) { + headerCheckbox.checked = false; + headerCheckbox.indeterminate = false; + } +} + +/** + * Cache the HTML of the comparison table when the comparison modal is hidden. + * Saves the table's current state in the `comparisonTables` object using the knowledge base (`kb`) as a key. + */ +function comparisonModalHidden() { + const table = document.querySelector("#table-container table"); + const kb = document.querySelector("#comparisonKb").innerHTML; + comparisonTables[kb] = table.outerHTML; +} diff --git a/src/qlever/evaluation/www/helper.js b/src/qlever/evaluation/www/helper.js new file mode 100644 index 00000000..2c4a823a --- /dev/null +++ b/src/qlever/evaluation/www/helper.js @@ -0,0 +1,238 @@ +// Important Global variables +var sparqlEngines = []; +var execTreeEngines = []; +var kbs = []; +var outputUrl = window.location.pathname.replace("www", "output"); +var performanceDataPerKb = {}; +var comparisonTables = {}; + +var high_query_time_ms = 200; +var very_high_query_time_ms = 1000; + +/** + * Formats a number to include commas as thousands separators and ensures exactly two decimal places. + * + * @param {number} number - The number to format. + * @returns {string} The formatted number as a string. + */ +function formatNumber(number) { + return number.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +/** + * Formats a number to include commas as thousands separators without ensuring decimal places. + * + * @param {number} number - The number to format. + * @returns {string} The formatted number as a string with commas as thousands separators. + */ +function format(number) { + return number.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,"); +} + +/** + * Generates the file URL for the query log yaml based on the given SPARQL engine and knowledge base. + * + * @param {string} engine - The SPARQL engine (e.g., 'qlever'). + * @param {string} kb - The knowledge base (e.g., 'sp2b'). + * @returns {string} The file URL for the query log. + */ +function getQueryLog(engine, kb) { + return `${kb}.${engine}.queries.executed.yml`; +} + +/** + * Generates the file URL for the eval log tsv based on the given SPARQL engine and knowledge base. + * + * @param {string} engine - The SPARQL engine (e.g., 'qlever'). + * @param {string} kb - The knowledge base (e.g., 'sp2b'). + * @returns {string} The file URL for the eval log. + */ +function getEvalLog(engine, kb) { + return `${kb}.${engine}.queries.results.tsv`; +} + +/** + * Generates the file URL for the fail log txt based on the given SPARQL engine and knowledge base. + * + * @param {string} engine - The SPARQL engine (e.g., 'qlever'). + * @param {string} kb - The knowledge base (e.g., 'sp2b'). + * @returns {string} The file URL for the failure log. + */ +function getFailLog(engine, kb) { + return `${kb}.${engine}.queries.fail.txt`; +} + +/** + * Fetches and processes a YAML file from a specified URL. + * + * @async + * @param {string} yamlFileUrl - The URL of the YAML file to fetch. + * @returns {Promise} A promise that resolves to an array of objects parsed from the YAML file. + * Will log an error if fetching or processing the YAML file fails. + */ +async function getYamlData(yamlFileUrl) { + // Fetch the YAML file and process its content + try { + const response = await fetch(outputUrl + yamlFileUrl); + const yamlContent = await response.text(); + // Split the content into rows + const data = jsyaml.loadAll(yamlContent); + return data; + } catch (error) { + console.error("Error fetching or processing YAML file:", error); + } +} + +/** + * Processes the content of a TSV file and calculates various statistics. + * + * @param {string} tsvContent - The content of the TSV file as a string. + * @returns {Object} An object containing parsed data, statistical metrics, and query performance analysis. + * Will log an error if processing the TSV content fails. + */ +function getTsvData(tsvContent) { + // Fetch the TSV file and process its content + try { + // Split the content into rows + const rows = tsvContent.replace(/\r/g, "").trim().split("\n"); + + // Parse the header row + const headers = rows[0].split("\t"); + + // Initialize an array to hold data objects + const data = []; + const result = {}; + + let queryTimeArray = []; + let totalTime = 0; + let queries_under_1s = 0; + let queries_over_5s = 0; + let failed_queries = 0; + // Iterate through the remaining rows + for (let i = 1; i < rows.length; i++) { + const values = rows[i].split("\t"); + const entry = {}; + + // Create an object with headers as keys and values as values + for (let j = 0; j < headers.length; j++) { + entry[headers[j]] = values[j]; + } + let query_time = parseFloat(values[2]); + queryTimeArray.push(query_time); + totalTime += query_time; + if (values[3] == "True") { + failed_queries++; + } else { + if (query_time < 1000) { + queries_under_1s++; + } + if (query_time > 5000) { + queries_over_5s++; + } + } + + data.push(entry); + } + + result.data = data; // The array of parsed data objects. + result.avgTime = totalTime / (rows.length - 1) / 1000; // The average query time in seconds. + result.medianTime = median(queryTimeArray) / 1000; //The median query time in seconds. + // Percentage of queries executed under 1 second. + result.under_1s = (queries_under_1s / (rows.length - 1)) * 100; + // Percentage of queries executed over 5 seconds. + result.over_5s = (queries_over_5s / (rows.length - 1)) * 100; + result.failed = (failed_queries / (rows.length - 1)) * 100; // Percentage of failed queries. + // Percentage of queries executed between 1 and 5 seconds. + result.between_1_to_5s = 100 - result.under_1s - result.over_5s - result.failed; + return result; + } catch (error) { + console.error("Error processing TSV file:", error); + } +} + +/** + * Processes the content of a TXT file and returns its lines as an array of strings. + * + * @param {string} txtContent - The content of the TXT file as a string. + * @returns {string[]} An array of strings representing each line in the TXT file. + * Will log an error if processing the TXT content fails. + */ +function getTxtData(txtContent) { + // Fetch the TSV file and process its content + try { + // Split the content into rows + if (!txtContent) { + return []; + } + const rows = txtContent.replace(/\r/g, "").trim().split("\n"); + return rows; + } catch (error) { + console.error("Error processing TXT file:", error); + } +} + +/** + * Displays the loading spinner by updating the relevant CSS classes. + */ +function showSpinner() { + document.querySelector("#spinner").classList.remove("d-none", "d-flex"); + document.querySelector("#spinner").classList.add("d-flex"); +} + +/** + * Hides the loading spinner by updating the relevant CSS classes. + */ +function hideSpinner() { + document.querySelector("#spinner").classList.remove("d-none", "d-flex"); + document.querySelector("#spinner").classList.add("d-none"); +} + +/** + * Calculates the median value from an array of numbers. + * + * @param {number[]} values - An array of numbers. + * @returns {number} The median value, or -1 if the array is empty. + */ +function median(values) { + if (values.length === 0) { + return -1; + } + + values = [...values].sort((a, b) => a - b); + + const half = Math.floor(values.length / 2); + + return values.length % 2 ? values[half] : (values[half - 1] + values[half]) / 2; +} + +/** + * Set multiple attributes on a given DOM node dynamically. + * Simplifies setting multiple attributes at once by iterating over a key-value pair object. + * @param {HTMLElement} node - The DOM node on which attributes will be set. + * @param {Object} attributes - An object containing key-value pairs of attributes to set. + */ +function setAttributes(node, attributes) { + if (node) { + for (const [key, value] of Object.entries(attributes)) { + node.setAttribute(key, value); + } + } +} + +/** + * Display a Bootstrap modal and optionally set attributes dynamically. + * Ensures the modal is shown and adds a custom `pop-triggered` attribute for tracking purposes. + * @param {HTMLElement} modalNode - The DOM node representing the modal to be shown. + * @param {Object} [attributes={}] - Optional attributes to set on the modal before showing it. + * @param {boolean} fromPopState - Is the modal being shown when pop state event is fired. + */ +function showModal(modalNode, attributes = {}, fromPopState = false) { + if (modalNode) { + setAttributes(modalNode, attributes); + if (fromPopState) { + modalNode.setAttribute("pop-triggered", true); + } + const modal = bootstrap.Modal.getOrCreateInstance(modalNode); + modal.show(); + } +} diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html new file mode 100644 index 00000000..e5b715d8 --- /dev/null +++ b/src/qlever/evaluation/www/index.html @@ -0,0 +1,246 @@ + + + + + SPARQL Engine Comparison + + + + + + + + + + + + + + + + + + + + + + +
+

SPARQL Engine Comparison


+
+ +
+
+ Loading... +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js new file mode 100644 index 00000000..36daf1ba --- /dev/null +++ b/src/qlever/evaluation/www/main.js @@ -0,0 +1,345 @@ +/** + * Create a bootstrap card with engine metrics from cardTemplate for the main page + * @param cardTemplate cardTemplate document node + * @param kb name of the knowledge base + * @param data metrics for each SPARQL engine for the given knowledge base + * @return A bootstrap card displaying SPARQL Engine metrics for the given kb + */ +function populateCard(cardTemplate, kb, data) { + const clone = document.importNode(cardTemplate.content, true); + const cardTitle = clone.querySelector("h5"); + clone.querySelector("button").addEventListener("click", handleCompareResultsClick.bind(null, kb)); + cardTitle.innerHTML = kb[0].toUpperCase() + kb.slice(1); + const cardBody = clone.querySelector("tbody"); + + data.forEach((engine) => { + const row = document.createElement("tr"); + row.style.cursor = "pointer"; + row.addEventListener("click", handleRowClick); + row.innerHTML = ` + ${engine.engine} + ${engine.failed}% + ${ + engine.avgRuntime ? formatNumber(parseFloat(engine.avgRuntime)) : "N/A" + } + ${ + engine.medianRuntime ? formatNumber(parseFloat(engine.medianRuntime)) : "N/A" + } + ${ + engine.under_1s ? formatNumber(parseFloat(engine.under_1s)) + "%" : "N/A" + } + ${ + engine.between_1_to_5s ? formatNumber(parseFloat(engine.between_1_to_5s)) + "%" : "N/A" + } + ${ + engine.over_5s ? formatNumber(parseFloat(engine.over_5s)) + "%" : "N/A" + } + `; + cardBody.appendChild(row); + }); + return clone; +} + +/** + * Get urls for all the eval(tsv) and fail(txt) data + * @param fileList Array of file names in the output directory + * @return Array of eval and fail logs for each kb and engine combination + */ +function getFileUrls(fileList) { + const fileUrls = []; + const kb_engine_map = {}; + + for (let file of fileList) { + const parts = file.split("."); + if (parts.length === 5 && parts[2] === "queries") { + const kb = parts[0]; + const engine = parts[1]; + + if (!kb_engine_map[kb]) { + kb_engine_map[kb] = []; + } + + if (!kb_engine_map[kb].includes(engine)) { + kb_engine_map[kb].push(engine); + } + } + } + + for (let kb of kbs) { + performanceDataPerKb[kb.toLowerCase()] = {}; + for (let engine of sparqlEngines) { + if (kb_engine_map[kb].includes(engine)) { + const evalLog = getEvalLog(engine.toLowerCase(), kb.toLowerCase()); + const failLog = getFailLog(engine.toLowerCase(), kb.toLowerCase()); + fileUrls.push(evalLog); + fileUrls.push(failLog); + } + } + } + return fileUrls; +} + +/** + * Create promises out of eval and fail logs and return the results + * @param fileUrls fileUrls array from getFileUrls function + * @return Promise that is reolved with array of results of fetching eval and fail logs + * @async + */ +async function fetchAndProcessFiles(fileUrls) { + const fetchPromises = fileUrls.map(async (url) => { + try { + const response = await fetch(outputUrl + url, { + headers: { + "Cache-Control": "no-cache", + }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}`); + } + const content = await response.text(); + console.log(`File ${url} content: ${content}`); + // Process the content as needed + return { status: "fulfilled", value: content }; + } catch (error) { + console.error(`Error fetching ${url}: ${error.message}`); + // Handle the error gracefully + return { status: "rejected", reason: error.message }; + } + }); + + const results = await Promise.allSettled(fetchPromises); + return results; // Return the results to be accessed later +} + +/** + * Fetch all the relevant metrics required by + * populateCard function and construct and display all cards with engine metrics on the main page. + * @param results Array withresults from fetching eval and fail logs + * @param fileUrls fileUrls array from getFileUrls function + * @param fragment Document fragment to which to append the bootstrap cards + * @param cardTemplate cardTemplate document node + */ +function processAndDisplayResults(results, fileUrls, fragment, cardTemplate) { + sparqlEngineData = []; + let currentKb = fileUrls[0].split(".")[0]; + for (let i = 0; i < results.length; i += 2) { + let data = {}; + let evalUrl = fileUrls[i].split("."); + const kb = evalUrl[0]; + const engine = evalUrl[1]; + data["engine"] = engine; + if (results[i].status == "fulfilled" && results[i].value.status == "fulfilled") { + let evalResult = getTsvData(results[i].value.value); + performanceDataPerKb[kb][engine] = evalResult.data; + data["avgRuntime"] = evalResult.avgTime.toFixed(2); + data["medianRuntime"] = evalResult.medianTime.toFixed(2); + data["under_1s"] = evalResult.under_1s.toFixed(2); + data["over_5s"] = evalResult.over_5s.toFixed(2); + data["between_1_to_5s"] = evalResult.between_1_to_5s.toFixed(2); + data["failed"] = evalResult.failed.toFixed(2); + } + if (results[i + 1].status == "fulfilled" && results[i + 1].value.status == "fulfilled") { + let failResult = getTxtData(results[i + 1].value.value); + data["failedQueries"] = failResult.length; + } + if (kb != currentKb) { + fragment.appendChild(populateCard(cardTemplate, currentKb, sparqlEngineData)); + sparqlEngineData = []; + currentKb = kb; + } + sparqlEngineData.push(data); + } + fragment.appendChild(populateCard(cardTemplate, currentKb, sparqlEngineData)); + document.getElementById("cardsContainer").appendChild(fragment); +} + +/** + * Populate global sparqlEngines and kbs array based on files in the output directory + * @param url url of the output directory + * @async + */ +async function getOutputFiles(url) { + try { + const response = await fetch(url); + const data = await response.text(); + + // Parse the HTML response to extract file names + const parser = new DOMParser(); + const htmlDoc = parser.parseFromString(data, "text/html"); + const fileList = Array.from(htmlDoc.querySelectorAll("a")).map((link) => link.textContent.trim()); + for (const file of fileList) { + const parts = file.split("."); + if (parts.length === 5 && parts[2] === "queries") { + const kb = parts[0]; + if (!kbs.includes(kb)) kbs.push(kb); + const engine = parts[1]; + if (!sparqlEngines.includes(engine)) sparqlEngines.push(engine); + } + } + return fileList; + } catch (error) { + console.error("Error fetching file list:", error); + } +} + +/** + * Hide a modal if it is currently open. + * Adds a custom `pop-triggered` attribute to the modal so that modal.hide() doesn't execute any code after closing + * @param {HTMLElement} modalNode - The DOM node representing the modal to be hidden. + */ +function hideModalIfOpened(modalNode) { + if (modalNode.classList.contains("show")) { + modalNode.setAttribute("pop-triggered", true); + bootstrap.Modal.getInstance(modalNode).hide(); + } +} + +/** + * Handle browser's back button actions by displaying or hiding modals based on the current state. + * Dynamically adjusts modal attributes and visibility depending on the `page` property in the `popstate` event's state. + * @param {PopStateEvent} event - The popstate event triggered by browser navigation actions. + */ +window.addEventListener("popstate", function (event) { + const comparisonModal = document.querySelector("#comparisonModal"); + const queryDetailsModal = document.querySelector("#queryDetailsModal"); + const compareExecTreesModal = document.querySelector("#compareExecTreeModal"); + + const state = event.state || {}; + const { page, kb, engine, q, t, s1, s2, qid } = state; + const { selectedQuery, tab } = getSanitizedQAndT(q, t); + + // Close all modals initially + //[comparisonModal, queryDetailsModal, compareExecTreesModal].forEach(hideModalIfOpened); + + switch (page) { + case "comparison": + [queryDetailsModal, compareExecTreesModal].forEach(hideModalIfOpened); + showModal(comparisonModal, { "data-kb": kb }, true); + break; + + case "queriesDetails": + [comparisonModal, compareExecTreesModal].forEach(hideModalIfOpened); + if (queryDetailsModal.classList.contains("show")) { + tab ? showTab(tab) : showTab(0); + } else { + showModal( + queryDetailsModal, + { "data-kb": kb, "data-engine": engine, "data-query": selectedQuery, "data-tab": tab }, + true + ); + } + break; + + case "compareExecTrees": + [comparisonModal, queryDetailsModal].forEach(hideModalIfOpened); + showModal(compareExecTreesModal, { "data-kb": kb, "data-s1": s1, "data-s2": s2, "data-qid": qid }, true); + break; + + case "main": + default: + [comparisonModal, queryDetailsModal, compareExecTreesModal].forEach(hideModalIfOpened); + // No action needed for the main page + break; + } +}); + +/** + * Display appropriate modals based on URL parameters. + * Reads URL query parameters to determine the `page` and associated attributes, + * then displays the corresponding modal. + * If no valid page parameter is found, the URL is reset to the main page. + * @async + */ +async function showPageFromUrl() { + const urlParams = new URLSearchParams(window.location.search); + const page = urlParams.get("page"); + const kb = urlParams.get("kb")?.toLowerCase(); + const { selectedQuery, tab } = getSanitizedQAndT(urlParams.get("q"), urlParams.get("t")); + const queryDetailsModal = document.querySelector("#queryDetailsModal"); + const comparisonModal = document.querySelector("#comparisonModal"); + const compareExecTreesModal = document.querySelector("#compareExecTreeModal"); + + switch (page) { + case "comparison": + showModal(comparisonModal, { "data-kb": kb }); + break; + + case "queriesDetails": + showModal(queryDetailsModal, { + "data-kb": kb, + "data-engine": urlParams.get("engine")?.toLowerCase(), + "data-query": selectedQuery, + "data-tab": tab, + }); + break; + + case "compareExecTrees": + // Add runtime_info to PerformanceDataPerKb so that trees can be displayed + for (const engine of sparqlEngines) { + await addRuntimeToPerformanceDataPerKb(kb, engine); + } + showModal(compareExecTreesModal, { + "data-kb": kb, + "data-s1": urlParams.get("s1")?.toLowerCase(), + "data-s2": urlParams.get("s2")?.toLowerCase(), + "data-qid": urlParams.get("qid"), + }); + break; + + default: + // Navigate back to the main page if no valid page parameter + const url = new URL(window.location); + url.search = ""; + window.history.replaceState({ page: "main" }, "", url); + break; + } +} + +function getSanitizedQAndT(q, t) { + let selectedQuery = parseInt(q); + let tab = parseInt(t); + + if (isNaN(tab) || tab < 1 || tab > 3) { + tab = ""; + } + + if (isNaN(selectedQuery) || selectedQuery < 0) { + selectedQuery = ""; + } + return { selectedQuery: selectedQuery, tab: tab }; +} + +// Use the DOMContentLoaded event listener to ensure the DOM is ready +document.addEventListener("DOMContentLoaded", async function () { + getOutputFiles(outputUrl).then(async function (fileList) { + // Fetch the card template + const response = await fetch("card-template.html"); + const templateText = await response.text(); + + // Create a virtual DOM element to hold the template + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = templateText; + const cardTemplate = tempDiv.querySelector("#cardTemplate"); + + const fragment = document.createDocumentFragment(); + + const fileUrls = getFileUrls(fileList); + + // For all the tsv files in the output folder, create bootstrap card and display on main page + fetchAndProcessFiles(fileUrls).then(async (results) => { + processAndDisplayResults(results, fileUrls, fragment, cardTemplate); + $("#cardsContainer table").tablesorter({ + theme: "bootstrap", + sortStable: true, + sortInitialOrder: "desc", + }); + // Navigate to the correct page (or modal) based on the url + await showPageFromUrl(); + }); + + // Setup event listeners for queryDetailsModal, comparisonModal and compareExecModal + setListenersForQueriesTabs(); + setListenersForCompareExecModal(); + setListenersForEnginesComparison(); + }); +}); diff --git a/src/qlever/evaluation/www/query-details.js b/src/qlever/evaluation/www/query-details.js new file mode 100644 index 00000000..b198fb3e --- /dev/null +++ b/src/qlever/evaluation/www/query-details.js @@ -0,0 +1,412 @@ +/** + * Sets event listeners for the tabs in queryDetailsModal + * + * - Listens for click events on query tab buttons and handles tab switching. + * - Controls the visibility of the modal footer based on the selected tab and content. + */ +function setListenersForQueriesTabs() { + const modalNode = document.querySelector("#queryDetailsModal"); + // Before the modal is shown, update the url and history Stack and remove the previous table + modalNode.addEventListener("show.bs.modal", async function () { + const kb = modalNode.getAttribute("data-kb"); + const engine = modalNode.getAttribute("data-engine"); + const selectedQuery = modalNode.getAttribute("data-query") ? modalNode.getAttribute("data-query") : null; + const tab = modalNode.getAttribute("data-tab") ? modalNode.getAttribute("data-tab") : null; + + if (kb && engine) { + // If back/forward button, do nothing + if (modalNode.getAttribute("pop-triggered")) { + modalNode.removeAttribute("pop-triggered"); + } + // Else Update the url params and push the page to history stack + else { + updateUrlAndState(kb, engine, selectedQuery, tab); + } + + // If the kb and engine are the same as previously opened, do nothing + const modalTitle = modalNode.querySelector(".modal-title"); + const tab1Content = modalNode.querySelector("#runtimes-tab-pane"); + if (modalTitle.textContent.includes(engine) && tab1Content.querySelector(".card-title").innerHTML.includes(kb)) { + return; + } + // Else clear the table with queries and runtimes before the modal is shown + else { + const tabBody = modalNode.querySelector("#queryList"); + tabBody.replaceChildren(); + // Populate modal title + modalTitle.textContent = "SPARQL Engine - "; + // Populate Knowledge base + tab1Content.querySelector(".card-title").innerHTML = "Knowledge Graph - "; + //bootstrap.Tab.getOrCreateInstance(document.querySelector("#runtimes-tab")).show(); + showTab(0); + } + } + }); + + // After the modal is shown, populate the modal based on kb and engine attributes + modalNode.addEventListener("shown.bs.modal", async function () { + const kb = modalNode.getAttribute("data-kb"); + const engine = modalNode.getAttribute("data-engine"); + const selectedQuery = modalNode.getAttribute("data-query") ? parseInt(modalNode.getAttribute("data-query")) : null; + const tab = modalNode.getAttribute("data-tab") ? parseInt(modalNode.getAttribute("data-tab")) : null; + if (kb && engine) { + await openQueryDetailsModal(kb, engine, selectedQuery, tab); + } + }); + + // Handle the modal's `hidden.bs.modal` event + modalNode.addEventListener("hidden.bs.modal", function () { + // Don't execute any url or state based code when back/forward button clicked + if (modalNode.getAttribute("pop-triggered")) { + modalNode.removeAttribute("pop-triggered"); + return; + } + // Remove modal-related parameters from the URL + const url = new URL(window.location); + url.searchParams.delete("page"); + url.searchParams.delete("kb"); + url.searchParams.delete("engine"); + url.searchParams.delete("q"); + url.searchParams.delete("t"); + window.history.pushState({ page: "main" }, "", url); + }); + + const triggerTabList = document.querySelectorAll("#myTab button"); + triggerTabList.forEach((triggerEl, index) => { + triggerEl.addEventListener("click", (event) => { + event.preventDefault(); + showTab(index); + const urlParams = new URLSearchParams(window.location.search); + updateUrlAndState(urlParams.get("kb"), urlParams.get("engine"), urlParams.get("q"), index); + }); + }); + + // Adds functionality to buttons in the modal footer for zooming in/out the execution tree + modalNode.querySelector(".modal-footer").addEventListener("click", function (event) { + if (event.target.tagName === "BUTTON") { + const purpose = event.target.id; + const treeId = "#result-tree"; + const tree = document.querySelector(treeId); + const currentFontSize = tree.querySelector(".node[class*=font-size-]").className.match(/font-size-(\d+)/)[1]; + generateExecutionTree(null, purpose, treeId, Number.parseInt(currentFontSize)); + } + }); +} + +function updateUrlAndState(kb, engine, selectedQuery, tab) { + const url = new URL(window.location); + url.searchParams.set("page", "queriesDetails"); + url.searchParams.set("kb", kb); + url.searchParams.set("engine", engine); + const state = { page: "queriesDetails", kb: kb, engine: engine }; + selectedQuery !== null && (state.q = selectedQuery) && url.searchParams.set("q", selectedQuery); + tab !== null && (state.t = tab) && url.searchParams.set("t", tab); + // If this page is directly opened from url, replace the null state in history stack + if (window.history.state === null) { + window.history.replaceState(state, "", url); + } else { + window.history.pushState(state, "", url); + } +} + +function fixUrlAndState(totalQueries, selectedQuery, tab) { + const url = new URL(window.location); + let updateState = false; + if (selectedQuery === null || selectedQuery >= totalQueries) { + url.searchParams.delete("q"); + updateState = true; + selectedQuery = null; + } + if (tab === null) { + url.searchParams.delete("t"); + updateState = true; + } + if (updateState) { + const currentState = window.history.state; + const newState = { ...currentState }; + selectedQuery === null && delete newState["q"]; + tab === null && delete newState["t"]; + history.replaceState(newState, "", url); + } + return { q: selectedQuery, t: tab }; +} + +/** + * Handles the click event on a row in the cards on main page. + * + * Set the kb and engine attribute based on the row clicked on by the user and open the modal + * + * @async + * @param {Event} event - The click event triggered by selecting a row. + */ +async function handleRowClick(event) { + const kb = event.currentTarget.closest(".card").querySelector("h5").innerHTML.toLowerCase(); + const engine = event.currentTarget.querySelector("td").innerHTML.toLowerCase(); + const modalNode = document.querySelector("#queryDetailsModal"); + modalNode.setAttribute("data-kb", kb); + modalNode.setAttribute("data-engine", engine); + modalNode.setAttribute("data-query", ""); + modalNode.setAttribute("data-tab", ""); + showModal(modalNode); +} + +/** + * - Fetches the query log and results based on the selected knowledge base (KB) and engine. + * - Updates the modal content and displays the query details. + * - Manages the state of the query execution tree and tab content. + * + * @async + * @param {string} kb - The selected knowledge base + * @param {string} engine - The selected sparql engine + * @param {number} selectedQuery - Selected Query index in runtimes table, if any + * @param {number} tabToOpen - index of the tab to show + */ +async function openQueryDetailsModal(kb, engine, selectedQuery, tabToOpen) { + const modalNode = document.getElementById("queryDetailsModal"); + + // If the kb and engine are the same as previously opened, do nothing and display the modal as it is. + const modalTitle = modalNode.querySelector(".modal-title"); + const tab1Content = modalNode.querySelector("#runtimes-tab-pane"); + if (modalTitle.textContent.includes(engine) && tab1Content.querySelector(".card-title").innerHTML.includes(kb)) { + tabToOpen === null ? showTab(0) : showTab(tabToOpen); + return; + } + + // Fetch and display the runtime table with all queries and populate and display the first tab + showSpinner(); + // Populate modal title + modalTitle.textContent = "SPARQL Engine - " + engine; + // Populate knowledge base + tab1Content.querySelector(".card-title").innerHTML = "Knowledge Graph - " + kb; + + const tabBody = modalNode.querySelector("#queryList"); + const queryLog = getQueryLog(engine, kb); + const queryResult = await getYamlData(queryLog); + + if (!queryResult) queryResult = []; + if ( + queryResult && + queryResult[0].runtime_info.hasOwnProperty("query_execution_tree") && + !execTreeEngines.includes(engine) + ) { + execTreeEngines.push(engine); + } + + document.getElementById("resultsTable").replaceChildren(); + + for (let id of ["#tab2Content", "#tab3Content", "#tab4Content"]) { + const tabContent = modalNode.querySelector(id); + tabContent.replaceChildren(document.createTextNode("Please select a query from the table in Query runtimes tab")); + } + document.getElementById("result-tree").replaceChildren(); + + createQueryTable(queryResult, kb, engine, tabBody); + $("#runtimes-tab-pane table").tablesorter({ + theme: "bootstrap", + sortStable: true, + sortInitialOrder: "desc", + }); + + document.querySelector("#queryDetailsModal").querySelector(".modal-footer").classList.add("d-none"); + const { q, t } = fixUrlAndState(queryResult.length, selectedQuery, tabToOpen); + if (q !== null) { + document.querySelector("#queryList").querySelectorAll("tr")[q].classList.add("table-active"); + populateTabsFromSelectedRow(queryResult[q]); + } + if (t !== null) { + showTab(t); + } + hideSpinner(); +} + +/** + * Creates and populates the query table inside the modal with query results and runtimes. + * + * - Iterates over the query results and dynamically creates table rows. + * - Each row displays the query and its runtime, along with click event listeners. + * + * @param {Object[]} queryResult - The array of query result objects. + * @param {string} kb - The name of the knowledge base. + * @param {string} engine - The SPARQL engine used. + * @param {HTMLElement} tabBody - The #queryList element. + */ +function createQueryTable(queryResult, kb, engine, tabBody) { + queryResult.forEach((query, i) => { + performanceDataPerKb[kb][engine][i]["Query"] = query.sparql; + performanceDataPerKb[kb][engine][i]["Runtime"] = query.runtime_info; + const tabRow = document.createElement("tr"); + tabRow.style.cursor = "pointer"; + tabRow.addEventListener("click", handleTabRowClick.bind(null, query)); + let runtime; + if (query.runtime_info && Object.hasOwn(query.runtime_info, "client_time")) { + runtime = formatNumber(query.runtime_info.client_time); + } else if (Number.isFinite(query.runtime_info)) { + runtime = formatNumber(query.runtime_info); + } else { + runtime = "N/A"; + } + + resultClass = query.result === null || !Array.isArray(query.result) ? "bg-danger bg-opacity-25" : ""; + tabRow.innerHTML = ` + ${query.query} + ${runtime} + `; + tabBody.appendChild(tabRow); + }); +} + +/** + * Handles the click event on a query row within the query table. + * + * - Highlights the selected row and switches to the query details tab. + * - Generates the query SPARQL, execution results, and execution tree in their relevant tabs + * + * @param {Object} queryRow - The object representing the query details. + * @param {Event} event - The click event triggered by selecting a query row. + */ +function handleTabRowClick(queryRow, event) { + const kb = document.querySelector("#queryDetailsModal").getAttribute("data-kb"); + const engine = document.querySelector("#queryDetailsModal").getAttribute("data-engine"); + updateUrlAndState(kb, engine, event.currentTarget.rowIndex - 1, 1); + let activeRow = document.querySelector("#queryList").querySelector(".table-active"); + if (activeRow) { + if (activeRow.rowIndex === event.currentTarget.rowIndex) { + showTab(1); + return; + } + activeRow.classList.remove("table-active"); + } + event.currentTarget.classList.add("table-active"); + showSpinner(); + showTab(1); + populateTabsFromSelectedRow(queryRow); + hideSpinner(); +} + +/** + * - Generates the query SPARQL, execution results, and execution tree in their relevant tabs + * + * @param {Object} queryRow - The object representing the query details. + */ +function populateTabsFromSelectedRow(queryRow) { + const tab2Content = document.querySelector("#tab2Content"); + tab2Content.textContent = queryRow.sparql; + document.querySelector("#showMore").classList.replace("d-flex", "d-none"); + const showMoreCloneButton = document.querySelector("#showMoreButton").cloneNode(true); + document.querySelector("#showMore").replaceChild(showMoreCloneButton, document.querySelector("#showMoreButton")); + generateHTMLTable(queryRow.result); + generateExecutionTree(queryRow); +} + +/** + * - Display the tab corresponding to the passed tabIndex + * + * @param {number} tabIndex - index of the tab to display (0 - 3) + */ +function showTab(tabIndex) { + const tabNodeId = document.querySelectorAll("#myTab button")[tabIndex].id; + const tab = bootstrap.Tab.getOrCreateInstance(document.getElementById(tabNodeId)); + tab.show(); + // Enable zoom buttons for execution tree tab (2) + if (tabIndex === 2 && document.querySelector("#result-tree").children.length !== 0) { + document.querySelector("#queryDetailsModal").querySelector(".modal-footer").classList.remove("d-none"); + } +} + +/** + * Generates the query results table for a set of results. + * + * - Iterates over the provided results and creates table rows for each result entry. + * - Handles formatting of SPARQL results, stripping unnecessary data type information. + * + * @param {string[][]} results - A 2D array representing the query results. + */ +function generateQueryResultsTable(results) { + const table = document.getElementById("resultsTable"); + const tableFragment = document.createDocumentFragment(); + + const index = results.length > 1000 ? 1000 : results.length; + // Create table rows + for (let i = 0; i < index; i++) { + const row = document.createElement("tr"); + for (let j = 0; j < results[i].length; j++) { + const cell = document.createElement("td"); + + // Extract only the value without the data type information + let value = results[i][j]; + if (!value) value = "N/A"; + else if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); // Remove double quotes + } else if (value.includes("^^<")) { + value = value.split("^^<")[0]; // Remove data type + } + + cell.textContent = value; + row.appendChild(cell); + } + tableFragment.appendChild(row); + } + // Append the fragment to the table + table.appendChild(tableFragment); +} + +/** + * Displays additional query results if there are more than 1000 results. + * + * - Handles pagination of results when there are more than 1000 entries. + * - Dynamically loads more results when the "Show More" button is clicked. + * + * @param {string[][]} results - A 2D array representing the remaining query results. + */ +function displayMoreResults(results) { + if (results.length > 1000) { + generateQueryResultsTable(results.slice(0, 1000)); + let remainingResults = results.slice(1000); + document.querySelector("#showMoreButton").addEventListener( + "click", + function () { + displayMoreResults(remainingResults); + }, + { once: true } + ); + } else { + generateQueryResultsTable(results); + document.querySelector("#showMore").classList.replace("d-flex", "d-none"); + } +} + +/** + * Generates the HTML table for SPARQL query results and handles pagination if needed. + * + * - Displays a message if no results are available or if the query failed. + * - Generates the query results table and manages the "Show More" button for large result sets. + * + * @param {string[][] | Object} tableData - A 2D array of query results or an error message object. + */ +function generateHTMLTable(tableData) { + document.getElementById("resultsTable").replaceChildren(); + if (!tableData) { + document + .getElementById("tab4Content") + .replaceChildren(document.createTextNode("No SPARQL results available for this query!")); + return; + } + if (!Array.isArray(tableData)) { + document.getElementById("tab4Content").replaceChildren(document.createTextNode(tableData.msg)); + return; + } + document.getElementById("tab4Content").replaceChildren(); + document.getElementById("resultsTable").replaceChildren(); + generateQueryResultsTable(tableData); + if (1000 < tableData.length) { + document.querySelector("#showMore").classList.replace("d-none", "d-flex"); + let remainingResults = tableData.slice(1000); + document.querySelector("#showMoreButton").addEventListener( + "click", + function displayResults() { + displayMoreResults(remainingResults); + }, + { once: true } + ); + } +} diff --git a/src/qlever/evaluation/www/treant.css b/src/qlever/evaluation/www/treant.css new file mode 100644 index 00000000..f4db0fb7 --- /dev/null +++ b/src/qlever/evaluation/www/treant.css @@ -0,0 +1,56 @@ +/* required LIB STYLES */ +/* .Treant se automatski dodaje na svaki chart conatiner */ +.Treant { position: relative; overflow: hidden; padding: 0 !important; } +.Treant > .node, +.Treant > .pseudo { position: absolute; display: block; visibility: hidden; } +.Treant.Treant-loaded .node, +.Treant.Treant-loaded .pseudo { visibility: visible; } +.Treant > .pseudo { width: 0; height: 0; border: none; padding: 0; } +.Treant .collapse-switch { width: 3px; height: 3px; display: block; border: 1px solid black; position: absolute; top: 1px; right: 1px; cursor: pointer; } +.Treant .collapsed .collapse-switch { background-color: #868DEE; } +.Treant > .node img { border: none; float: left; } + +.node { + padding: 2px; + border-radius: 3px; + background-color: #FEFEFE; + border: 1px solid #000; + min-width: 20em; + max-width: 30em; + color: black; + } + + .node > p { margin: 0; } + .node-name { font-weight: bold; } + .node-size:before { content: "Size: "; } + .node-cols:before { content: "Cols: "; } + .node-size:before { content: "Size: "; } + .node-time:before { content: "Time: "; } + .node-time:after { content: "ms"; } + div.cached .node-time:after { content: "ms (cached -> 0ms)"; } + .node-total { display: none; } + .node-cached { display: none; } + .node.cached { color: grey; border: 1px solid grey; } + .node.high { background-color: #FFF7F7; } + .node.veryhigh { background-color: #FFEEEE; } + .node.high.cached { background-color: #FFFFF7; } + .node.veryhigh.cached { background-color: #FFFFEE; } + + #spinner { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(186, 179, 179, 0.7); + justify-content: center; + align-items: center; + z-index: 10000; +} + +.font-size-30 { font-size: 30%; } +.font-size-40 { font-size: 40%; } +.font-size-50 { font-size: 50%; } +.font-size-60 { font-size: 60%; } +.font-size-70 { font-size: 70%; } +.font-size-80 { font-size: 80%; } diff --git a/src/qlever/evaluation/www/treant.js b/src/qlever/evaluation/www/treant.js new file mode 100644 index 00000000..124d725b --- /dev/null +++ b/src/qlever/evaluation/www/treant.js @@ -0,0 +1,2171 @@ +/* + * Treant-js + * + * (c) 2013 Fran Peručić + * Treant-js may be freely distributed under the MIT license. + * For all details and documentation: + * http://fperucic.github.io/treant-js + * + * Treant is an open-source JavaScipt library for visualization of tree diagrams. + * It implements the node positioning algorithm of John Q. Walker II "Positioning nodes for General Trees". + * + * References: + * Emilio Cortegoso Lobato: ECOTree.js v1.0 (October 26th, 2006) + * + * Contributors: + * Fran Peručić, https://github.com/fperucic + * Dave Goodchild, https://github.com/dlgoodchild + */ + +;( function() { + // Polyfill for IE to use startsWith + if (!String.prototype.startsWith) { + String.prototype.startsWith = function(searchString, position){ + return this.substr(position || 0, searchString.length) === searchString; + }; + } + + var $ = null; + + var UTIL = { + + /** + * Directly updates, recursively/deeply, the first object with all properties in the second object + * @param {object} applyTo + * @param {object} applyFrom + * @return {object} + */ + inheritAttrs: function( applyTo, applyFrom ) { + for ( var attr in applyFrom ) { + if ( applyFrom.hasOwnProperty( attr ) ) { + if ( ( applyTo[attr] instanceof Object && applyFrom[attr] instanceof Object ) && ( typeof applyFrom[attr] !== 'function' ) ) { + this.inheritAttrs( applyTo[attr], applyFrom[attr] ); + } + else { + applyTo[attr] = applyFrom[attr]; + } + } + } + return applyTo; + }, + + /** + * Returns a new object by merging the two supplied objects + * @param {object} obj1 + * @param {object} obj2 + * @returns {object} + */ + createMerge: function( obj1, obj2 ) { + var newObj = {}; + if ( obj1 ) { + this.inheritAttrs( newObj, this.cloneObj( obj1 ) ); + } + if ( obj2 ) { + this.inheritAttrs( newObj, obj2 ); + } + return newObj; + }, + + /** + * Takes any number of arguments + * @returns {*} + */ + extend: function() { + if ( $ ) { + Array.prototype.unshift.apply( arguments, [true, {}] ); + return $.extend.apply( $, arguments ); + } + else { + return UTIL.createMerge.apply( this, arguments ); + } + }, + + /** + * @param {object} obj + * @returns {*} + */ + cloneObj: function ( obj ) { + if ( Object( obj ) !== obj ) { + return obj; + } + var res = new obj.constructor(); + for ( var key in obj ) { + if ( obj.hasOwnProperty(key) ) { + res[key] = this.cloneObj(obj[key]); + } + } + return res; + }, + + /** + * @param {Element} el + * @param {string} eventType + * @param {function} handler + */ + addEvent: function( el, eventType, handler ) { + if ( $ ) { + $( el ).on( eventType+'.treant', handler ); + } + else if ( el.addEventListener ) { // DOM Level 2 browsers + el.addEventListener( eventType, handler, false ); + } + else if ( el.attachEvent ) { // IE <= 8 + el.attachEvent( 'on' + eventType, handler ); + } + else { // ancient browsers + el['on' + eventType] = handler; + } + }, + + /** + * @param {string} selector + * @param {boolean} raw + * @param {Element} parentEl + * @returns {Element|jQuery} + */ + findEl: function( selector, raw, parentEl ) { + parentEl = parentEl || document; + + if ( $ ) { + var $element = $( selector, parentEl ); + return ( raw? $element.get( 0 ): $element ); + } + else { + // todo: getElementsByName() + // todo: getElementsByTagName() + // todo: getElementsByTagNameNS() + if ( selector.charAt( 0 ) === '#' ) { + return parentEl.getElementById( selector.substring( 1 ) ); + } + else if ( selector.charAt( 0 ) === '.' ) { + var oElements = parentEl.getElementsByClassName( selector.substring( 1 ) ); + return ( oElements.length? oElements[0]: null ); + } + + throw new Error( 'Unknown container element' ); + } + }, + + getOuterHeight: function( element ) { + var nRoundingCompensation = 1; + if ( typeof element.getBoundingClientRect === 'function' ) { + return element.getBoundingClientRect().height; + } + else if ( $ ) { + return Math.ceil( $( element ).outerHeight() ) + nRoundingCompensation; + } + else { + return Math.ceil( + element.clientHeight + + UTIL.getStyle( element, 'border-top-width', true ) + + UTIL.getStyle( element, 'border-bottom-width', true ) + + UTIL.getStyle( element, 'padding-top', true ) + + UTIL.getStyle( element, 'padding-bottom', true ) + + nRoundingCompensation + ); + } + }, + + getOuterWidth: function( element ) { + var nRoundingCompensation = 1; + if ( typeof element.getBoundingClientRect === 'function' ) { + return element.getBoundingClientRect().width; + } + else if ( $ ) { + return Math.ceil( $( element ).outerWidth() ) + nRoundingCompensation; + } + else { + return Math.ceil( + element.clientWidth + + UTIL.getStyle( element, 'border-left-width', true ) + + UTIL.getStyle( element, 'border-right-width', true ) + + UTIL.getStyle( element, 'padding-left', true ) + + UTIL.getStyle( element, 'padding-right', true ) + + nRoundingCompensation + ); + } + }, + + getStyle: function( element, strCssRule, asInt ) { + var strValue = ""; + if ( document.defaultView && document.defaultView.getComputedStyle ) { + strValue = document.defaultView.getComputedStyle( element, '' ).getPropertyValue( strCssRule ); + } + else if( element.currentStyle ) { + strCssRule = strCssRule.replace(/\-(\w)/g, + function (strMatch, p1){ + return p1.toUpperCase(); + } + ); + strValue = element.currentStyle[strCssRule]; + } + //Number(elem.style.width.replace(/[^\d\.\-]/g, '')); + return ( asInt? parseFloat( strValue ): strValue ); + }, + + addClass: function( element, cssClass ) { + if ( $ ) { + $( element ).addClass( cssClass ); + } + else { + if ( !UTIL.hasClass( element, cssClass ) ) { + if ( element.classList ) { + element.classList.add( cssClass ); + } + else { + element.className += " "+cssClass; + } + } + } + }, + + hasClass: function(element, my_class) { + return (" " + element.className + " ").replace(/[\n\t]/g, " ").indexOf(" "+my_class+" ") > -1; + }, + + toggleClass: function ( element, cls, apply ) { + if ( $ ) { + $( element ).toggleClass( cls, apply ); + } + else { + if ( apply ) { + //element.className += " "+cls; + element.classList.add( cls ); + } + else { + element.classList.remove( cls ); + } + } + }, + + setDimensions: function( element, width, height ) { + if ( $ ) { + $( element ).width( width ).height( height ); + } + else { + element.style.width = width+'px'; + element.style.height = height+'px'; + } + }, + isjQueryAvailable: function() {return(typeof ($) !== 'undefined' && $);}, + }; + + /** + * ImageLoader is used for determining if all the images from the Tree are loaded. + * Node size (width, height) can be correctly determined only when all inner images are loaded + */ + var ImageLoader = function() { + this.reset(); + }; + + ImageLoader.prototype = { + + /** + * @returns {ImageLoader} + */ + reset: function() { + this.loading = []; + return this; + }, + + /** + * @param {TreeNode} node + * @returns {ImageLoader} + */ + processNode: function( node ) { + var aImages = node.nodeDOM.getElementsByTagName( 'img' ); + + var i = aImages.length; + while ( i-- ) { + this.create( node, aImages[i] ); + } + return this; + }, + + /** + * @returns {ImageLoader} + */ + removeAll: function( img_src ) { + var i = this.loading.length; + while ( i-- ) { + if ( this.loading[i] === img_src ) { + this.loading.splice( i, 1 ); + } + } + return this; + }, + + /** + * @param {TreeNode} node + * @param {Element} image + * @returns {*} + */ + create: function ( node, image ) { + var self = this, source = image.src; + + function imgTrigger() { + self.removeAll( source ); + node.width = node.nodeDOM.offsetWidth; + node.height = node.nodeDOM.offsetHeight; + } + + if ( image.src.indexOf( 'data:' ) !== 0 ) { + this.loading.push( source ); + + if ( image.complete ) { + return imgTrigger(); + } + + UTIL.addEvent( image, 'load', imgTrigger ); + UTIL.addEvent( image, 'error', imgTrigger ); // handle broken url-s + + // load event is not fired for cached images, force the load event + image.src += ( ( image.src.indexOf( '?' ) > 0)? '&': '?' ) + new Date().getTime(); + } + else { + imgTrigger(); + } + }, + + /** + * @returns {boolean} + */ + isNotLoading: function() { + return ( this.loading.length === 0 ); + } + }; + + /** + * Class: TreeStore + * TreeStore is used for holding initialized Tree objects + * Its purpose is to avoid global variables and enable multiple Trees on the page. + */ + var TreeStore = { + + store: [], + + /** + * @param {object} jsonConfig + * @returns {Tree} + */ + createTree: function( jsonConfig ) { + var nNewTreeId = this.store.length; + this.store.push( new Tree( jsonConfig, nNewTreeId ) ); + return this.get( nNewTreeId ); + }, + + /** + * @param {number} treeId + * @returns {Tree} + */ + get: function ( treeId ) { + return this.store[treeId]; + }, + + /** + * @param {number} treeId + * @returns {TreeStore} + */ + destroy: function( treeId ) { + var tree = this.get( treeId ); + if ( tree ) { + tree._R.remove(); + var draw_area = tree.drawArea; + + while ( draw_area.firstChild ) { + draw_area.removeChild( draw_area.firstChild ); + } + + var classes = draw_area.className.split(' '), + classes_to_stay = []; + + for ( var i = 0; i < classes.length; i++ ) { + var cls = classes[i]; + if ( cls !== 'Treant' && cls !== 'Treant-loaded' ) { + classes_to_stay.push(cls); + } + } + draw_area.style.overflowY = ''; + draw_area.style.overflowX = ''; + draw_area.className = classes_to_stay.join(' '); + + this.store[treeId] = null; + } + return this; + } + }; + + /** + * Tree constructor. + * @param {object} jsonConfig + * @param {number} treeId + * @constructor + */ + var Tree = function (jsonConfig, treeId ) { + + /** + * @param {object} jsonConfig + * @param {number} treeId + * @returns {Tree} + */ + this.reset = function( jsonConfig, treeId ) { + this.initJsonConfig = jsonConfig; + this.initTreeId = treeId; + + this.id = treeId; + + this.CONFIG = UTIL.extend( Tree.CONFIG, jsonConfig.chart ); + this.drawArea = UTIL.findEl( this.CONFIG.container, true ); + if ( !this.drawArea ) { + throw new Error( 'Failed to find element by selector "'+this.CONFIG.container+'"' ); + } + + UTIL.addClass( this.drawArea, 'Treant' ); + + // kill of any child elements that may be there + this.drawArea.innerHTML = ''; + + this.imageLoader = new ImageLoader(); + + this.nodeDB = new NodeDB( jsonConfig.nodeStructure, this ); + + // key store for storing reference to node connectors, + // key = nodeId where the connector ends + this.connectionStore = {}; + + this.loaded = false; + + this._R = new Raphael( this.drawArea, 100, 100 ); + + return this; + }; + + /** + * @returns {Tree} + */ + this.reload = function() { + this.reset( this.initJsonConfig, this.initTreeId ).redraw(); + return this; + }; + + this.reset( jsonConfig, treeId ); + }; + + Tree.prototype = { + + /** + * @returns {NodeDB} + */ + getNodeDb: function() { + return this.nodeDB; + }, + + /** + * @param {TreeNode} parentTreeNode + * @param {object} nodeDefinition + * @returns {TreeNode} + */ + addNode: function( parentTreeNode, nodeDefinition ) { + var dbEntry = this.nodeDB.get( parentTreeNode.id ); + + this.CONFIG.callback.onBeforeAddNode.apply( this, [parentTreeNode, nodeDefinition] ); + + var oNewNode = this.nodeDB.createNode( nodeDefinition, parentTreeNode.id, this ); + oNewNode.createGeometry( this ); + + oNewNode.parent().createSwitchGeometry( this ); + + this.positionTree(); + + this.CONFIG.callback.onAfterAddNode.apply( this, [oNewNode, parentTreeNode, nodeDefinition] ); + + return oNewNode; + }, + + /** + * @returns {Tree} + */ + redraw: function() { + this.positionTree(); + return this; + }, + + /** + * @param {function} callback + * @returns {Tree} + */ + positionTree: function( callback ) { + var self = this; + + if ( this.imageLoader.isNotLoading() ) { + var root = this.root(), + orient = this.CONFIG.rootOrientation; + + this.resetLevelData(); + + this.firstWalk( root, 0 ); + this.secondWalk( root, 0, 0, 0 ); + + this.positionNodes(); + + if ( this.CONFIG.animateOnInit ) { + setTimeout( + function() { + root.toggleCollapse(); + }, + this.CONFIG.animateOnInitDelay + ); + } + + if ( !this.loaded ) { + UTIL.addClass( this.drawArea, 'Treant-loaded' ); // nodes are hidden until .loaded class is added + if ( Object.prototype.toString.call( callback ) === "[object Function]" ) { + callback( self ); + } + self.CONFIG.callback.onTreeLoaded.apply( self, [root] ); + this.loaded = true; + } + + } + else { + setTimeout( + function() { + self.positionTree( callback ); + }, 10 + ); + } + return this; + }, + + /** + * In a first post-order walk, every node of the tree is assigned a preliminary + * x-coordinate (held in field node->prelim). + * In addition, internal nodes are given modifiers, which will be used to move their + * children to the right (held in field node->modifier). + * @param {TreeNode} node + * @param {number} level + * @returns {Tree} + */ + firstWalk: function( node, level ) { + node.prelim = null; + node.modifier = null; + + this.setNeighbors( node, level ); + this.calcLevelDim( node, level ); + + var leftSibling = node.leftSibling(); + + if ( node.childrenCount() === 0 || level == this.CONFIG.maxDepth ) { + // set preliminary x-coordinate + if ( leftSibling ) { + node.prelim = leftSibling.prelim + leftSibling.size() + this.CONFIG.siblingSeparation; + } + else { + node.prelim = 0; + } + } + else { + //node is not a leaf, firstWalk for each child + for ( var i = 0, n = node.childrenCount(); i < n; i++ ) { + this.firstWalk(node.childAt(i), level + 1); + } + + var midPoint = node.childrenCenter() - node.size() / 2; + + if ( leftSibling ) { + node.prelim = leftSibling.prelim + leftSibling.size() + this.CONFIG.siblingSeparation; + node.modifier = node.prelim - midPoint; + this.apportion( node, level ); + } + else { + node.prelim = midPoint; + } + + // handle stacked children positioning + if ( node.stackParent ) { // handle the parent of stacked children + node.modifier += this.nodeDB.get( node.stackChildren[0] ).size()/2 + node.connStyle.stackIndent; + } + else if ( node.stackParentId ) { // handle stacked children + node.prelim = 0; + } + } + return this; + }, + + /* + * Clean up the positioning of small sibling subtrees. + * Subtrees of a node are formed independently and + * placed as close together as possible. By requiring + * that the subtrees be rigid at the time they are put + * together, we avoid the undesirable effects that can + * accrue from positioning nodes rather than subtrees. + */ + apportion: function (node, level) { + var firstChild = node.firstChild(), + firstChildLeftNeighbor = firstChild.leftNeighbor(), + compareDepth = 1, + depthToStop = this.CONFIG.maxDepth - level; + + while( firstChild && firstChildLeftNeighbor && compareDepth <= depthToStop ) { + // calculate the position of the firstChild, according to the position of firstChildLeftNeighbor + + var modifierSumRight = 0, + modifierSumLeft = 0, + leftAncestor = firstChildLeftNeighbor, + rightAncestor = firstChild; + + for ( var i = 0; i < compareDepth; i++ ) { + leftAncestor = leftAncestor.parent(); + rightAncestor = rightAncestor.parent(); + modifierSumLeft += leftAncestor.modifier; + modifierSumRight += rightAncestor.modifier; + + // all the stacked children are oriented towards right so use right variables + if ( rightAncestor.stackParent !== undefined ) { + modifierSumRight += rightAncestor.size() / 2; + } + } + + // find the gap between two trees and apply it to subTrees + // and mathing smaller gaps to smaller subtrees + + var totalGap = (firstChildLeftNeighbor.prelim + modifierSumLeft + firstChildLeftNeighbor.size() + this.CONFIG.subTeeSeparation) - (firstChild.prelim + modifierSumRight ); + + if ( totalGap > 0 ) { + var subtreeAux = node, + numSubtrees = 0; + + // count all the subtrees in the LeftSibling + while ( subtreeAux && subtreeAux.id !== leftAncestor.id ) { + subtreeAux = subtreeAux.leftSibling(); + numSubtrees++; + } + + if ( subtreeAux ) { + var subtreeMoveAux = node, + singleGap = totalGap / numSubtrees; + + while ( subtreeMoveAux.id !== leftAncestor.id ) { + subtreeMoveAux.prelim += totalGap; + subtreeMoveAux.modifier += totalGap; + + totalGap -= singleGap; + subtreeMoveAux = subtreeMoveAux.leftSibling(); + } + } + } + + compareDepth++; + + firstChild = ( firstChild.childrenCount() === 0 )? + node.leftMost(0, compareDepth): + firstChild = firstChild.firstChild(); + + if ( firstChild ) { + firstChildLeftNeighbor = firstChild.leftNeighbor(); + } + } + }, + + /* + * During a second pre-order walk, each node is given a + * final x-coordinate by summing its preliminary + * x-coordinate and the modifiers of all the node's + * ancestors. The y-coordinate depends on the height of + * the tree. (The roles of x and y are reversed for + * RootOrientations of EAST or WEST.) + */ + secondWalk: function( node, level, X, Y ) { + if ( level <= this.CONFIG.maxDepth ) { + var xTmp = node.prelim + X, + yTmp = Y, align = this.CONFIG.nodeAlign, + orient = this.CONFIG.rootOrientation, + levelHeight, nodesizeTmp; + + if (orient === 'NORTH' || orient === 'SOUTH') { + levelHeight = this.levelMaxDim[level].height; + nodesizeTmp = node.height; + if (node.pseudo) { + node.height = levelHeight; + } // assign a new size to pseudo nodes + } + else if (orient === 'WEST' || orient === 'EAST') { + levelHeight = this.levelMaxDim[level].width; + nodesizeTmp = node.width; + if (node.pseudo) { + node.width = levelHeight; + } // assign a new size to pseudo nodes + } + + node.X = xTmp; + + if (node.pseudo) { // pseudo nodes need to be properly aligned, otherwise position is not correct in some examples + if (orient === 'NORTH' || orient === 'WEST') { + node.Y = yTmp; // align "BOTTOM" + } + else if (orient === 'SOUTH' || orient === 'EAST') { + node.Y = (yTmp + (levelHeight - nodesizeTmp)); // align "TOP" + } + + } else { + node.Y = ( align === 'CENTER' ) ? (yTmp + (levelHeight - nodesizeTmp) / 2) : + ( align === 'TOP' ) ? (yTmp + (levelHeight - nodesizeTmp)) : + yTmp; + } + + if ( orient === 'WEST' || orient === 'EAST' ) { + var swapTmp = node.X; + node.X = node.Y; + node.Y = swapTmp; + } + + if (orient === 'SOUTH' ) { + node.Y = -node.Y - nodesizeTmp; + } + else if ( orient === 'EAST' ) { + node.X = -node.X - nodesizeTmp; + } + + if ( node.childrenCount() !== 0 ) { + if ( node.id === 0 && this.CONFIG.hideRootNode ) { + // ako je root node Hiden onda nemoj njegovu dijecu pomaknut po Y osi za Level separation, neka ona budu na vrhu + this.secondWalk(node.firstChild(), level + 1, X + node.modifier, Y); + } + else { + this.secondWalk(node.firstChild(), level + 1, X + node.modifier, Y + levelHeight + this.CONFIG.levelSeparation); + } + } + + if ( node.rightSibling() ) { + this.secondWalk( node.rightSibling(), level, X, Y ); + } + } + }, + + /** + * position all the nodes, center the tree in center of its container + * 0,0 coordinate is in the upper left corner + * @returns {Tree} + */ + positionNodes: function() { + var self = this, + treeSize = { + x: self.nodeDB.getMinMaxCoord('X', null, null), + y: self.nodeDB.getMinMaxCoord('Y', null, null) + }, + + treeWidth = treeSize.x.max - treeSize.x.min, + treeHeight = treeSize.y.max - treeSize.y.min, + + treeCenter = { + x: treeSize.x.max - treeWidth/2, + y: treeSize.y.max - treeHeight/2 + }; + + this.handleOverflow(treeWidth, treeHeight); + + var + containerCenter = { + x: self.drawArea.clientWidth/2, + y: self.drawArea.clientHeight/2 + }, + + deltaX = containerCenter.x - treeCenter.x, + deltaY = containerCenter.y - treeCenter.y, + + // all nodes must have positive X or Y coordinates, handle this with offsets + negOffsetX = ((treeSize.x.min + deltaX) <= 0) ? Math.abs(treeSize.x.min) : 0, + negOffsetY = ((treeSize.y.min + deltaY) <= 0) ? Math.abs(treeSize.y.min) : 0, + i, len, node; + + // position all the nodes + for ( i = 0, len = this.nodeDB.db.length; i < len; i++ ) { + + node = this.nodeDB.get(i); + + self.CONFIG.callback.onBeforePositionNode.apply( self, [node, i, containerCenter, treeCenter] ); + + if ( node.id === 0 && this.CONFIG.hideRootNode ) { + self.CONFIG.callback.onAfterPositionNode.apply( self, [node, i, containerCenter, treeCenter] ); + continue; + } + + // if the tree is smaller than the draw area, then center the tree within drawing area + node.X += negOffsetX + ((treeWidth < this.drawArea.clientWidth) ? deltaX : this.CONFIG.padding); + node.Y += negOffsetY + ((treeHeight < this.drawArea.clientHeight) ? deltaY : this.CONFIG.padding); + + var collapsedParent = node.collapsedParent(), + hidePoint = null; + + if (collapsedParent) { + // position the node behind the connector point of the parent, so future animations can be visible + hidePoint = collapsedParent.connectorPoint( true ); + node.hide(hidePoint); + + } + else if (node.positioned) { + // node is already positioned, + node.show(); + } + else { // inicijalno stvaranje nodeova, postavi lokaciju + node.nodeDOM.style.left = node.X + 'px'; + node.nodeDOM.style.top = node.Y + 'px'; + node.positioned = true; + } + + if (node.id !== 0 && !(node.parent().id === 0 && this.CONFIG.hideRootNode)) { + this.setConnectionToParent(node, hidePoint); // skip the root node + } + else if (!this.CONFIG.hideRootNode && node.drawLineThrough) { + // drawlinethrough is performed for for the root node also + node.drawLineThroughMe(); + } + + self.CONFIG.callback.onAfterPositionNode.apply( self, [node, i, containerCenter, treeCenter] ); + } + return this; + }, + + /** + * Create Raphael instance, (optionally set scroll bars if necessary) + * @param {number} treeWidth + * @param {number} treeHeight + * @returns {Tree} + */ + handleOverflow: function( treeWidth, treeHeight ) { + var viewWidth = (treeWidth < this.drawArea.clientWidth) ? this.drawArea.clientWidth : treeWidth + this.CONFIG.padding*2, + viewHeight = (treeHeight < this.drawArea.clientHeight) ? this.drawArea.clientHeight : treeHeight + this.CONFIG.padding*2; + + this._R.setSize( viewWidth, viewHeight ); + + if ( this.CONFIG.scrollbar === 'resize') { + UTIL.setDimensions( this.drawArea, viewWidth, viewHeight ); + } + else if ( !UTIL.isjQueryAvailable() || this.CONFIG.scrollbar === 'native' ) { + + if ( this.drawArea.clientWidth < treeWidth ) { // is overflow-x necessary + this.drawArea.style.overflowX = "auto"; + } + + if ( this.drawArea.clientHeight < treeHeight ) { // is overflow-y necessary + this.drawArea.style.overflowY = "auto"; + } + } + // Fancy scrollbar relies heavily on jQuery, so guarding with if ( $ ) + else if ( this.CONFIG.scrollbar === 'fancy') { + var jq_drawArea = $( this.drawArea ); + if (jq_drawArea.hasClass('ps-container')) { // znaci da je 'fancy' vec inicijaliziran, treba updateat + jq_drawArea.find('.Treant').css({ + width: viewWidth, + height: viewHeight + }); + + jq_drawArea.perfectScrollbar('update'); + } + else { + var mainContainer = jq_drawArea.wrapInner('
'), + child = mainContainer.find('.Treant'); + + child.css({ + width: viewWidth, + height: viewHeight + }); + + mainContainer.perfectScrollbar(); + } + } // else this.CONFIG.scrollbar == 'None' + + return this; + }, + /** + * @param {TreeNode} treeNode + * @param {boolean} hidePoint + * @returns {Tree} + */ + setConnectionToParent: function( treeNode, hidePoint ) { + var stacked = treeNode.stackParentId, + connLine, + parent = ( stacked? this.nodeDB.get( stacked ): treeNode.parent() ), + + pathString = hidePoint? + this.getPointPathString(hidePoint): + this.getPathString(parent, treeNode, stacked); + + if ( this.connectionStore[treeNode.id] ) { + // connector already exists, update the connector geometry + connLine = this.connectionStore[treeNode.id]; + this.animatePath( connLine, pathString ); + } + else { + connLine = this._R.path( pathString ); + this.connectionStore[treeNode.id] = connLine; + + // don't show connector arrows por pseudo nodes + if ( treeNode.pseudo ) { + delete parent.connStyle.style['arrow-end']; + } + if ( parent.pseudo ) { + delete parent.connStyle.style['arrow-start']; + } + + connLine.attr( parent.connStyle.style ); + + if ( treeNode.drawLineThrough || treeNode.pseudo ) { + treeNode.drawLineThroughMe( hidePoint ); + } + } + treeNode.connector = connLine; + return this; + }, + + /** + * Create the path which is represented as a point, used for hiding the connection + * A path with a leading "_" indicates the path will be hidden + * See: http://dmitrybaranovskiy.github.io/raphael/reference.html#Paper.path + * @param {object} hidePoint + * @returns {string} + */ + getPointPathString: function( hidePoint ) { + return ["_M", hidePoint.x, ",", hidePoint.y, 'L', hidePoint.x, ",", hidePoint.y, hidePoint.x, ",", hidePoint.y].join(' '); + }, + + /** + * This method relied on receiving a valid Raphael Paper.path. + * See: http://dmitrybaranovskiy.github.io/raphael/reference.html#Paper.path + * A pathString is typically in the format of "M10,20L30,40" + * @param path + * @param {string} pathString + * @returns {Tree} + */ + animatePath: function( path, pathString ) { + if (path.hidden && pathString.charAt(0) !== "_") { // path will be shown, so show it + path.show(); + path.hidden = false; + } + + // See: http://dmitrybaranovskiy.github.io/raphael/reference.html#Element.animate + path.animate( + { + path: pathString.charAt(0) === "_"? + pathString.substring(1): + pathString // remove the "_" prefix if it exists + }, + this.CONFIG.animation.connectorsSpeed, + this.CONFIG.animation.connectorsAnimation, + function() { + if ( pathString.charAt(0) === "_" ) { // animation is hiding the path, hide it at the and of animation + path.hide(); + path.hidden = true; + } + } + ); + return this; + }, + + /** + * + * @param {TreeNode} from_node + * @param {TreeNode} to_node + * @param {boolean} stacked + * @returns {string} + */ + getPathString: function( from_node, to_node, stacked ) { + var startPoint = from_node.connectorPoint( true ), + endPoint = to_node.connectorPoint( false ), + orientation = this.CONFIG.rootOrientation, + connType = from_node.connStyle.type, + P1 = {}, P2 = {}; + + if ( orientation === 'NORTH' || orientation === 'SOUTH' ) { + P1.y = P2.y = (startPoint.y + endPoint.y) / 2; + + P1.x = startPoint.x; + P2.x = endPoint.x; + } + else if ( orientation === 'EAST' || orientation === 'WEST' ) { + P1.x = P2.x = (startPoint.x + endPoint.x) / 2; + + P1.y = startPoint.y; + P2.y = endPoint.y; + } + + // sp, p1, pm, p2, ep == "x,y" + var sp = startPoint.x+','+startPoint.y, p1 = P1.x+','+P1.y, p2 = P2.x+','+P2.y, ep = endPoint.x+','+endPoint.y, + pm = (P1.x + P2.x)/2 +','+ (P1.y + P2.y)/2, pathString, stackPoint; + + if ( stacked ) { // STACKED CHILDREN + + stackPoint = (orientation === 'EAST' || orientation === 'WEST')? + endPoint.x+','+startPoint.y: + startPoint.x+','+endPoint.y; + + if ( connType === "step" || connType === "straight" ) { + pathString = ["M", sp, 'L', stackPoint, 'L', ep]; + } + else if ( connType === "curve" || connType === "bCurve" ) { + var helpPoint, // used for nicer curve lines + indent = from_node.connStyle.stackIndent; + + if ( orientation === 'NORTH' ) { + helpPoint = (endPoint.x - indent)+','+(endPoint.y - indent); + } + else if ( orientation === 'SOUTH' ) { + helpPoint = (endPoint.x - indent)+','+(endPoint.y + indent); + } + else if ( orientation === 'EAST' ) { + helpPoint = (endPoint.x + indent) +','+startPoint.y; + } + else if ( orientation === 'WEST' ) { + helpPoint = (endPoint.x - indent) +','+startPoint.y; + } + pathString = ["M", sp, 'L', helpPoint, 'S', stackPoint, ep]; + } + + } + else { // NORMAL CHILDREN + if ( connType === "step" ) { + pathString = ["M", sp, 'L', p1, 'L', p2, 'L', ep]; + } + else if ( connType === "curve" ) { + pathString = ["M", sp, 'C', p1, p2, ep ]; + } + else if ( connType === "bCurve" ) { + pathString = ["M", sp, 'Q', p1, pm, 'T', ep]; + } + else if (connType === "straight" ) { + pathString = ["M", sp, 'L', sp, ep]; + } + } + + return pathString.join(" "); + }, + + /** + * Algorithm works from left to right, so previous processed node will be left neighbour of the next node + * @param {TreeNode} node + * @param {number} level + * @returns {Tree} + */ + setNeighbors: function( node, level ) { + node.leftNeighborId = this.lastNodeOnLevel[level]; + if ( node.leftNeighborId ) { + node.leftNeighbor().rightNeighborId = node.id; + } + this.lastNodeOnLevel[level] = node.id; + return this; + }, + + /** + * Used for calculation of height and width of a level (level dimensions) + * @param {TreeNode} node + * @param {number} level + * @returns {Tree} + */ + calcLevelDim: function( node, level ) { // root node is on level 0 + this.levelMaxDim[level] = { + width: Math.max( this.levelMaxDim[level]? this.levelMaxDim[level].width: 0, node.width ), + height: Math.max( this.levelMaxDim[level]? this.levelMaxDim[level].height: 0, node.height ) + }; + return this; + }, + + /** + * @returns {Tree} + */ + resetLevelData: function() { + this.lastNodeOnLevel = []; + this.levelMaxDim = []; + return this; + }, + + /** + * @returns {TreeNode} + */ + root: function() { + return this.nodeDB.get( 0 ); + } + }; + + /** + * NodeDB is used for storing the nodes. Each tree has its own NodeDB. + * @param {object} nodeStructure + * @param {Tree} tree + * @constructor + */ + var NodeDB = function ( nodeStructure, tree ) { + this.reset( nodeStructure, tree ); + }; + + NodeDB.prototype = { + + /** + * @param {object} nodeStructure + * @param {Tree} tree + * @returns {NodeDB} + */ + reset: function( nodeStructure, tree ) { + + this.db = []; + + var self = this; + + /** + * @param {object} node + * @param {number} parentId + */ + function iterateChildren( node, parentId ) { + var newNode = self.createNode( node, parentId, tree, null ); + + if ( node.children ) { + // pseudo node is used for descending children to the next level + if ( node.childrenDropLevel && node.childrenDropLevel > 0 ) { + while ( node.childrenDropLevel-- ) { + // pseudo node needs to inherit the connection style from its parent for continuous connectors + var connStyle = UTIL.cloneObj( newNode.connStyle ); + newNode = self.createNode( 'pseudo', newNode.id, tree, null ); + newNode.connStyle = connStyle; + newNode.children = []; + } + } + + var stack = ( node.stackChildren && !self.hasGrandChildren( node ) )? newNode.id: null; + + // children are positioned on separate levels, one beneath the other + if ( stack !== null ) { + newNode.stackChildren = []; + } + + for ( var i = 0, len = node.children.length; i < len ; i++ ) { + if ( stack !== null ) { + newNode = self.createNode( node.children[i], newNode.id, tree, stack ); + if ( ( i + 1 ) < len ) { + // last node cant have children + newNode.children = []; + } + } + else { + iterateChildren( node.children[i], newNode.id ); + } + } + } + } + + if ( tree.CONFIG.animateOnInit ) { + nodeStructure.collapsed = true; + } + + iterateChildren( nodeStructure, -1 ); // root node + + this.createGeometries( tree ); + + return this; + }, + + /** + * @param {Tree} tree + * @returns {NodeDB} + */ + createGeometries: function( tree ) { + var i = this.db.length; + + while ( i-- ) { + this.get( i ).createGeometry( tree ); + } + return this; + }, + + /** + * @param {number} nodeId + * @returns {TreeNode} + */ + get: function ( nodeId ) { + return this.db[nodeId]; // get TreeNode by ID + }, + + /** + * @param {function} callback + * @returns {NodeDB} + */ + walk: function( callback ) { + var i = this.db.length; + + while ( i-- ) { + callback.apply( this, [ this.get( i ) ] ); + } + return this; + }, + + /** + * + * @param {object} nodeStructure + * @param {number} parentId + * @param {Tree} tree + * @param {number} stackParentId + * @returns {TreeNode} + */ + createNode: function( nodeStructure, parentId, tree, stackParentId ) { + var node = new TreeNode( nodeStructure, this.db.length, parentId, tree, stackParentId ); + + this.db.push( node ); + + // skip root node (0) + if ( parentId >= 0 ) { + var parent = this.get( parentId ); + + // todo: refactor into separate private method + if ( nodeStructure.position ) { + if ( nodeStructure.position === 'left' ) { + parent.children.push( node.id ); + } + else if ( nodeStructure.position === 'right' ) { + parent.children.splice( 0, 0, node.id ); + } + else if ( nodeStructure.position === 'center' ) { + parent.children.splice( Math.floor( parent.children.length / 2 ), 0, node.id ); + } + else { + // edge case when there's only 1 child + var position = parseInt( nodeStructure.position ); + if ( parent.children.length === 1 && position > 0 ) { + parent.children.splice( 0, 0, node.id ); + } + else { + parent.children.splice( + Math.max( position, parent.children.length - 1 ), + 0, node.id + ); + } + } + } + else { + parent.children.push( node.id ); + } + } + + if ( stackParentId ) { + this.get( stackParentId ).stackParent = true; + this.get( stackParentId ).stackChildren.push( node.id ); + } + + return node; + }, + + getMinMaxCoord: function( dim, parent, MinMax ) { // used for getting the dimensions of the tree, dim = 'X' || 'Y' + // looks for min and max (X and Y) within the set of nodes + parent = parent || this.get(0); + + MinMax = MinMax || { // start with root node dimensions + min: parent[dim], + max: parent[dim] + ( ( dim === 'X' )? parent.width: parent.height ) + }; + + var i = parent.childrenCount(); + + while ( i-- ) { + var node = parent.childAt( i ), + maxTest = node[dim] + ( ( dim === 'X' )? node.width: node.height ), + minTest = node[dim]; + + if ( maxTest > MinMax.max ) { + MinMax.max = maxTest; + } + if ( minTest < MinMax.min ) { + MinMax.min = minTest; + } + + this.getMinMaxCoord( dim, node, MinMax ); + } + return MinMax; + }, + + /** + * @param {object} nodeStructure + * @returns {boolean} + */ + hasGrandChildren: function( nodeStructure ) { + var i = nodeStructure.children.length; + while ( i-- ) { + if ( nodeStructure.children[i].children ) { + return true; + } + } + return false; + } + }; + + /** + * TreeNode constructor. + * @param {object} nodeStructure + * @param {number} id + * @param {number} parentId + * @param {Tree} tree + * @param {number} stackParentId + * @constructor + */ + var TreeNode = function( nodeStructure, id, parentId, tree, stackParentId ) { + this.reset( nodeStructure, id, parentId, tree, stackParentId ); + }; + + TreeNode.prototype = { + + /** + * @param {object} nodeStructure + * @param {number} id + * @param {number} parentId + * @param {Tree} tree + * @param {number} stackParentId + * @returns {TreeNode} + */ + reset: function( nodeStructure, id, parentId, tree, stackParentId ) { + this.id = id; + this.parentId = parentId; + this.treeId = tree.id; + + this.prelim = 0; + this.modifier = 0; + this.leftNeighborId = null; + + this.stackParentId = stackParentId; + + // pseudo node is a node with width=height=0, it is invisible, but necessary for the correct positioning of the tree + this.pseudo = nodeStructure === 'pseudo' || nodeStructure['pseudo']; // todo: surely if nodeStructure is a scalar then the rest will error: + + this.meta = nodeStructure.meta || {}; + this.image = nodeStructure.image || null; + + this.link = UTIL.createMerge( tree.CONFIG.node.link, nodeStructure.link ); + + this.connStyle = UTIL.createMerge( tree.CONFIG.connectors, nodeStructure.connectors ); + this.connector = null; + + this.drawLineThrough = nodeStructure.drawLineThrough === false ? false : ( nodeStructure.drawLineThrough || tree.CONFIG.node.drawLineThrough ); + + this.collapsable = nodeStructure.collapsable === false ? false : ( nodeStructure.collapsable || tree.CONFIG.node.collapsable ); + this.collapsed = nodeStructure.collapsed; + + this.text = nodeStructure.text; + + // '.node' DIV + this.nodeInnerHTML = nodeStructure.innerHTML; + this.nodeHTMLclass = (tree.CONFIG.node.HTMLclass ? tree.CONFIG.node.HTMLclass : '') + // globally defined class for the nodex + (nodeStructure.HTMLclass ? (' ' + nodeStructure.HTMLclass) : ''); // + specific node class + + this.nodeHTMLid = nodeStructure.HTMLid; + + this.children = []; + + return this; + }, + + /** + * @returns {Tree} + */ + getTree: function() { + return TreeStore.get( this.treeId ); + }, + + /** + * @returns {object} + */ + getTreeConfig: function() { + return this.getTree().CONFIG; + }, + + /** + * @returns {NodeDB} + */ + getTreeNodeDb: function() { + return this.getTree().getNodeDb(); + }, + + /** + * @param {number} nodeId + * @returns {TreeNode} + */ + lookupNode: function( nodeId ) { + return this.getTreeNodeDb().get( nodeId ); + }, + + /** + * @returns {Tree} + */ + Tree: function() { + return TreeStore.get( this.treeId ); + }, + + /** + * @param {number} nodeId + * @returns {TreeNode} + */ + dbGet: function( nodeId ) { + return this.getTreeNodeDb().get( nodeId ); + }, + + /** + * Returns the width of the node + * @returns {float} + */ + size: function() { + var orientation = this.getTreeConfig().rootOrientation; + + if ( this.pseudo ) { + // prevents separating the subtrees + return ( -this.getTreeConfig().subTeeSeparation ); + } + + if ( orientation === 'NORTH' || orientation === 'SOUTH' ) { + return this.width; + } + else if ( orientation === 'WEST' || orientation === 'EAST' ) { + return this.height; + } + }, + + /** + * @returns {number} + */ + childrenCount: function () { + return ( ( this.collapsed || !this.children)? 0: this.children.length ); + }, + + /** + * @param {number} index + * @returns {TreeNode} + */ + childAt: function( index ) { + return this.dbGet( this.children[index] ); + }, + + /** + * @returns {TreeNode} + */ + firstChild: function() { + return this.childAt( 0 ); + }, + + /** + * @returns {TreeNode} + */ + lastChild: function() { + return this.childAt( this.children.length - 1 ); + }, + + /** + * @returns {TreeNode} + */ + parent: function() { + return this.lookupNode( this.parentId ); + }, + + /** + * @returns {TreeNode} + */ + leftNeighbor: function() { + if ( this.leftNeighborId ) { + return this.lookupNode( this.leftNeighborId ); + } + }, + + /** + * @returns {TreeNode} + */ + rightNeighbor: function() { + if ( this.rightNeighborId ) { + return this.lookupNode( this.rightNeighborId ); + } + }, + + /** + * @returns {TreeNode} + */ + leftSibling: function () { + var leftNeighbor = this.leftNeighbor(); + + if ( leftNeighbor && leftNeighbor.parentId === this.parentId ){ + return leftNeighbor; + } + }, + + /** + * @returns {TreeNode} + */ + rightSibling: function () { + var rightNeighbor = this.rightNeighbor(); + + if ( rightNeighbor && rightNeighbor.parentId === this.parentId ) { + return rightNeighbor; + } + }, + + /** + * @returns {number} + */ + childrenCenter: function () { + var first = this.firstChild(), + last = this.lastChild(); + + return ( first.prelim + ((last.prelim - first.prelim) + last.size()) / 2 ); + }, + + /** + * Find out if one of the node ancestors is collapsed + * @returns {*} + */ + collapsedParent: function() { + var parent = this.parent(); + if ( !parent ) { + return false; + } + if ( parent.collapsed ) { + return parent; + } + return parent.collapsedParent(); + }, + + /** + * Returns the leftmost child at specific level, (initial level = 0) + * @param level + * @param depth + * @returns {*} + */ + leftMost: function ( level, depth ) { + if ( level >= depth ) { + return this; + } + if ( this.childrenCount() === 0 ) { + return; + } + + for ( var i = 0, n = this.childrenCount(); i < n; i++ ) { + var leftmostDescendant = this.childAt( i ).leftMost( level + 1, depth ); + if ( leftmostDescendant ) { + return leftmostDescendant; + } + } + }, + + // returns start or the end point of the connector line, origin is upper-left + connectorPoint: function(startPoint) { + var orient = this.Tree().CONFIG.rootOrientation, point = {}; + + if ( this.stackParentId ) { // return different end point if node is a stacked child + if ( orient === 'NORTH' || orient === 'SOUTH' ) { + orient = 'WEST'; + } + else if ( orient === 'EAST' || orient === 'WEST' ) { + orient = 'NORTH'; + } + } + + // if pseudo, a virtual center is used + if ( orient === 'NORTH' ) { + point.x = (this.pseudo) ? this.X - this.Tree().CONFIG.subTeeSeparation/2 : this.X + this.width/2; + point.y = (startPoint) ? this.Y + this.height : this.Y; + } + else if (orient === 'SOUTH') { + point.x = (this.pseudo) ? this.X - this.Tree().CONFIG.subTeeSeparation/2 : this.X + this.width/2; + point.y = (startPoint) ? this.Y : this.Y + this.height; + } + else if (orient === 'EAST') { + point.x = (startPoint) ? this.X : this.X + this.width; + point.y = (this.pseudo) ? this.Y - this.Tree().CONFIG.subTeeSeparation/2 : this.Y + this.height/2; + } + else if (orient === 'WEST') { + point.x = (startPoint) ? this.X + this.width : this.X; + point.y = (this.pseudo) ? this.Y - this.Tree().CONFIG.subTeeSeparation/2 : this.Y + this.height/2; + } + return point; + }, + + /** + * @returns {string} + */ + pathStringThrough: function() { // get the geometry of a path going through the node + var startPoint = this.connectorPoint( true ), + endPoint = this.connectorPoint( false ); + + return ["M", startPoint.x+","+startPoint.y, 'L', endPoint.x+","+endPoint.y].join(" "); + }, + + /** + * @param {object} hidePoint + */ + drawLineThroughMe: function( hidePoint ) { // hidepoint se proslijedjuje ako je node sakriven zbog collapsed + var pathString = hidePoint? + this.Tree().getPointPathString( hidePoint ): + this.pathStringThrough(); + + this.lineThroughMe = this.lineThroughMe || this.Tree()._R.path(pathString); + + var line_style = UTIL.cloneObj( this.connStyle.style ); + + delete line_style['arrow-start']; + delete line_style['arrow-end']; + + this.lineThroughMe.attr( line_style ); + + if ( hidePoint ) { + this.lineThroughMe.hide(); + this.lineThroughMe.hidden = true; + } + }, + + addSwitchEvent: function( nodeSwitch ) { + var self = this; + UTIL.addEvent( nodeSwitch, 'click', + function( e ) { + e.preventDefault(); + if ( self.getTreeConfig().callback.onBeforeClickCollapseSwitch.apply( self, [ nodeSwitch, e ] ) === false ) { + return false; + } + + self.toggleCollapse(); + + self.getTreeConfig().callback.onAfterClickCollapseSwitch.apply( self, [ nodeSwitch, e ] ); + } + ); + }, + + /** + * @returns {TreeNode} + */ + collapse: function() { + if ( !this.collapsed ) { + this.toggleCollapse(); + } + return this; + }, + + /** + * @returns {TreeNode} + */ + expand: function() { + if ( this.collapsed ) { + this.toggleCollapse(); + } + return this; + }, + + /** + * @returns {TreeNode} + */ + toggleCollapse: function() { + var oTree = this.getTree(); + + if ( !oTree.inAnimation ) { + oTree.inAnimation = true; + + this.collapsed = !this.collapsed; // toggle the collapse at each click + UTIL.toggleClass( this.nodeDOM, 'collapsed', this.collapsed ); + + oTree.positionTree(); + + var self = this; + + setTimeout( + function() { // set the flag after the animation + oTree.inAnimation = false; + oTree.CONFIG.callback.onToggleCollapseFinished.apply( oTree, [ self, self.collapsed ] ); + }, + ( oTree.CONFIG.animation.nodeSpeed > oTree.CONFIG.animation.connectorsSpeed )? + oTree.CONFIG.animation.nodeSpeed: + oTree.CONFIG.animation.connectorsSpeed + ); + } + return this; + }, + + hide: function( collapse_to_point ) { + collapse_to_point = collapse_to_point || false; + + var bCurrentState = this.hidden; + this.hidden = true; + + this.nodeDOM.style.overflow = 'hidden'; + + var tree = this.getTree(), + config = this.getTreeConfig(), + oNewState = { + opacity: 0 + }; + + if ( collapse_to_point ) { + oNewState.left = collapse_to_point.x; + oNewState.top = collapse_to_point.y; + } + + // if parent was hidden in initial configuration, position the node behind the parent without animations + if ( !this.positioned || bCurrentState ) { + this.nodeDOM.style.visibility = 'hidden'; + if ( $ ) { + $( this.nodeDOM ).css( oNewState ); + } + else { + this.nodeDOM.style.left = oNewState.left + 'px'; + this.nodeDOM.style.top = oNewState.top + 'px'; + } + this.positioned = true; + } + else { + // todo: fix flashy bug when a node is manually hidden and tree.redraw is called. + if ( $ ) { + $( this.nodeDOM ).animate( + oNewState, config.animation.nodeSpeed, config.animation.nodeAnimation, + function () { + this.style.visibility = 'hidden'; + } + ); + } + else { + this.nodeDOM.style.transition = 'all '+config.animation.nodeSpeed+'ms ease'; + this.nodeDOM.style.transitionProperty = 'opacity, left, top'; + this.nodeDOM.style.opacity = oNewState.opacity; + this.nodeDOM.style.left = oNewState.left + 'px'; + this.nodeDOM.style.top = oNewState.top + 'px'; + this.nodeDOM.style.visibility = 'hidden'; + } + } + + // animate the line through node if the line exists + if ( this.lineThroughMe ) { + var new_path = tree.getPointPathString( collapse_to_point ); + if ( bCurrentState ) { + // update without animations + this.lineThroughMe.attr( { path: new_path } ); + } + else { + // update with animations + tree.animatePath( this.lineThroughMe, tree.getPointPathString( collapse_to_point ) ); + } + } + + return this; + }, + + /** + * @returns {TreeNode} + */ + hideConnector: function() { + var oTree = this.Tree(); + var oPath = oTree.connectionStore[this.id]; + if ( oPath ) { + oPath.animate( + { 'opacity': 0 }, + oTree.CONFIG.animation.connectorsSpeed, + oTree.CONFIG.animation.connectorsAnimation + ); + } + return this; + }, + + show: function() { + var bCurrentState = this.hidden; + this.hidden = false; + + this.nodeDOM.style.visibility = 'visible'; + + var oTree = this.Tree(); + + var oNewState = { + left: this.X, + top: this.Y, + opacity: 1 + }, + config = this.getTreeConfig(); + + // if the node was hidden, update opacity and position + if ( $ ) { + $( this.nodeDOM ).animate( + oNewState, + config.animation.nodeSpeed, config.animation.nodeAnimation, + function () { + // $.animate applies "overflow:hidden" to the node, remove it to avoid visual problems + this.style.overflow = ""; + } + ); + } + else { + this.nodeDOM.style.transition = 'all '+config.animation.nodeSpeed+'ms ease'; + this.nodeDOM.style.transitionProperty = 'opacity, left, top'; + this.nodeDOM.style.left = oNewState.left + 'px'; + this.nodeDOM.style.top = oNewState.top + 'px'; + this.nodeDOM.style.opacity = oNewState.opacity; + this.nodeDOM.style.overflow = ''; + } + + if ( this.lineThroughMe ) { + this.getTree().animatePath( this.lineThroughMe, this.pathStringThrough() ); + } + + return this; + }, + + /** + * @returns {TreeNode} + */ + showConnector: function() { + var oTree = this.Tree(); + var oPath = oTree.connectionStore[this.id]; + if ( oPath ) { + oPath.animate( + { 'opacity': 1 }, + oTree.CONFIG.animation.connectorsSpeed, + oTree.CONFIG.animation.connectorsAnimation + ); + } + return this; + } + }; + + + /** + * Build a node from the 'text' and 'img' property and return with it. + * + * The node will contain all the fields that present under the 'text' property + * Each field will refer to a css class with name defined as node-{$property_name} + * + * Example: + * The definition: + * + * text: { + * desc: "some description", + * paragraph: "some text" + * } + * + * will generate the following elements: + * + *

some description

+ *

some text

+ * + * @Returns the configured node + */ + TreeNode.prototype.buildNodeFromText = function (node) { + // IMAGE + if (this.image) { + image = document.createElement('img'); + image.src = this.image; + node.appendChild(image); + } + + // TEXT + if (this.text) { + for (var key in this.text) { + // adding DATA Attributes to the node + if (key.startsWith("data-")) { + node.setAttribute(key, this.text[key]); + } else { + + var textElement = document.createElement(this.text[key].href ? 'a' : 'p'); + + // make an element if required + if (this.text[key].href) { + textElement.href = this.text[key].href; + if (this.text[key].target) { + textElement.target = this.text[key].target; + } + } + + textElement.className = "node-"+key; + textElement.appendChild(document.createTextNode( + this.text[key].val ? this.text[key].val : + this.text[key] instanceof Object ? "'val' param missing!" : this.text[key] + ) + ); + + node.appendChild(textElement); + } + } + } + return node; + }; + + /** + * Build a node from 'nodeInnerHTML' property that defines an existing HTML element, referenced by it's id, e.g: #someElement + * Change the text in the passed node to 'Wrong ID selector' if the referenced element does ot exist, + * return with a cloned and configured node otherwise + * + * @Returns node the configured node + */ + TreeNode.prototype.buildNodeFromHtml = function(node) { + // get some element by ID and clone its structure into a node + if (this.nodeInnerHTML.charAt(0) === "#") { + var elem = document.getElementById(this.nodeInnerHTML.substring(1)); + if (elem) { + node = elem.cloneNode(true); + node.id += "-clone"; + node.className += " node"; + } + else { + node.innerHTML = " Wrong ID selector "; + } + } + else { + // insert your custom HTML into a node + node.innerHTML = this.nodeInnerHTML; + } + return node; + }; + + /** + * @param {Tree} tree + */ + TreeNode.prototype.createGeometry = function( tree ) { + if ( this.id === 0 && tree.CONFIG.hideRootNode ) { + this.width = 0; + this.height = 0; + return; + } + + var drawArea = tree.drawArea, + image, + + /////////// CREATE NODE ////////////// + node = document.createElement( this.link.href? 'a': 'div' ); + + node.className = ( !this.pseudo )? TreeNode.CONFIG.nodeHTMLclass: 'pseudo'; + if ( this.nodeHTMLclass && !this.pseudo ) { + node.className += ' ' + this.nodeHTMLclass; + } + + if ( this.nodeHTMLid ) { + node.id = this.nodeHTMLid; + } + + if ( this.link.href ) { + node.href = this.link.href; + node.target = this.link.target; + } + + if ( $ ) { + $( node ).data( 'treenode', this ); + } + else { + node.data = { + 'treenode': this + }; + } + + /////////// BUILD NODE CONTENT ////////////// + if ( !this.pseudo ) { + node = this.nodeInnerHTML? this.buildNodeFromHtml(node) : this.buildNodeFromText(node) + + // handle collapse switch + if ( this.collapsed || (this.collapsable && this.childrenCount() && !this.stackParentId) ) { + this.createSwitchGeometry( tree, node ); + } + } + + tree.CONFIG.callback.onCreateNode.apply( tree, [this, node] ); + + /////////// APPEND all ////////////// + drawArea.appendChild(node); + + this.width = node.offsetWidth; + this.height = node.offsetHeight; + + this.nodeDOM = node; + + tree.imageLoader.processNode(this); + }; + + /** + * @param {Tree} tree + * @param {Element} nodeEl + */ + TreeNode.prototype.createSwitchGeometry = function( tree, nodeEl ) { + nodeEl = nodeEl || this.nodeDOM; + + // safe guard and check to see if it has a collapse switch + var nodeSwitchEl = UTIL.findEl( '.collapse-switch', true, nodeEl ); + if ( !nodeSwitchEl ) { + nodeSwitchEl = document.createElement( 'a' ); + nodeSwitchEl.className = "collapse-switch"; + + nodeEl.appendChild( nodeSwitchEl ); + this.addSwitchEvent( nodeSwitchEl ); + if ( this.collapsed ) { + nodeEl.className += " collapsed"; + } + + tree.CONFIG.callback.onCreateNodeCollapseSwitch.apply( tree, [this, nodeEl, nodeSwitchEl] ); + } + return nodeSwitchEl; + }; + + + // ########################################### + // Expose global + default CONFIG params + // ########################################### + + + Tree.CONFIG = { + maxDepth: 100, + rootOrientation: 'NORTH', // NORTH || EAST || WEST || SOUTH + nodeAlign: 'CENTER', // CENTER || TOP || BOTTOM + levelSeparation: 30, + siblingSeparation: 30, + subTeeSeparation: 30, + + hideRootNode: false, + + animateOnInit: false, + animateOnInitDelay: 500, + + padding: 15, // the difference is seen only when the scrollbar is shown + scrollbar: 'native', // "native" || "fancy" || "None" (PS: "fancy" requires jquery and perfect-scrollbar) + + connectors: { + type: 'curve', // 'curve' || 'step' || 'straight' || 'bCurve' + style: { + stroke: 'black' + }, + stackIndent: 15 + }, + + node: { // each node inherits this, it can all be overridden in node config + + // HTMLclass: 'node', + // drawLineThrough: false, + // collapsable: false, + link: { + target: '_self' + } + }, + + animation: { // each node inherits this, it can all be overridden in node config + nodeSpeed: 450, + nodeAnimation: 'linear', + connectorsSpeed: 450, + connectorsAnimation: 'linear' + }, + + callback: { + onCreateNode: function( treeNode, treeNodeDom ) {}, // this = Tree + onCreateNodeCollapseSwitch: function( treeNode, treeNodeDom, switchDom ) {}, // this = Tree + onAfterAddNode: function( newTreeNode, parentTreeNode, nodeStructure ) {}, // this = Tree + onBeforeAddNode: function( parentTreeNode, nodeStructure ) {}, // this = Tree + onAfterPositionNode: function( treeNode, nodeDbIndex, containerCenter, treeCenter) {}, // this = Tree + onBeforePositionNode: function( treeNode, nodeDbIndex, containerCenter, treeCenter) {}, // this = Tree + onToggleCollapseFinished: function ( treeNode, bIsCollapsed ) {}, // this = Tree + onAfterClickCollapseSwitch: function( nodeSwitch, event ) {}, // this = TreeNode + onBeforeClickCollapseSwitch: function( nodeSwitch, event ) {}, // this = TreeNode + onTreeLoaded: function( rootTreeNode ) {} // this = Tree + } + }; + + TreeNode.CONFIG = { + nodeHTMLclass: 'node' + }; + + // ############################################# + // Makes a JSON chart config out of Array config + // ############################################# + + var JSONconfig = { + make: function( configArray ) { + + var i = configArray.length, node; + + this.jsonStructure = { + chart: null, + nodeStructure: null + }; + //fist loop: find config, find root; + while(i--) { + node = configArray[i]; + if (node.hasOwnProperty('container')) { + this.jsonStructure.chart = node; + continue; + } + + if (!node.hasOwnProperty('parent') && ! node.hasOwnProperty('container')) { + this.jsonStructure.nodeStructure = node; + node._json_id = 0; + } + } + + this.findChildren(configArray); + + return this.jsonStructure; + }, + + findChildren: function(nodes) { + var parents = [0]; // start with a a root node + + while(parents.length) { + var parentId = parents.pop(), + parent = this.findNode(this.jsonStructure.nodeStructure, parentId), + i = 0, len = nodes.length, + children = []; + + for(;i Date: Tue, 21 Jan 2025 00:34:14 +0100 Subject: [PATCH 07/55] Add serve-evaluation-app command to serve the evaluation webapp using http.server --- src/qlever/commands/serve_evaluation_app.py | 62 +++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/qlever/commands/serve_evaluation_app.py diff --git a/src/qlever/commands/serve_evaluation_app.py b/src/qlever/commands/serve_evaluation_app.py new file mode 100644 index 00000000..c3de25d8 --- /dev/null +++ b/src/qlever/commands/serve_evaluation_app.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from functools import partial +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path + +from qlever.command import QleverCommand +from qlever.log import log + +EVAL_DIR = Path(__file__).parent.parent / "evaluation" + + +class ServeEvaluationAppCommand(QleverCommand): + """ + Class for executing the `serve-evaluation-app` command. + """ + + def __init__(self): + pass + + def description(self) -> str: + return "Serve the SPARQL Engine performance comparison webapp" + + def should_have_qleverfile(self) -> bool: + return False + + def relevant_qleverfile_arguments(self) -> dict[str : list[str]]: + return {} + + def additional_arguments(self, subparser) -> None: + subparser.add_argument( + "--port", + type=int, + default=8000, + help=( + "Port where the Performance comparison webapp will be " + "served (Default = 8000)" + ), + ) + subparser.add_argument( + "--show-files-only", + action="store_true", + default=False, + help="Show list of yaml files that will be used for comparison", + ) + + def execute(self, args) -> bool: + if args.show_files_only: + output_dir = EVAL_DIR / "output" + for yaml_file in output_dir.iterdir(): + if yaml_file.is_file(): + log.info(yaml_file.name) + return True + + handler = partial(SimpleHTTPRequestHandler, directory=EVAL_DIR) + httpd = HTTPServer(("", args.port), handler) + log.info( + f"Performance Comparison Web App is available at " + f"http://localhost:{args.port}/www" + ) + httpd.serve_forever() + return True From 19b2d43c35f522d0efbe790536cef190eaa40ffb Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 24 Jan 2025 22:59:23 +0100 Subject: [PATCH 08/55] Make the comparison web app work with new yaml results file --- .../evaluation/www/compare-exec-trees.js | 10 +-- .../evaluation/www/engines-comparison.js | 56 +++--------- src/qlever/evaluation/www/helper.js | 40 ++++++++- src/qlever/evaluation/www/main.js | 89 +++++++------------ src/qlever/evaluation/www/query-details.js | 12 +-- 5 files changed, 90 insertions(+), 117 deletions(-) diff --git a/src/qlever/evaluation/www/compare-exec-trees.js b/src/qlever/evaluation/www/compare-exec-trees.js index 7f82f6b8..0b9a8201 100644 --- a/src/qlever/evaluation/www/compare-exec-trees.js +++ b/src/qlever/evaluation/www/compare-exec-trees.js @@ -168,11 +168,11 @@ function showCompareExecTrees(purpose, select1, select2, kb, queryIndex, idOfTre } } document.querySelector("#runtimeQuery").textContent = - "Query: " + performanceDataPerKb[kb][qlevers[0].toLowerCase()][queryIndex]["Query ID"]; + "Query: " + performanceDataPerKb[kb][qlevers[0].toLowerCase()]["queries"][queryIndex]["query"]; document.querySelector("#runtimeQuery").title = - performanceDataPerKb[kb][qlevers[0].toLowerCase()][queryIndex]["Query"]; + performanceDataPerKb[kb][qlevers[0].toLowerCase()]["queries"][queryIndex]["sparql"]; qlevers.forEach((engine, index) => { - let runtime = performanceDataPerKb[kb][engine.toLowerCase()][queryIndex]["Runtime"].query_execution_tree; + let runtime = performanceDataPerKb[kb][engine.toLowerCase()]["queries"][queryIndex].runtime_info.query_execution_tree; let treeid = "#tree" + (index + 1).toString(); if (purpose === "modalShow" || idOfTreeToZoom === treeid) { document.querySelector(treeid).replaceChildren(); @@ -390,7 +390,7 @@ function generateExecutionTree(queryRow, purpose, treeid, currentFontSize) { .textContent.substring("SPARQL Engine - ".length) .toLowerCase(); const queryIndex = document.querySelector("#queryList").querySelector(".table-active").rowIndex - 1; - let runtime = performanceDataPerKb[kb][engine][queryIndex]["Runtime"].query_execution_tree; + let runtime = performanceDataPerKb[kb][engine]["queries"][queryIndex].runtime_info.query_execution_tree; document.querySelector(treeid).replaceChildren(); let tree = createExecTree(runtime, treeid); drawExecTree(tree, treeid, purpose, currentFontSize); @@ -399,7 +399,7 @@ function generateExecutionTree(queryRow, purpose, treeid, currentFontSize) { if (!queryRow.runtime_info || !Object.hasOwn(queryRow.runtime_info, "query_execution_tree")) { document.getElementById("tab3Content").replaceChildren(); document.getElementById("result-tree").replaceChildren(); - if (queryRow.result) { + if (queryRow.results) { document .querySelector("#tab3Content") .replaceChildren(document.createTextNode("Execution tree not available for this engine!")); diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index e221f245..0f70e2a3 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -56,7 +56,7 @@ function setListenersForEnginesComparison() { document.querySelector("#comparisonModal").addEventListener("shown.bs.modal", async function () { const kb = document.querySelector("#comparisonModal").getAttribute("data-kb"); if (kb) { - await openComparisonModal(kb); + openComparisonModal(kb); } // Scroll to the previously selected row if user is coming back to this modal const activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); @@ -139,7 +139,7 @@ function handleCompareResultsClick(kb) { * @async * @param {string} kb - The selected knowledge base */ -async function openComparisonModal(kb) { +function openComparisonModal(kb) { // Open the previously opened modal without any processing overhead if kb is the same if (document.querySelector("#comparisonKb").innerHTML === kb) { return; @@ -154,7 +154,7 @@ async function openComparisonModal(kb) { select1.innerHTML = ""; select2.innerHTML = ""; for (let engine of sparqlEngines) { - await addRuntimeToPerformanceDataPerKb(kb, engine); + //await addRuntimeToPerformanceDataPerKb(kb, engine); if (performanceDataPerKb[kb].hasOwnProperty(engine) && execTreeEngines.includes(engine)) { select1.add(new Option(engine)); select2.add(new Option(engine)); @@ -201,40 +201,6 @@ async function openComparisonModal(kb) { hideSpinner(); } -/** - * - Fetch the yaml file query logs for the selected kb and engine - * - Add full sparql query and runtime info (including execution tree) to PerformanceDataPerKb - * - * @async - * @param {string} kb - The selected knowledge base - * @param {string} engine - The selected sparql engine - */ -async function addRuntimeToPerformanceDataPerKb(kb, engine) { - let queryResult = null; - if ( - performanceDataPerKb[kb][engine.toLowerCase()]?.length && - !performanceDataPerKb[kb][engine.toLowerCase()][0].hasOwnProperty("Query") - ) { - const queryLog = getQueryLog(engine.toLowerCase(), kb); - queryResult = await getYamlData(queryLog); - if (!queryResult) queryResult = []; - if (queryResult && !execTreeEngines.includes(engine)) { - for (query of queryResult) { - if (Array.isArray(query.result) && query.result.length > 0) { - if (query.runtime_info.hasOwnProperty("query_execution_tree") && !execTreeEngines.includes(engine)) { - execTreeEngines.push(engine); - } - break; - } - } - } - for (i = 0; i < queryResult.length; i++) { - performanceDataPerKb[kb][engine.toLowerCase()][i]["Query"] = queryResult[i].sparql; - performanceDataPerKb[kb][engine.toLowerCase()][i]["Runtime"] = queryResult[i].runtime_info; - } - } -} - /** * Uses performanceDataPerKb object to create the engine runtime for each query comparison table * Gives the user the ability to selectively hide queries to reduce the clutter @@ -262,8 +228,8 @@ function createCompareResultsTable(kb) { const engines = Object.keys(performanceDataPerKb[kb]); let engineIndexForQueriesList = 0; for (let i = 0; i < engines.length; i++) { - if (performanceDataPerKb[kb][engines[i]].length > queryCount) { - queryCount = performanceDataPerKb[kb][engines[i]].length; + if (performanceDataPerKb[kb][engines[i]]["queries"].length > queryCount) { + queryCount = performanceDataPerKb[kb][engines[i]]["queries"].length; engineIndexForQueriesList = i; } headerRow.innerHTML += `${engines[i]}`; @@ -282,19 +248,19 @@ function createCompareResultsTable(kb) { `; row.style.cursor = "pointer"; - const title = performanceDataPerKb[kb][engines[engineIndexForQueriesList]][i]["Query"]; + const title = performanceDataPerKb[kb][engines[engineIndexForQueriesList]]["queries"][i]["sparql"]; row.innerHTML += `${ - performanceDataPerKb[kb][engines[engineIndexForQueriesList]][i]["Query ID"] + performanceDataPerKb[kb][engines[engineIndexForQueriesList]]["queries"][i]["query"] }`; for (let engine of engines) { - const result = performanceDataPerKb[kb][engine][i]; + const result = performanceDataPerKb[kb][engine]["queries"][i]; if (!result) { row.innerHTML += "N/A"; continue; } - let runtime = result["Total Client Time (ms)"]; - let resultClass = result["Query Failed"] === "True" ? "bg-danger bg-opacity-25" : ""; - let runtimeText = runtime != "" ? `${formatNumber(parseFloat(runtime) / 1000)} s` : "Failed"; + let runtime = result.runtime_info.client_time; + let resultClass = result.headers.length === 0 || !Array.isArray(result.results) ? "bg-danger bg-opacity-25" : ""; + let runtimeText = `${formatNumber(parseFloat(runtime))} s`; row.innerHTML += `${runtimeText}`; } row.addEventListener("click", function () { diff --git a/src/qlever/evaluation/www/helper.js b/src/qlever/evaluation/www/helper.js index 2c4a823a..4b7643a3 100644 --- a/src/qlever/evaluation/www/helper.js +++ b/src/qlever/evaluation/www/helper.js @@ -70,16 +70,20 @@ function getFailLog(engine, kb) { * @returns {Promise} A promise that resolves to an array of objects parsed from the YAML file. * Will log an error if fetching or processing the YAML file fails. */ -async function getYamlData(yamlFileUrl) { +async function getYamlData(yamlFileUrl, headers = {}) { // Fetch the YAML file and process its content try { - const response = await fetch(outputUrl + yamlFileUrl); + const response = await fetch(outputUrl + yamlFileUrl, { headers }); + if (!response.ok) { + throw new Error(`Failed to fetch ${yamlFileUrl}`); + } const yamlContent = await response.text(); // Split the content into rows - const data = jsyaml.loadAll(yamlContent); + const data = jsyaml.load(yamlContent); return data; } catch (error) { console.error("Error fetching or processing YAML file:", error); + return null; } } @@ -150,6 +154,36 @@ function getTsvData(tsvContent) { } } +function addQueryStatistics(queryData) { + let runtimeArray = []; + let totalTime = 0; + let queriesUnder1s = 0; + let queriesOver5s = 0; + let failedQueries = 0; + for (const query of queryData.queries) { + let runtime = parseFloat(query.runtime_info.client_time); + runtimeArray.push(runtime); + totalTime += runtime; + if (query.headers.length === 0 && typeof(query.results) == "string") { + failedQueries++ + } + else { + if (runtime < 1) { + queriesUnder1s++; + } + if (runtime > 5) { + queriesOver5s++; + } + } + } + queryData.avgTime = totalTime / queryData.queries.length; + queryData.medianTime = median(runtimeArray); + queryData.under1s = (queriesUnder1s / queryData.queries.length) * 100; + queryData.over5s = (queriesOver5s / queryData.queries.length) * 100; + queryData.failed = (failedQueries / queryData.queries.length) * 100; + queryData.between1to5s = 100 - queryData.under1s - queryData.over5s - queryData.failed; +} + /** * Processes the content of a TXT file and returns its lines as an array of strings. * diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index 36daf1ba..aa17318f 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -5,34 +5,35 @@ * @param data metrics for each SPARQL engine for the given knowledge base * @return A bootstrap card displaying SPARQL Engine metrics for the given kb */ -function populateCard(cardTemplate, kb, data) { +function populateCard(cardTemplate, kb) { const clone = document.importNode(cardTemplate.content, true); const cardTitle = clone.querySelector("h5"); clone.querySelector("button").addEventListener("click", handleCompareResultsClick.bind(null, kb)); cardTitle.innerHTML = kb[0].toUpperCase() + kb.slice(1); const cardBody = clone.querySelector("tbody"); - data.forEach((engine) => { + Object.keys(performanceDataPerKb[kb]).forEach((engine) => { + const engineData = performanceDataPerKb[kb][engine]; const row = document.createElement("tr"); row.style.cursor = "pointer"; row.addEventListener("click", handleRowClick); row.innerHTML = ` - ${engine.engine} - ${engine.failed}% + ${engine} + ${engineData.failed}% ${ - engine.avgRuntime ? formatNumber(parseFloat(engine.avgRuntime)) : "N/A" + formatNumber(parseFloat(engineData.avgTime)) } ${ - engine.medianRuntime ? formatNumber(parseFloat(engine.medianRuntime)) : "N/A" + formatNumber(parseFloat(engineData.medianTime)) } ${ - engine.under_1s ? formatNumber(parseFloat(engine.under_1s)) + "%" : "N/A" + formatNumber(parseFloat(engineData.under1s)) } ${ - engine.between_1_to_5s ? formatNumber(parseFloat(engine.between_1_to_5s)) + "%" : "N/A" + formatNumber(parseFloat(engineData.between1to5s)) + "%" } ${ - engine.over_5s ? formatNumber(parseFloat(engine.over_5s)) + "%" : "N/A" + formatNumber(parseFloat(engineData.over5s)) + "%" } `; cardBody.appendChild(row); @@ -88,15 +89,15 @@ function getFileUrls(fileList) { async function fetchAndProcessFiles(fileUrls) { const fetchPromises = fileUrls.map(async (url) => { try { - const response = await fetch(outputUrl + url, { + //const content = await response.text(); + const content = await getYamlData(url, { headers: { "Cache-Control": "no-cache", }, }); - if (!response.ok) { + if (content == null) { throw new Error(`Failed to fetch ${url}`); } - const content = await response.text(); console.log(`File ${url} content: ${content}`); // Process the content as needed return { status: "fulfilled", value: content }; @@ -112,45 +113,21 @@ async function fetchAndProcessFiles(fileUrls) { } /** - * Fetch all the relevant metrics required by - * populateCard function and construct and display all cards with engine metrics on the main page. - * @param results Array withresults from fetching eval and fail logs - * @param fileUrls fileUrls array from getFileUrls function - * @param fragment Document fragment to which to append the bootstrap cards - * @param cardTemplate cardTemplate document node + * Fetch all the relevant metrics required by populateCard function + * @param results Array withresults from fetching yaml file + * @param fileList fileList array from getOutputFiles function */ -function processAndDisplayResults(results, fileUrls, fragment, cardTemplate) { - sparqlEngineData = []; - let currentKb = fileUrls[0].split(".")[0]; - for (let i = 0; i < results.length; i += 2) { - let data = {}; - let evalUrl = fileUrls[i].split("."); - const kb = evalUrl[0]; - const engine = evalUrl[1]; - data["engine"] = engine; +function processResults(results, fileList) { + for (let i = 0; i < results.length; i++) { + let fileNameComponents = fileList[i].split("."); + const kb = fileNameComponents[0]; + const engine = fileNameComponents[1]; if (results[i].status == "fulfilled" && results[i].value.status == "fulfilled") { - let evalResult = getTsvData(results[i].value.value); - performanceDataPerKb[kb][engine] = evalResult.data; - data["avgRuntime"] = evalResult.avgTime.toFixed(2); - data["medianRuntime"] = evalResult.medianTime.toFixed(2); - data["under_1s"] = evalResult.under_1s.toFixed(2); - data["over_5s"] = evalResult.over_5s.toFixed(2); - data["between_1_to_5s"] = evalResult.between_1_to_5s.toFixed(2); - data["failed"] = evalResult.failed.toFixed(2); - } - if (results[i + 1].status == "fulfilled" && results[i + 1].value.status == "fulfilled") { - let failResult = getTxtData(results[i + 1].value.value); - data["failedQueries"] = failResult.length; - } - if (kb != currentKb) { - fragment.appendChild(populateCard(cardTemplate, currentKb, sparqlEngineData)); - sparqlEngineData = []; - currentKb = kb; + const queryData = results[i].value.value; + addQueryStatistics(queryData); + performanceDataPerKb[kb][engine] = queryData; } - sparqlEngineData.push(data); } - fragment.appendChild(populateCard(cardTemplate, currentKb, sparqlEngineData)); - document.getElementById("cardsContainer").appendChild(fragment); } /** @@ -169,7 +146,7 @@ async function getOutputFiles(url) { const fileList = Array.from(htmlDoc.querySelectorAll("a")).map((link) => link.textContent.trim()); for (const file of fileList) { const parts = file.split("."); - if (parts.length === 5 && parts[2] === "queries") { + if (parts.length === 4 && parts[2] === "results") { const kb = parts[0]; if (!kbs.includes(kb)) kbs.push(kb); const engine = parts[1]; @@ -274,10 +251,6 @@ async function showPageFromUrl() { break; case "compareExecTrees": - // Add runtime_info to PerformanceDataPerKb so that trees can be displayed - for (const engine of sparqlEngines) { - await addRuntimeToPerformanceDataPerKb(kb, engine); - } showModal(compareExecTreesModal, { "data-kb": kb, "data-s1": urlParams.get("s1")?.toLowerCase(), @@ -323,11 +296,17 @@ document.addEventListener("DOMContentLoaded", async function () { const fragment = document.createDocumentFragment(); - const fileUrls = getFileUrls(fileList); + for (let kb of kbs) { + performanceDataPerKb[kb.toLowerCase()] = {}; + } // For all the tsv files in the output folder, create bootstrap card and display on main page - fetchAndProcessFiles(fileUrls).then(async (results) => { - processAndDisplayResults(results, fileUrls, fragment, cardTemplate); + fetchAndProcessFiles(fileList).then(async (results) => { + processResults(results, fileList, fragment, cardTemplate); + for (const kb of Object.keys(performanceDataPerKb)) { + fragment.appendChild(populateCard(cardTemplate, kb)); + } + document.getElementById("cardsContainer").appendChild(fragment); $("#cardsContainer table").tablesorter({ theme: "bootstrap", sortStable: true, diff --git a/src/qlever/evaluation/www/query-details.js b/src/qlever/evaluation/www/query-details.js index b198fb3e..71c4eee2 100644 --- a/src/qlever/evaluation/www/query-details.js +++ b/src/qlever/evaluation/www/query-details.js @@ -180,10 +180,8 @@ async function openQueryDetailsModal(kb, engine, selectedQuery, tabToOpen) { tab1Content.querySelector(".card-title").innerHTML = "Knowledge Graph - " + kb; const tabBody = modalNode.querySelector("#queryList"); - const queryLog = getQueryLog(engine, kb); - const queryResult = await getYamlData(queryLog); + const queryResult = performanceDataPerKb[kb][engine]["queries"]; - if (!queryResult) queryResult = []; if ( queryResult && queryResult[0].runtime_info.hasOwnProperty("query_execution_tree") && @@ -232,21 +230,17 @@ async function openQueryDetailsModal(kb, engine, selectedQuery, tabToOpen) { */ function createQueryTable(queryResult, kb, engine, tabBody) { queryResult.forEach((query, i) => { - performanceDataPerKb[kb][engine][i]["Query"] = query.sparql; - performanceDataPerKb[kb][engine][i]["Runtime"] = query.runtime_info; const tabRow = document.createElement("tr"); tabRow.style.cursor = "pointer"; tabRow.addEventListener("click", handleTabRowClick.bind(null, query)); let runtime; if (query.runtime_info && Object.hasOwn(query.runtime_info, "client_time")) { runtime = formatNumber(query.runtime_info.client_time); - } else if (Number.isFinite(query.runtime_info)) { - runtime = formatNumber(query.runtime_info); } else { runtime = "N/A"; } - resultClass = query.result === null || !Array.isArray(query.result) ? "bg-danger bg-opacity-25" : ""; + resultClass = query.headers.length === 0 || !Array.isArray(query.results) ? "bg-danger bg-opacity-25" : ""; tabRow.innerHTML = ` ${query.query} ${runtime} @@ -294,7 +288,7 @@ function populateTabsFromSelectedRow(queryRow) { document.querySelector("#showMore").classList.replace("d-flex", "d-none"); const showMoreCloneButton = document.querySelector("#showMoreButton").cloneNode(true); document.querySelector("#showMore").replaceChild(showMoreCloneButton, document.querySelector("#showMoreButton")); - generateHTMLTable(queryRow.result); + generateHTMLTable(queryRow.results); generateExecutionTree(queryRow); } From 95c62ef7ce93f4946479310c2787c04af71105c1 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 24 Jan 2025 23:00:38 +0100 Subject: [PATCH 09/55] Add checkboxes to main page to select which engines to use for comparison when compareResults button is clicked --- src/qlever/evaluation/www/card-template.html | 3 + .../evaluation/www/engines-comparison.js | 29 +++++++++- src/qlever/evaluation/www/main.js | 58 +++++++------------ src/qlever/evaluation/www/query-details.js | 8 ++- 4 files changed, 58 insertions(+), 40 deletions(-) diff --git a/src/qlever/evaluation/www/card-template.html b/src/qlever/evaluation/www/card-template.html index 3b10385f..fbe27f17 100644 --- a/src/qlever/evaluation/www/card-template.html +++ b/src/qlever/evaluation/www/card-template.html @@ -13,6 +13,9 @@
Dataset
+ + + SPARQL Engine Queries Failed Avg Runtime (s) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index 0f70e2a3..13a9ca04 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -126,10 +126,30 @@ function compareExecutionTreesClicked() { * Display the modal page where all the engine runtimes are compared against each other on a per query basis */ function handleCompareResultsClick(kb) { + const enginesToDisplay = getEnginesToDisplay(kb); + if (enginesToDisplay.length === 0) { + alert("All engines are unselected from comparison! Choose at least one or ideally more for comparison!"); + return; + } document.querySelector("#comparisonModal").setAttribute("data-kb", kb); showModal(document.querySelector("#comparisonModal")); } +function getEnginesToDisplay(kb) { + for (const cardBody of document.querySelectorAll(".card-body")) { + const selectedKb = cardBody.querySelector("h5").innerHTML.toLowerCase(); + if (kb === selectedKb) { + let enginesToShow = []; + for (const row of cardBody.querySelectorAll("tbody tr")) { + if (row.children[0].firstElementChild.checked) { + enginesToShow.push(row.children[1].innerHTML.toLowerCase()) + } + } + return enginesToShow; + } + } +} + /** * - Updates the url with kb and pushes the page to history stack * - Fetches the query log and results based on the selected knowledge base (KB) and engine. @@ -140,6 +160,9 @@ function handleCompareResultsClick(kb) { * @param {string} kb - The selected knowledge base */ function openComparisonModal(kb) { + const enginesToDisplay = getEnginesToDisplay(kb); + console.log(enginesToDisplay); + // Open the previously opened modal without any processing overhead if kb is the same if (document.querySelector("#comparisonKb").innerHTML === kb) { return; @@ -185,7 +208,7 @@ function openComparisonModal(kb) { } else { // Create a DocumentFragment to build the table const fragment = document.createDocumentFragment(); - const table = createCompareResultsTable(kb); + const table = createCompareResultsTable(kb, enginesToDisplay); fragment.appendChild(table); @@ -207,7 +230,7 @@ function openComparisonModal(kb) { * @param kb Name of the knowledge base for which to get engine runtimes * @return HTML table with queries as rows and engine runtimes as columns */ -function createCompareResultsTable(kb) { +function createCompareResultsTable(kb, enginesToDisplay) { let queryCount = 0; const table = document.createElement("table"); table.classList.add("table", "table-hover", "table-bordered", "w-auto"); @@ -225,7 +248,7 @@ function createCompareResultsTable(kb) { `; // Create dynamic headers and add them to the header row headerRow.innerHTML += "Query"; - const engines = Object.keys(performanceDataPerKb[kb]); + const engines = enginesToDisplay; let engineIndexForQueriesList = 0; for (let i = 0; i < engines.length; i++) { if (performanceDataPerKb[kb][engines[i]]["queries"].length > queryCount) { diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index aa17318f..27898b3e 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -18,6 +18,9 @@ function populateCard(cardTemplate, kb) { row.style.cursor = "pointer"; row.addEventListener("click", handleRowClick); row.innerHTML = ` + + + ${engine} ${engineData.failed}% ${ @@ -37,47 +40,30 @@ function populateCard(cardTemplate, kb) { } `; cardBody.appendChild(row); + addEventListenersForCard(clone.querySelector("table")); }); return clone; } -/** - * Get urls for all the eval(tsv) and fail(txt) data - * @param fileList Array of file names in the output directory - * @return Array of eval and fail logs for each kb and engine combination - */ -function getFileUrls(fileList) { - const fileUrls = []; - const kb_engine_map = {}; - - for (let file of fileList) { - const parts = file.split("."); - if (parts.length === 5 && parts[2] === "queries") { - const kb = parts[0]; - const engine = parts[1]; - - if (!kb_engine_map[kb]) { - kb_engine_map[kb] = []; - } - - if (!kb_engine_map[kb].includes(engine)) { - kb_engine_map[kb].push(engine); - } - } - } +function addEventListenersForCard(cardNode) { + thead = cardNode.querySelector("thead"); + tbody = cardNode.querySelector("tbody"); + // If the header is checked, all the rows must be checked and vice-versa + thead.addEventListener("change", function (event) { + const headerCheckbox = event.target; + const rowCheckboxes = tbody.querySelectorAll(".row-checkbox"); + rowCheckboxes.forEach((checkbox) => { + checkbox.checked = headerCheckbox.checked; + }); + }); - for (let kb of kbs) { - performanceDataPerKb[kb.toLowerCase()] = {}; - for (let engine of sparqlEngines) { - if (kb_engine_map[kb].includes(engine)) { - const evalLog = getEvalLog(engine.toLowerCase(), kb.toLowerCase()); - const failLog = getFailLog(engine.toLowerCase(), kb.toLowerCase()); - fileUrls.push(evalLog); - fileUrls.push(failLog); - } - } - } - return fileUrls; + // Update the checker status of header based on if all rows are selected or not + tbody.addEventListener("change", function () { + const headerCheckbox = thead.querySelector("input"); + const rowCheckboxes = tbody.querySelectorAll(".row-checkbox"); + const allChecked = Array.from(rowCheckboxes).every((checkbox) => checkbox.checked); + headerCheckbox.checked = allChecked; + }); } /** diff --git a/src/qlever/evaluation/www/query-details.js b/src/qlever/evaluation/www/query-details.js index 71c4eee2..cc684049 100644 --- a/src/qlever/evaluation/www/query-details.js +++ b/src/qlever/evaluation/www/query-details.js @@ -140,8 +140,14 @@ function fixUrlAndState(totalQueries, selectedQuery, tab) { * @param {Event} event - The click event triggered by selecting a row. */ async function handleRowClick(event) { + if ( + event.target.classList.contains("row-checkbox") || + event.target.firstElementChild?.classList.contains("row-checkbox") + ) { + return; + } const kb = event.currentTarget.closest(".card").querySelector("h5").innerHTML.toLowerCase(); - const engine = event.currentTarget.querySelector("td").innerHTML.toLowerCase(); + const engine = event.currentTarget.children[1].innerHTML.toLowerCase(); const modalNode = document.querySelector("#queryDetailsModal"); modalNode.setAttribute("data-kb", kb); modalNode.setAttribute("data-engine", engine); From 132a0d84f6c0a36106942d7fadb534a36a38e1d7 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sat, 25 Jan 2025 15:18:29 +0100 Subject: [PATCH 10/55] Record query runtime even when the query errors out --- src/qlever/commands/example_queries.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 6fa875e0..6e7e47d3 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -490,18 +490,18 @@ def execute(self, args) -> bool: ) # Launch query. + curl_cmd = ( + f"curl -s {sparql_endpoint}" + f' -w "HTTP code: %{{http_code}}\\n"' + f' -H "Accept: {accept_header}"' + f" --data-urlencode query={shlex.quote(query)}" + ) + log.debug(curl_cmd) + result_file = ( + f"qlever.example_queries.result.{abs(hash(curl_cmd))}.tmp" + ) + start_time = time.time() try: - curl_cmd = ( - f"curl -s {sparql_endpoint}" - f' -w "HTTP code: %{{http_code}}\\n"' - f' -H "Accept: {accept_header}"' - f" --data-urlencode query={shlex.quote(query)}" - ) - log.debug(curl_cmd) - result_file = ( - f"qlever.example_queries.result.{abs(hash(curl_cmd))}.tmp" - ) - start_time = time.time() http_code = run_curl_command( sparql_endpoint, headers={"Accept": accept_header}, @@ -512,6 +512,7 @@ def execute(self, args) -> bool: time_seconds = time.time() - start_time error_msg = None else: + time_seconds = time.time() - start_time error_msg = { "short": f"HTTP code: {http_code}", "long": re.sub( @@ -519,6 +520,7 @@ def execute(self, args) -> bool: ), } except Exception as e: + time_seconds = time.time() - start_time if args.log_level == "DEBUG": traceback.print_exc() error_msg = { @@ -767,6 +769,7 @@ def get_record_for_yaml( results = f"{result['short']}: {result['long']}" headers = [] else: + record["result_size"] = result_size result_size = ( MAX_RESULT_SIZE if result_size > MAX_RESULT_SIZE From 36c40151e62d49cf2195923eb169783f3b55f924 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sat, 25 Jan 2025 22:12:26 +0100 Subject: [PATCH 11/55] Added header row and result_size to query results table --- src/qlever/evaluation/www/query-details.js | 27 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/qlever/evaluation/www/query-details.js b/src/qlever/evaluation/www/query-details.js index cc684049..65c4181c 100644 --- a/src/qlever/evaluation/www/query-details.js +++ b/src/qlever/evaluation/www/query-details.js @@ -294,7 +294,7 @@ function populateTabsFromSelectedRow(queryRow) { document.querySelector("#showMore").classList.replace("d-flex", "d-none"); const showMoreCloneButton = document.querySelector("#showMoreButton").cloneNode(true); document.querySelector("#showMore").replaceChild(showMoreCloneButton, document.querySelector("#showMoreButton")); - generateHTMLTable(queryRow.results); + generateHTMLTable(queryRow); generateExecutionTree(queryRow); } @@ -321,10 +321,20 @@ function showTab(tabIndex) { * * @param {string[][]} results - A 2D array representing the query results. */ -function generateQueryResultsTable(results) { +function generateQueryResultsTable(results, headers) { const table = document.getElementById("resultsTable"); const tableFragment = document.createDocumentFragment(); + if (headers) { + const headerRow = document.createElement("tr"); + for (const header of headers) { + const headerCell = document.createElement("th"); + headerCell.textContent = header; + headerRow.appendChild(headerCell); + } + tableFragment.appendChild(headerRow); + } + const index = results.length > 1000 ? 1000 : results.length; // Create table rows for (let i = 0; i < index; i++) { @@ -383,7 +393,9 @@ function displayMoreResults(results) { * * @param {string[][] | Object} tableData - A 2D array of query results or an error message object. */ -function generateHTMLTable(tableData) { +function generateHTMLTable(queryRow) { + const tableData = queryRow.results; + const headers = queryRow.headers; document.getElementById("resultsTable").replaceChildren(); if (!tableData) { document @@ -392,12 +404,17 @@ function generateHTMLTable(tableData) { return; } if (!Array.isArray(tableData)) { - document.getElementById("tab4Content").replaceChildren(document.createTextNode(tableData.msg)); + document.getElementById("tab4Content").replaceChildren(document.createTextNode(tableData)); return; } document.getElementById("tab4Content").replaceChildren(); document.getElementById("resultsTable").replaceChildren(); - generateQueryResultsTable(tableData); + const h5Text = document.createElement("h5"); + h5Text.textContent = `${ + queryRow.result_size + } results found for this query in ${queryRow.runtime_info.client_time.toPrecision(2)}s`; + document.getElementById("tab4Content").replaceChildren(h5Text); + generateQueryResultsTable(tableData, headers); if (1000 < tableData.length) { document.querySelector("#showMore").classList.replace("d-none", "d-flex"); let remainingResults = tableData.slice(1000); From 8a02fba068c33e82e4aacd9791190a92498755cf Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sat, 25 Jan 2025 22:16:05 +0100 Subject: [PATCH 12/55] Compare only the selected engines and remove hide & reset buttons and checkboxes from comparison page. Fix the hover sparql text by escaping HTML text properly --- .../evaluation/www/engines-comparison.js | 184 ++++-------------- src/qlever/evaluation/www/helper.js | 117 ++--------- src/qlever/evaluation/www/index.html | 9 - src/qlever/evaluation/www/query-details.js | 2 +- 4 files changed, 55 insertions(+), 257 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index 13a9ca04..87cec4d4 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -5,10 +5,6 @@ * - Selects the previously selected row again and scrolls to it when user comes back */ function setListenersForEnginesComparison() { - document.getElementById("hideSelectedButton").addEventListener("click", hideSelectedButtonClicked); - document.getElementById("resetButton").addEventListener("click", resetButtonClicked); - document.getElementById("comparisonModal").addEventListener("hide.bs.modal", comparisonModalHidden); - // Event to display compareExecTree modal with Exec tree comparison for selected engines document.querySelector("#compareExecTrees").addEventListener("click", (event) => { event.preventDefault(); @@ -39,16 +35,9 @@ function setListenersForEnginesComparison() { } } - // Open the previously opened modal without any processing overhead if kb is the same - if (document.querySelector("#comparisonKb").innerHTML === kb) { - return; - } - // Else clear the table with queries and engines runtimes before the modal is shown - else { - const tableContainer = document.getElementById("table-container"); - tableContainer.replaceChildren(); - document.querySelector("#comparisonKb").innerHTML = ""; - } + const tableContainer = document.getElementById("table-container"); + tableContainer.replaceChildren(); + document.querySelector("#comparisonKb").innerHTML = ""; } }); @@ -115,6 +104,17 @@ function compareExecutionTreesClicked() { alert("Please select a query from the table!"); return; } + const kb = document.querySelector("#comparisonModal").getAttribute("data-kb"); + const select1 = document.querySelector("#select1").value; + const select2 = document.querySelector("#select2").value; + const queryIndex = document.querySelector("#comparisonModal .table-active").rowIndex - 1; + if ( + !performanceDataPerKb[kb][select1.toLowerCase()]["queries"][queryIndex] || + !performanceDataPerKb[kb][select2.toLowerCase()]["queries"][queryIndex] + ) { + alert("Execution tree not available for this query for these engines!"); + return; + } document.querySelector("#comparisonModal").setAttribute("compare-exec-clicked", true); const compareResultsmodal = bootstrap.Modal.getInstance(document.querySelector("#comparisonModal")); compareResultsmodal.hide(); @@ -142,11 +142,11 @@ function getEnginesToDisplay(kb) { let enginesToShow = []; for (const row of cardBody.querySelectorAll("tbody tr")) { if (row.children[0].firstElementChild.checked) { - enginesToShow.push(row.children[1].innerHTML.toLowerCase()) + enginesToShow.push(row.children[1].innerHTML.toLowerCase()); } } return enginesToShow; - } + } } } @@ -163,11 +163,6 @@ function openComparisonModal(kb) { const enginesToDisplay = getEnginesToDisplay(kb); console.log(enginesToDisplay); - // Open the previously opened modal without any processing overhead if kb is the same - if (document.querySelector("#comparisonKb").innerHTML === kb) { - return; - } - showSpinner(); document.querySelector("#comparisonKb").innerHTML = kb; const tableContainer = document.getElementById("table-container"); @@ -176,7 +171,7 @@ function openComparisonModal(kb) { let select2 = document.querySelector("#select2"); select1.innerHTML = ""; select2.innerHTML = ""; - for (let engine of sparqlEngines) { + for (let engine of enginesToDisplay) { //await addRuntimeToPerformanceDataPerKb(kb, engine); if (performanceDataPerKb[kb].hasOwnProperty(engine) && execTreeEngines.includes(engine)) { select1.add(new Option(engine)); @@ -193,28 +188,14 @@ function openComparisonModal(kb) { select2.selectedIndex = 1; } - // If there is a previously saved state for the runtimes comparison table, fetch that and show it. - // Also re-attach the click event listener to all the rows that were lost when the table state was saved - if (comparisonTables.hasOwnProperty(kb) && comparisonTables[kb]) { - tableContainer.innerHTML = comparisonTables[kb]; - const rows = tableContainer.querySelectorAll("tbody tr"); - for (const row of rows) { - row.addEventListener("click", function () { - let activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); - if (activeRow) activeRow.classList.remove("table-active"); - row.classList.add("table-active"); - }); - } - } else { - // Create a DocumentFragment to build the table - const fragment = document.createDocumentFragment(); - const table = createCompareResultsTable(kb, enginesToDisplay); + // Create a DocumentFragment to build the table + const fragment = document.createDocumentFragment(); + const table = createCompareResultsTable(kb, enginesToDisplay); - fragment.appendChild(table); + fragment.appendChild(table); - // Append the table to the container in a single operation - tableContainer.appendChild(fragment); - } + // Append the table to the container in a single operation + tableContainer.appendChild(fragment); $("#table-container table").tablesorter({ theme: "bootstrap", @@ -238,16 +219,14 @@ function createCompareResultsTable(kb, enginesToDisplay) { // Create the table header row const thead = document.createElement("thead"); const headerRow = document.createElement("tr"); - headerRow.title = - "Click on a column to sort it in descending or ascending order. Sort multiple columns simultaneously by holding down the Shift key and clicking a second, third or even fourth column header!"; + headerRow.title = ` + Click on a column to sort it in descending or ascending order. + Sort multiple columns simultaneously by holding down the Shift key + and clicking a second, third or even fourth column header! + `; - headerRow.innerHTML = ` - - - - `; // Create dynamic headers and add them to the header row - headerRow.innerHTML += "Query"; + headerRow.innerHTML = "Query"; const engines = enginesToDisplay; let engineIndexForQueriesList = 0; for (let i = 0; i < engines.length; i++) { @@ -265,13 +244,7 @@ function createCompareResultsTable(kb, enginesToDisplay) { const tbody = document.createElement("tbody"); for (let i = 0; i < queryCount; i++) { const row = document.createElement("tr"); - row.innerHTML = ` - - - - `; - row.style.cursor = "pointer"; - const title = performanceDataPerKb[kb][engines[engineIndexForQueriesList]]["queries"][i]["sparql"]; + const title = EscapeAttribute(performanceDataPerKb[kb][engines[engineIndexForQueriesList]]["queries"][i]["sparql"]); row.innerHTML += `${ performanceDataPerKb[kb][engines[engineIndexForQueriesList]]["queries"][i]["query"] }`; @@ -286,98 +259,17 @@ function createCompareResultsTable(kb, enginesToDisplay) { let runtimeText = `${formatNumber(parseFloat(runtime))} s`; row.innerHTML += `${runtimeText}`; } - row.addEventListener("click", function () { - let activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); - if (activeRow) activeRow.classList.remove("table-active"); - row.classList.add("table-active"); - }); + if (!document.querySelector("#compareExecDiv").classList.contains("d-none")) { + row.style.cursor = "pointer"; + row.addEventListener("click", function () { + let activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); + if (activeRow) activeRow.classList.remove("table-active"); + row.classList.add("table-active"); + }); + } tbody.appendChild(row); } table.appendChild(tbody); - // Add event listeners for header and row checkboxes - - // If the header is checked, all the rows must be checked and vice-versa - thead.addEventListener("change", function (event) { - const headerCheckbox = event.target; - const rowCheckboxes = tbody.querySelectorAll(".row-checkbox"); - rowCheckboxes.forEach((checkbox) => { - checkbox.checked = headerCheckbox.checked; - }); - }); - - // Update the checker status of header based on if all rows are selected or not - tbody.addEventListener("change", function () { - const headerCheckbox = thead.querySelector("#selectAll"); - const rowCheckboxes = tbody.querySelectorAll(".row-checkbox"); - const allChecked = Array.from(rowCheckboxes).every((checkbox) => checkbox.checked); - const noneChecked = Array.from(rowCheckboxes).every((checkbox) => !checkbox.checked); - headerCheckbox.checked = allChecked; - headerCheckbox.indeterminate = !allChecked && !noneChecked; - }); - return table; } - -/** - * Hide rows in the table where checkboxes are selected. - * Ensures the "select all" header checkbox is not checked and prevents hiding all rows. - * Alerts the user if an attempt is made to hide all rows of the table. - */ -function hideSelectedButtonClicked() { - const table = document.querySelector("#table-container table"); - const checkboxes = table.querySelectorAll("tbody .row-checkbox:checked"); // Selected checkboxes - const headerCheckbox = table.querySelector("#selectAll"); // Header selectAll checkbox - - if (headerCheckbox.checked) { - alert("Cannot hide all the rows of the table!"); - return; - } - - // Hide rows with checked checkboxes - checkboxes.forEach((checkbox) => { - const row = checkbox.closest("tr"); - row.style.display = "none"; // Hide the row - row.classList.remove("table-active"); - }); - - headerCheckbox.checked = false; - headerCheckbox.indeterminate = false; -} - -/** - * Reset the table to its default state. - * Makes all rows visible, unchecks all checkboxes, and ensures the "select all" header checkbox is cleared. - */ -function resetButtonClicked() { - const table = document.querySelector("#table-container table"); - const allRows = table.querySelectorAll("tbody tr"); - const allCheckboxes = table.querySelectorAll('input[type="checkbox"]'); - - // Reset all rows to be visible - allRows.forEach((row) => { - row.style.display = ""; // Make the row visible - }); - - // Uncheck all checkboxes - allCheckboxes.forEach((checkbox) => { - checkbox.checked = false; - }); - - // Reset the header checkbox - const headerCheckbox = table.querySelector("#selectAll"); - if (headerCheckbox) { - headerCheckbox.checked = false; - headerCheckbox.indeterminate = false; - } -} - -/** - * Cache the HTML of the comparison table when the comparison modal is hidden. - * Saves the table's current state in the `comparisonTables` object using the knowledge base (`kb`) as a key. - */ -function comparisonModalHidden() { - const table = document.querySelector("#table-container table"); - const kb = document.querySelector("#comparisonKb").innerHTML; - comparisonTables[kb] = table.outerHTML; -} diff --git a/src/qlever/evaluation/www/helper.js b/src/qlever/evaluation/www/helper.js index 4b7643a3..a2d5aa7a 100644 --- a/src/qlever/evaluation/www/helper.js +++ b/src/qlever/evaluation/www/helper.js @@ -4,7 +4,6 @@ var execTreeEngines = []; var kbs = []; var outputUrl = window.location.pathname.replace("www", "output"); var performanceDataPerKb = {}; -var comparisonTables = {}; var high_query_time_ms = 200; var very_high_query_time_ms = 1000; @@ -29,39 +28,6 @@ function format(number) { return number.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,"); } -/** - * Generates the file URL for the query log yaml based on the given SPARQL engine and knowledge base. - * - * @param {string} engine - The SPARQL engine (e.g., 'qlever'). - * @param {string} kb - The knowledge base (e.g., 'sp2b'). - * @returns {string} The file URL for the query log. - */ -function getQueryLog(engine, kb) { - return `${kb}.${engine}.queries.executed.yml`; -} - -/** - * Generates the file URL for the eval log tsv based on the given SPARQL engine and knowledge base. - * - * @param {string} engine - The SPARQL engine (e.g., 'qlever'). - * @param {string} kb - The knowledge base (e.g., 'sp2b'). - * @returns {string} The file URL for the eval log. - */ -function getEvalLog(engine, kb) { - return `${kb}.${engine}.queries.results.tsv`; -} - -/** - * Generates the file URL for the fail log txt based on the given SPARQL engine and knowledge base. - * - * @param {string} engine - The SPARQL engine (e.g., 'qlever'). - * @param {string} kb - The knowledge base (e.g., 'sp2b'). - * @returns {string} The file URL for the failure log. - */ -function getFailLog(engine, kb) { - return `${kb}.${engine}.queries.fail.txt`; -} - /** * Fetches and processes a YAML file from a specified URL. * @@ -87,73 +53,6 @@ async function getYamlData(yamlFileUrl, headers = {}) { } } -/** - * Processes the content of a TSV file and calculates various statistics. - * - * @param {string} tsvContent - The content of the TSV file as a string. - * @returns {Object} An object containing parsed data, statistical metrics, and query performance analysis. - * Will log an error if processing the TSV content fails. - */ -function getTsvData(tsvContent) { - // Fetch the TSV file and process its content - try { - // Split the content into rows - const rows = tsvContent.replace(/\r/g, "").trim().split("\n"); - - // Parse the header row - const headers = rows[0].split("\t"); - - // Initialize an array to hold data objects - const data = []; - const result = {}; - - let queryTimeArray = []; - let totalTime = 0; - let queries_under_1s = 0; - let queries_over_5s = 0; - let failed_queries = 0; - // Iterate through the remaining rows - for (let i = 1; i < rows.length; i++) { - const values = rows[i].split("\t"); - const entry = {}; - - // Create an object with headers as keys and values as values - for (let j = 0; j < headers.length; j++) { - entry[headers[j]] = values[j]; - } - let query_time = parseFloat(values[2]); - queryTimeArray.push(query_time); - totalTime += query_time; - if (values[3] == "True") { - failed_queries++; - } else { - if (query_time < 1000) { - queries_under_1s++; - } - if (query_time > 5000) { - queries_over_5s++; - } - } - - data.push(entry); - } - - result.data = data; // The array of parsed data objects. - result.avgTime = totalTime / (rows.length - 1) / 1000; // The average query time in seconds. - result.medianTime = median(queryTimeArray) / 1000; //The median query time in seconds. - // Percentage of queries executed under 1 second. - result.under_1s = (queries_under_1s / (rows.length - 1)) * 100; - // Percentage of queries executed over 5 seconds. - result.over_5s = (queries_over_5s / (rows.length - 1)) * 100; - result.failed = (failed_queries / (rows.length - 1)) * 100; // Percentage of failed queries. - // Percentage of queries executed between 1 and 5 seconds. - result.between_1_to_5s = 100 - result.under_1s - result.over_5s - result.failed; - return result; - } catch (error) { - console.error("Error processing TSV file:", error); - } -} - function addQueryStatistics(queryData) { let runtimeArray = []; let totalTime = 0; @@ -205,6 +104,22 @@ function getTxtData(txtContent) { } } +/** + * Escape text for an attribute + * Source: https://stackoverflow.com/a/77873486 + * @param {string} text + * @returns {string} + */ +function EscapeAttribute(text) { + return text.replace(/[&<>"']/g, match => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[match])); +} + /** * Displays the loading spinner by updating the relevant CSS classes. */ diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html index e5b715d8..3f15521d 100644 --- a/src/qlever/evaluation/www/index.html +++ b/src/qlever/evaluation/www/index.html @@ -145,15 +145,6 @@
- -
- - -
diff --git a/src/qlever/evaluation/www/query-details.js b/src/qlever/evaluation/www/query-details.js index 65c4181c..8a6326a8 100644 --- a/src/qlever/evaluation/www/query-details.js +++ b/src/qlever/evaluation/www/query-details.js @@ -248,7 +248,7 @@ function createQueryTable(queryResult, kb, engine, tabBody) { resultClass = query.headers.length === 0 || !Array.isArray(query.results) ? "bg-danger bg-opacity-25" : ""; tabRow.innerHTML = ` - ${query.query} + ${query.query} ${runtime} `; tabBody.appendChild(tabRow); From 11fca709dcd9dabfaf2e59c4729b7ee2bed300b2 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sat, 25 Jan 2025 22:22:45 +0100 Subject: [PATCH 13/55] Add full error msg to output yaml file in example-queries --- src/qlever/commands/example_queries.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 6e7e47d3..7a9b7992 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -610,6 +610,8 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: except Exception as e: error_msg = get_json_error_msg(e) + error_msg_for_yaml = {} + # Print description, time, result in tabular form. if len(description) > width_query_description: description = ( @@ -627,6 +629,8 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: query_times.append(time_seconds) result_sizes.append(result_size) else: + for key in error_msg.keys(): + error_msg_for_yaml[key] = error_msg[key] num_failed += 1 if ( args.width_error_message > 0 @@ -669,7 +673,9 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: else result_length ) results_for_yaml = ( - error_msg if error_msg is not None else result_file + error_msg_for_yaml + if error_msg is not None + else result_file ) yaml_record = self.get_record_for_yaml( query=description, From aeb646f2772c03d6204313d65641e5afc02d8d0f Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sat, 25 Jan 2025 22:23:57 +0100 Subject: [PATCH 14/55] Removed some code and fixed some bugs --- src/qlever/evaluation/www/compare-exec-trees.js | 10 +++++----- src/qlever/evaluation/www/index.html | 2 +- src/qlever/evaluation/www/main.js | 6 ++++++ src/qlever/evaluation/www/query-details.js | 14 +++++++++----- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/qlever/evaluation/www/compare-exec-trees.js b/src/qlever/evaluation/www/compare-exec-trees.js index 0b9a8201..bb0d64ff 100644 --- a/src/qlever/evaluation/www/compare-exec-trees.js +++ b/src/qlever/evaluation/www/compare-exec-trees.js @@ -167,12 +167,12 @@ function showCompareExecTrees(purpose, select1, select2, kb, queryIndex, idOfTre document.querySelector(divIds[i]).innerHTML = qlevers[i]; } } - document.querySelector("#runtimeQuery").textContent = - "Query: " + performanceDataPerKb[kb][qlevers[0].toLowerCase()]["queries"][queryIndex]["query"]; - document.querySelector("#runtimeQuery").title = - performanceDataPerKb[kb][qlevers[0].toLowerCase()]["queries"][queryIndex]["sparql"]; + const queries = performanceDataPerKb[kb][qlevers[0].toLowerCase()]["queries"]; + document.querySelector("#runtimeQuery").textContent = "Query: " + queries[queryIndex]["query"]; + document.querySelector("#runtimeQuery").title = queries[queryIndex]["sparql"].replace(/"/g, '\\"'); qlevers.forEach((engine, index) => { - let runtime = performanceDataPerKb[kb][engine.toLowerCase()]["queries"][queryIndex].runtime_info.query_execution_tree; + let runtime = + performanceDataPerKb[kb][engine.toLowerCase()]["queries"][queryIndex].runtime_info.query_execution_tree; let treeid = "#tree" + (index + 1).toString(); if (purpose === "modalShow" || idOfTreeToZoom === treeid) { document.querySelector(treeid).replaceChildren(); diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html index 3f15521d..6def8a56 100644 --- a/src/qlever/evaluation/www/index.html +++ b/src/qlever/evaluation/www/index.html @@ -114,7 +114,7 @@
Engine

-
+


diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index 27898b3e..3852e046 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -111,6 +111,12 @@ function processResults(results, fileList) { if (results[i].status == "fulfilled" && results[i].value.status == "fulfilled") { const queryData = results[i].value.value; addQueryStatistics(queryData); + for (const query of queryData.queries) { + if (query.headers.length !== 0 && query.runtime_info.hasOwnProperty("query_execution_tree")) { + execTreeEngines.push(engine); + break; + } + } performanceDataPerKb[kb][engine] = queryData; } } diff --git a/src/qlever/evaluation/www/query-details.js b/src/qlever/evaluation/www/query-details.js index 8a6326a8..1321a9f2 100644 --- a/src/qlever/evaluation/www/query-details.js +++ b/src/qlever/evaluation/www/query-details.js @@ -99,8 +99,8 @@ function updateUrlAndState(kb, engine, selectedQuery, tab) { url.searchParams.set("kb", kb); url.searchParams.set("engine", engine); const state = { page: "queriesDetails", kb: kb, engine: engine }; - selectedQuery !== null && (state.q = selectedQuery) && url.searchParams.set("q", selectedQuery); - tab !== null && (state.t = tab) && url.searchParams.set("t", tab); + selectedQuery !== null && (state.q = selectedQuery.toString()) && url.searchParams.set("q", selectedQuery.toString()); + tab !== null && (state.t = tab.toString()) && url.searchParams.set("t", tab.toString()); // If this page is directly opened from url, replace the null state in history stack if (window.history.state === null) { window.history.replaceState(state, "", url); @@ -204,7 +204,7 @@ async function openQueryDetailsModal(kb, engine, selectedQuery, tabToOpen) { } document.getElementById("result-tree").replaceChildren(); - createQueryTable(queryResult, kb, engine, tabBody); + createQueryTable(queryResult, tabBody); $("#runtimes-tab-pane table").tablesorter({ theme: "bootstrap", sortStable: true, @@ -234,7 +234,7 @@ async function openQueryDetailsModal(kb, engine, selectedQuery, tabToOpen) { * @param {string} engine - The SPARQL engine used. * @param {HTMLElement} tabBody - The #queryList element. */ -function createQueryTable(queryResult, kb, engine, tabBody) { +function createQueryTable(queryResult, tabBody) { queryResult.forEach((query, i) => { const tabRow = document.createElement("tr"); tabRow.style.cursor = "pointer"; @@ -410,9 +410,13 @@ function generateHTMLTable(queryRow) { document.getElementById("tab4Content").replaceChildren(); document.getElementById("resultsTable").replaceChildren(); const h5Text = document.createElement("h5"); + const totalResults = queryRow.result_size; + const resultsShown = totalResults <= 1000 ? totalResults : 1000; h5Text.textContent = `${ queryRow.result_size - } results found for this query in ${queryRow.runtime_info.client_time.toPrecision(2)}s`; + } result(s) found for this query in ${queryRow.runtime_info.client_time.toPrecision( + 2 + )}s. Showing ${resultsShown} result(s)`; document.getElementById("tab4Content").replaceChildren(h5Text); generateQueryResultsTable(tableData, headers); if (1000 < tableData.length) { From 4d59fac95e45a32fc960f2415ff473fa742f137f Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Thu, 20 Feb 2025 18:48:25 +0100 Subject: [PATCH 15/55] Add `rdflib` dependency and `--host` argument --- pyproject.toml | 2 +- src/qlever/commands/serve_evaluation_app.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 470a3c2d..d451dbc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Database :: Front-Ends" ] -dependencies = [ "psutil", "termcolor", "argcomplete", "ruamel.yaml" ] +dependencies = [ "psutil", "termcolor", "argcomplete", "ruamel.yaml", "rdflib" ] [project.urls] Github = "https://github.com/ad-freiburg/qlever" diff --git a/src/qlever/commands/serve_evaluation_app.py b/src/qlever/commands/serve_evaluation_app.py index c3de25d8..76ea6f8d 100644 --- a/src/qlever/commands/serve_evaluation_app.py +++ b/src/qlever/commands/serve_evaluation_app.py @@ -37,6 +37,15 @@ def additional_arguments(self, subparser) -> None: "served (Default = 8000)" ), ) + subparser.add_argument( + "--host", + type=str, + default="localhost", + help=( + "Host where the Performance comparison webapp will be " + "served (Default = localhost)" + ), + ) subparser.add_argument( "--show-files-only", action="store_true", @@ -56,7 +65,7 @@ def execute(self, args) -> bool: httpd = HTTPServer(("", args.port), handler) log.info( f"Performance Comparison Web App is available at " - f"http://localhost:{args.port}/www" + f"http://{args.host}:{args.port}/www" ) httpd.serve_forever() return True From 7c7337c76b81cbaf483b7c95de7b2396255a52f1 Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Fri, 21 Mar 2025 23:32:01 +0100 Subject: [PATCH 16/55] No automatic `AUTO` for option `--generate-output-file` --- src/qlever/commands/example_queries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index c95bf859..8242a39a 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -291,7 +291,7 @@ def execute(self, args) -> bool: " must be passed when --generate-output-file is passed" ) return False - args.accept = "AUTO" + # args.accept = "AUTO" # If `args.accept` is `application/sparql-results+json` or # `application/qlever-results+json` or `AUTO`, we need `jq`. From 264484dcec179f17145a9af2ba7e418c4c8ddd3c Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Wed, 2 Apr 2025 16:16:45 +0200 Subject: [PATCH 17/55] Add statistics --- src/qlever/evaluation/www/card-template.html | 11 +++++----- src/qlever/evaluation/www/helper.js | 12 +++++++---- src/qlever/evaluation/www/main.js | 21 ++++++-------------- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/qlever/evaluation/www/card-template.html b/src/qlever/evaluation/www/card-template.html index fbe27f17..911c8bc3 100644 --- a/src/qlever/evaluation/www/card-template.html +++ b/src/qlever/evaluation/www/card-template.html @@ -17,12 +17,13 @@
Dataset
SPARQL Engine - Queries Failed - Avg Runtime (s) - Median Runtime (s) - Runtime <= 1.0s + Failed + Geom. mean + Arith. mean + Median + <= 1.0s (1.0s, 5.0s] - Runtime > 5s + > 5s diff --git a/src/qlever/evaluation/www/helper.js b/src/qlever/evaluation/www/helper.js index a2d5aa7a..b98ae0db 100644 --- a/src/qlever/evaluation/www/helper.js +++ b/src/qlever/evaluation/www/helper.js @@ -56,6 +56,7 @@ async function getYamlData(yamlFileUrl, headers = {}) { function addQueryStatistics(queryData) { let runtimeArray = []; let totalTime = 0; + let totalLogTime = 0; let queriesUnder1s = 0; let queriesOver5s = 0; let failedQueries = 0; @@ -63,6 +64,7 @@ function addQueryStatistics(queryData) { let runtime = parseFloat(query.runtime_info.client_time); runtimeArray.push(runtime); totalTime += runtime; + totalLogTime += Math.max(Math.log(runtime), 0.001); if (query.headers.length === 0 && typeof(query.results) == "string") { failedQueries++ } @@ -75,11 +77,13 @@ function addQueryStatistics(queryData) { } } } - queryData.avgTime = totalTime / queryData.queries.length; + let n = queryData.queries.length; + queryData.ameanTime = totalTime / n; + queryData.gmeanTime = Math.exp(totalLogTime / n); queryData.medianTime = median(runtimeArray); - queryData.under1s = (queriesUnder1s / queryData.queries.length) * 100; - queryData.over5s = (queriesOver5s / queryData.queries.length) * 100; - queryData.failed = (failedQueries / queryData.queries.length) * 100; + queryData.under1s = (queriesUnder1s / n) * 100; + queryData.over5s = (queriesOver5s / n) * 100; + queryData.failed = (failedQueries / n) * 100; queryData.between1to5s = 100 - queryData.under1s - queryData.over5s - queryData.failed; } diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index de911f48..8c327569 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -23,21 +23,12 @@ function populateCard(cardTemplate, kb) { ${engine} ${formatNumber(parseFloat(engineData.failed))}% - ${ - formatNumber(parseFloat(engineData.avgTime)) - } - ${ - formatNumber(parseFloat(engineData.medianTime)) - } - ${ - formatNumber(parseFloat(engineData.under1s)) - } - ${ - formatNumber(parseFloat(engineData.between1to5s)) + "%" - } - ${ - formatNumber(parseFloat(engineData.over5s)) + "%" - } + ${formatNumber(parseFloat(engineData.gmeanTime))}s + ${formatNumber(parseFloat(engineData.ameanTime))}s + ${formatNumber(parseFloat(engineData.medianTime))}s + ${formatNumber(parseFloat(engineData.under1s))}% + ${formatNumber(parseFloat(engineData.between1to5s))}% + ${formatNumber(parseFloat(engineData.over5s))}% `; cardBody.appendChild(row); addEventListenersForCard(clone.querySelector("table")); From 44753f42a5b2dcc1bfc9f1c39f31f88cd7e88d14 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sat, 5 Apr 2025 17:06:58 +0200 Subject: [PATCH 18/55] Added ability to generate yaml results from sparql-results+json --- src/qlever/commands/example_queries.py | 111 ++++++++++++------------- 1 file changed, 53 insertions(+), 58 deletions(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 00d8fcfc..6c13717c 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -1,11 +1,13 @@ from __future__ import annotations +import csv import json import re import shlex import subprocess import time import traceback +from io import StringIO from pathlib import Path from typing import Any @@ -19,7 +21,7 @@ from qlever.log import log, mute_log from qlever.util import run_command, run_curl_command -MAX_RESULT_SIZE = 1000 +MAX_RESULT_SIZE = 50 class ExampleQueriesCommand(QleverCommand): @@ -291,7 +293,6 @@ def execute(self, args) -> bool: " must be passed when --generate-output-file is passed" ) return False - # args.accept = "AUTO" # If `args.accept` is `application/sparql-results+json` or # `application/qlever-results+json` or `AUTO`, we need `jq`. @@ -479,22 +480,10 @@ def execute(self, args) -> bool: # queries and `application/sparql-results+json` for all others. accept_header = args.accept if accept_header == "AUTO": - if query_type == "DESCRIBE": + if query_type == "CONSTRUCT" or query_type == "DESCRIBE": accept_header = "text/turtle" - elif query_type == "CONSTRUCT": - accept_header = ( - "application/qlever-results+json" - if is_qlever and args.generate_output_file - else "text/turtle" - ) else: accept_header = "application/sparql-results+json" - if args.generate_output_file: - accept_header = ( - "application/qlever-results+json" - if is_qlever - else "text/tab-separated-values" - ) # Launch query. curl_cmd = ( @@ -547,7 +536,7 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: # Get result size (via the command line, in order to avoid loading # a potentially large JSON file into Python, which is slow). if error_msg is None: - single_result = None + single_int_result = None # CASE 0: The result is empty despite a 200 HTTP code (not a # problem for CONSTRUCT and DESCRIBE queries). if Path(result_file).stat().st_size == 0 and ( @@ -618,21 +607,16 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: ).rstrip() ) except Exception as e: - error_msg = { - "short": "Malformed JSON", - "long": re.sub(r"\s+", " ", str(e)), - } + error_msg = get_json_error_msg(e) if result_size == 1: try: - single_result = run_command( - f'jq -r ".results.bindings[0][] | .value"' - f" {result_file}", - return_output=True, - ).rstrip() - if single_result.isdigit(): - single_result = f"{int(single_result):,}" - else: - single_result = None + single_int_result = int( + run_command( + f'jq -e -r ".results.bindings[0][] | .value"' + f" {result_file}", + return_output=True, + ).rstrip() + ) except Exception: pass @@ -647,16 +631,16 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: ) if error_msg is None: result_size = int(result_size) - single_result = ( - f" [single result: {single_result}]" - if single_result is not None + single_int_result = ( + f" [single int result: {single_int_result:,}]" + if single_int_result is not None else "" ) log.info( f"{description:<{width_query_description}} " f"{time_seconds:6.2f} s " f"{result_size:>{args.width_result_size},}" - f"{single_result}" + f"{single_int_result}" ) query_times.append(time_seconds) result_sizes.append(result_size) @@ -838,37 +822,45 @@ def get_query_results( """ Return headers and results as a tuple """ - if accept_header == "text/tab-separated-values": + if accept_header in ("text/tab-separated-values", "text/csv"): + separator = "," if accept_header == "text/csv" else "\t" get_result_cmd = f"sed -n '1,{result_size + 1}p' {result_file}" results_str = run_command(get_result_cmd, return_output=True) results = results_str.splitlines() - headers = [header for header in results[0].split("\t")] - results = [result.split("\t") for result in results[1:]] - return headers, results - elif accept_header == "application/sparql-results+json": - get_headers_cmd = f"jq -r '.head.vars[]' {result_file}" - headers_str = run_command(get_headers_cmd, return_output=True) - headers = headers_str.splitlines() - results = [] - # For now, only consider the first result. - if result_size > 0: - get_result_cmd = ( - f"jq -r '.results.bindings[0]" - f" | .[].value' {result_file}" - ) - try: - result_str = run_command( - get_result_cmd, return_output=True - ) - results.append(result_str.split("\t")) - except Exception as e: - log.debug(f"ERROR getting first result: {e}") + reader = csv.reader(StringIO(results_str), delimiter=separator) + headers = next(reader) + results = [row for row in reader] return headers, results elif accept_header == "application/qlever-results+json": - get_result_cmd = f"jq '{{headers: .selected, results: .res[0:{result_size}]}}' {result_file}" + get_result_cmd = ( + f"jq '{{headers: .selected, results: .res[0:{result_size}]}}' " + f"{result_file}" + ) results_str = run_command(get_result_cmd, return_output=True) results_json = json.loads(results_str) return results_json["headers"], results_json["results"] + elif accept_header == "application/sparql-results+json": + get_result_cmd = ( + f"jq '{{headers: .head.vars, " + f"bindings: .results.bindings[0:{result_size}]}}' " + f"{result_file}" + ) + results_str = run_command(get_result_cmd, return_output=True) + results_json = json.loads(results_str) + results = [] + for binding in results_json["bindings"]: + result = [] + for obj in binding.values(): + value = '"' + obj["value"] + '"' + if obj["type"] == "uri": + value = "<" + value.strip('"') + ">" + elif "datatype" in obj: + value += "^^<" + obj["datatype"] + ">" + elif "xml:lang" in obj: + value += "@" + obj["xml:lang"] + result.append(value) + results.append(result) + return results_json["headers"], results else: # text/turtle graph = Graph() graph.parse(result_file, format="turtle") @@ -892,5 +884,8 @@ def write_query_data_to_yaml( output_dir = Path(__file__).parent.parent / "evaluation" / "output" output_dir.mkdir(parents=True, exist_ok=True) yaml_file_path = output_dir / out_file - with open(yaml_file_path, "wb") as yaml_file: - yaml.dump(query_data, yaml_file) + with open(yaml_file_path, "wb") as eval_yaml_file, open( + out_file, "wb" + ) as cwd_yaml_file: + yaml.dump(query_data, eval_yaml_file) + yaml.dump(query_data, cwd_yaml_file) From 5f5200be0fe9b7553e8e63f8bd3b29e55e1c17d2 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Mon, 7 Apr 2025 18:44:17 +0200 Subject: [PATCH 19/55] Make the tables in eval webapp more compact and make the comparison table-header sticky --- src/qlever/evaluation/www/engines-comparison.js | 4 ++-- src/qlever/evaluation/www/treant.css | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index 87cec4d4..e3565096 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -226,7 +226,7 @@ function createCompareResultsTable(kb, enginesToDisplay) { `; // Create dynamic headers and add them to the header row - headerRow.innerHTML = "Query"; + headerRow.innerHTML = "Query"; const engines = enginesToDisplay; let engineIndexForQueriesList = 0; for (let i = 0; i < engines.length; i++) { @@ -234,7 +234,7 @@ function createCompareResultsTable(kb, enginesToDisplay) { queryCount = performanceDataPerKb[kb][engines[i]]["queries"].length; engineIndexForQueriesList = i; } - headerRow.innerHTML += `${engines[i]}`; + headerRow.innerHTML += `${engines[i]}`; } thead.appendChild(headerRow); diff --git a/src/qlever/evaluation/www/treant.css b/src/qlever/evaluation/www/treant.css index f4db0fb7..34f01c97 100644 --- a/src/qlever/evaluation/www/treant.css +++ b/src/qlever/evaluation/www/treant.css @@ -54,3 +54,8 @@ .font-size-60 { font-size: 60%; } .font-size-70 { font-size: 70%; } .font-size-80 { font-size: 80%; } + +table td { + padding-top: 0.3em !important; + padding-bottom: 0.3em !important; +} From fc5161e4500ce92874f7ca5e1b0c939686fff43e Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Mon, 7 Apr 2025 18:44:55 +0200 Subject: [PATCH 20/55] Fix spacing and some other bugs --- src/qlever/evaluation/www/index.html | 18 ++++++++---------- src/qlever/evaluation/www/query-details.js | 7 +++++-- src/qlever/evaluation/www/treant.css | 4 ++-- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html index 6def8a56..55204160 100644 --- a/src/qlever/evaluation/www/index.html +++ b/src/qlever/evaluation/www/index.html @@ -45,7 +45,7 @@
- diff --git a/src/qlever/evaluation/www/query-details.js b/src/qlever/evaluation/www/query-details.js index 1321a9f2..0953aaac 100644 --- a/src/qlever/evaluation/www/query-details.js +++ b/src/qlever/evaluation/www/query-details.js @@ -26,7 +26,10 @@ function setListenersForQueriesTabs() { // If the kb and engine are the same as previously opened, do nothing const modalTitle = modalNode.querySelector(".modal-title"); const tab1Content = modalNode.querySelector("#runtimes-tab-pane"); - if (modalTitle.textContent.includes(engine) && tab1Content.querySelector(".card-title").innerHTML.includes(kb)) { + if ( + modalTitle.textContent.split(" - ")[1] === engine && + tab1Content.querySelector(".card-title").innerHTML.split(" - ")[1] + ) { return; } // Else clear the table with queries and runtimes before the modal is shown @@ -411,7 +414,7 @@ function generateHTMLTable(queryRow) { document.getElementById("resultsTable").replaceChildren(); const h5Text = document.createElement("h5"); const totalResults = queryRow.result_size; - const resultsShown = totalResults <= 1000 ? totalResults : 1000; + const resultsShown = totalResults <= 50 ? totalResults : 50; h5Text.textContent = `${ queryRow.result_size } result(s) found for this query in ${queryRow.runtime_info.client_time.toPrecision( diff --git a/src/qlever/evaluation/www/treant.css b/src/qlever/evaluation/www/treant.css index 34f01c97..60d4bbb1 100644 --- a/src/qlever/evaluation/www/treant.css +++ b/src/qlever/evaluation/www/treant.css @@ -56,6 +56,6 @@ .font-size-80 { font-size: 80%; } table td { - padding-top: 0.3em !important; - padding-bottom: 0.3em !important; + padding-top: 0.2em !important; + padding-bottom: 0.2em !important; } From e4156d591838320733eb5e7692b2d7976af5767c Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Mon, 7 Apr 2025 18:45:35 +0200 Subject: [PATCH 21/55] Add best runtime, error and result_size warning tooltip to comparison table --- .../evaluation/www/engines-comparison.js | 71 ++++++++++++++++++- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index e3565096..420d52de 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -205,6 +205,52 @@ function openComparisonModal(kb) { hideSpinner(); } +function getBestRuntime(kb, engines, queryId) { + best_time = Infinity; + for (let engine of engines) { + const result = performanceDataPerKb[kb][engine]["queries"][queryId]; + let runtime = parseFloat(result.runtime_info.client_time); + let failed = result.headers.length === 0 || !Array.isArray(result.results); + if (!failed && runtime < best_time) { + best_time = runtime; + } + } + return best_time; +} + +function getMajorityResultSize(kb, engines, queryId) { + const sizeCounts = new Map(); + let validResultFound = false; + + for (let engine of engines) { + const result = performanceDataPerKb[kb][engine]["queries"][queryId]; + const failed = result.headers.length === 0 || !Array.isArray(result.results); + + if (!failed && typeof result.result_size === "number" && result.result_size !== 0) { + validResultFound = true; + const size = result.result_size; + sizeCounts.set(size, (sizeCounts.get(size) || 0) + 1); + } + } + + if (!validResultFound || sizeCounts.size === 0) { + // All results failed or only had result_size = 0 + return null; + } + + let majorityResultSize = null; + let maxCount = 0; + + for (const [size, count] of sizeCounts.entries()) { + if (count > maxCount) { + maxCount = count; + majorityResultSize = size; + } + } + + return majorityResultSize; +} + /** * Uses performanceDataPerKb object to create the engine runtime for each query comparison table * Gives the user the ability to selectively hide queries to reduce the clutter @@ -248,6 +294,8 @@ function createCompareResultsTable(kb, enginesToDisplay) { row.innerHTML += `${ performanceDataPerKb[kb][engines[engineIndexForQueriesList]]["queries"][i]["query"] }`; + const bestRuntime = getBestRuntime(kb, engines, i); + const majorityResultSize = getMajorityResultSize(kb, engines, i); for (let engine of engines) { const result = performanceDataPerKb[kb][engine]["queries"][i]; if (!result) { @@ -255,9 +303,26 @@ function createCompareResultsTable(kb, enginesToDisplay) { continue; } let runtime = result.runtime_info.client_time; - let resultClass = result.headers.length === 0 || !Array.isArray(result.results) ? "bg-danger bg-opacity-25" : ""; - let runtimeText = `${formatNumber(parseFloat(runtime))} s`; - row.innerHTML += `${runtimeText}`; + const failed = !Array.isArray(result.results); + let resultClass = failed ? "bg-danger bg-opacity-25" : ""; + if (resultClass === "" && runtime === bestRuntime) { + resultClass = "bg-success bg-opacity-25"; + } + let td_title = ""; + let warningSymbol = ""; + const actualSize = result.result_size; + if (failed) { + td_title = EscapeAttribute(result.results); + } + else if (resultClass.includes("bg-success")) { + td_title = "Best runtime for this query!"; + } + if (majorityResultSize !== null && !failed && actualSize !== majorityResultSize) { + warningSymbol = ` ` ; + td_title += (td_title ? " " : "") + `Warning: Result size (${actualSize}) differs from majority (${majorityResultSize}).`; + } + let runtimeText = `${formatNumber(parseFloat(runtime))} s${warningSymbol}`; + row.innerHTML += `${runtimeText}`; } if (!document.querySelector("#compareExecDiv").classList.contains("d-none")) { row.style.cursor = "pointer"; From c51bd68494cd9ac148f721032b9ea55cf054b406 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Tue, 8 Apr 2025 00:01:00 +0200 Subject: [PATCH 22/55] Added result size bs popover to comparison Table --- .../evaluation/www/engines-comparison.js | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index 420d52de..6a14caf6 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -47,6 +47,12 @@ function setListenersForEnginesComparison() { if (kb) { openComparisonModal(kb); } + const popoverTriggerList = [].slice.call( + document.querySelector("#comparisonModal").querySelectorAll('[data-bs-toggle="popover"]') + ); + popoverTriggerList.map(function (el) { + return new bootstrap.Popover(el); + }); // Scroll to the previously selected row if user is coming back to this modal const activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); if (activeRow) { @@ -308,21 +314,46 @@ function createCompareResultsTable(kb, enginesToDisplay) { if (resultClass === "" && runtime === bestRuntime) { resultClass = "bg-success bg-opacity-25"; } - let td_title = ""; + let popoverContent = ""; let warningSymbol = ""; - const actualSize = result.result_size; + const actualSize = result.result_size ? result.result_size : 0; if (failed) { - td_title = EscapeAttribute(result.results); - } - else if (resultClass.includes("bg-success")) { - td_title = "Best runtime for this query!"; + popoverContent = result.results; + } else if (resultClass.includes("bg-success")) { + popoverContent = "Best runtime for this query!"; } if (majorityResultSize !== null && !failed && actualSize !== majorityResultSize) { - warningSymbol = ` ` ; - td_title += (td_title ? " " : "") + `Warning: Result size (${actualSize}) differs from majority (${majorityResultSize}).`; + warningSymbol = ` `; + popoverContent += + (popoverContent ? " " : "") + `Warning: Result size (${actualSize}) differs from majority (${majorityResultSize}).`; } let runtimeText = `${formatNumber(parseFloat(runtime))} s${warningSymbol}`; - row.innerHTML += `${runtimeText}`; + let popoverTitle = "No results returned" + if (actualSize === 1 && result.headers.length === 1) { + popoverTitle = `Single result: ${result.results[0]}` + } + else if (actualSize >= 1) { + popoverTitle = `Total result(s): ${result.result_size}`; + } + // const resultSizeLine = `
${actualSize}
`; + // const cellInnerHTML = ` + // ${runtimeText} + // ${resultSizeLine} + // `; + + // row.innerHTML += `${runtimeText}`; + row.innerHTML += ` + + ${runtimeText} + + `; } if (!document.querySelector("#compareExecDiv").classList.contains("d-none")) { row.style.cursor = "pointer"; From bb4f8351ed789bdffa618a0938b7380d56a506f2 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 11 Apr 2025 13:18:12 +0200 Subject: [PATCH 23/55] Add show result size checkbox to comparisonModal --- .../evaluation/www/engines-comparison.js | 100 +++++++++++++----- src/qlever/evaluation/www/index.html | 6 ++ src/qlever/evaluation/www/treant.css | 5 + 3 files changed, 82 insertions(+), 29 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index 6a14caf6..c9b3d837 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -11,13 +11,14 @@ function setListenersForEnginesComparison() { compareExecutionTreesClicked(); }); + const comparisonModal = document.querySelector("#comparisonModal"); // Before the modal is shown, update the url and history Stack and remove the previous table - document.querySelector("#comparisonModal").addEventListener("show.bs.modal", async function () { - const kb = document.querySelector("#comparisonModal").getAttribute("data-kb"); + comparisonModal.addEventListener("show.bs.modal", async function () { + const kb = comparisonModal.getAttribute("data-kb"); if (kb) { // If back/forward button, do nothing - if (document.querySelector("#comparisonModal").getAttribute("pop-triggered")) { - document.querySelector("#comparisonModal").removeAttribute("pop-triggered"); + if (comparisonModal.getAttribute("pop-triggered")) { + comparisonModal.removeAttribute("pop-triggered"); } // Else Update the url params and push the page to history stack else { @@ -42,19 +43,37 @@ function setListenersForEnginesComparison() { }); // After the modal is shown, populate the modal based on the selected kb - document.querySelector("#comparisonModal").addEventListener("shown.bs.modal", async function () { - const kb = document.querySelector("#comparisonModal").getAttribute("data-kb"); + comparisonModal.addEventListener("shown.bs.modal", async function () { + const kb = comparisonModal.getAttribute("data-kb"); if (kb) { openComparisonModal(kb); } - const popoverTriggerList = [].slice.call( - document.querySelector("#comparisonModal").querySelectorAll('[data-bs-toggle="popover"]') - ); + const popoverTriggerList = [].slice.call(comparisonModal.querySelectorAll('[data-bs-toggle="popover"]')); popoverTriggerList.map(function (el) { return new bootstrap.Popover(el); }); + + const resultSizeCheckbox = document.querySelector("#showResultSize"); + + if (!resultSizeCheckbox.hasEventListener) { + resultSizeCheckbox.addEventListener("change", function () { + const tdElements = comparisonModal.querySelectorAll("td"); + if (resultSizeCheckbox.checked) { + tdElements.forEach((td) => { + const resultSizeDiv = td.querySelector("div.text-muted.small"); + resultSizeDiv?.classList.remove("d-none"); + }); + } else { + tdElements.forEach((td) => { + const resultSizeDiv = td.querySelector("div.text-muted.small"); + resultSizeDiv?.classList.add("d-none"); + }); + } + }); + resultSizeCheckbox.hasEventListener = true; + } // Scroll to the previously selected row if user is coming back to this modal - const activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); + const activeRow = comparisonModal.querySelector(".table-active"); if (activeRow) { activeRow.scrollIntoView({ behavior: "auto", @@ -65,18 +84,18 @@ function setListenersForEnginesComparison() { }); // Handle the modal's `hidden.bs.modal` event - document.querySelector("#comparisonModal").addEventListener("hidden.bs.modal", function () { + comparisonModal.addEventListener("hidden.bs.modal", function () { // Don't execute any url or state based code when back/forward button clicked - if (document.querySelector("#comparisonModal").getAttribute("pop-triggered")) { - document.querySelector("#comparisonModal").removeAttribute("pop-triggered"); + if (comparisonModal.getAttribute("pop-triggered")) { + comparisonModal.removeAttribute("pop-triggered"); return; } // Case: Modal was hidden as a result of clicking on compare execution trees button - if (document.querySelector("#comparisonModal").getAttribute("compare-exec-clicked")) { + if (comparisonModal.getAttribute("compare-exec-clicked")) { const modalNode = document.querySelector("#compareExecTreeModal"); // Set kb, selected sparql engines and query attributes and show compareExecTreeModal - const kb = document.querySelector("#comparisonModal").getAttribute("data-kb"); + const kb = comparisonModal.getAttribute("data-kb"); const select1 = document.querySelector("#select1").value; const select2 = document.querySelector("#select2").value; const queryIndex = document.querySelector("#comparisonModal .table-active").rowIndex - 1; @@ -86,7 +105,7 @@ function setListenersForEnginesComparison() { modalNode.setAttribute("data-s2", select2); modalNode.setAttribute("data-qid", queryIndex); - document.querySelector("#comparisonModal").removeAttribute("compare-exec-clicked"); + comparisonModal.removeAttribute("compare-exec-clicked"); showModal(document.querySelector("#compareExecTreeModal")); } // Case: Modal was closed as result of clicking on the close button @@ -257,6 +276,23 @@ function getMajorityResultSize(kb, engines, queryId) { return majorityResultSize; } +function extractCoreValue(sparqlValue) { + if (sparqlValue.startsWith("<") && sparqlValue.endsWith(">")) { + // URI + return sparqlValue.slice(1, -1); + } + + const literalMatch = sparqlValue.match(/^"((?:[^"\\]|\\.)*)"/); + if (literalMatch) { + // Decode escape sequences (e.g. \" \\n etc.) + const raw = literalMatch[1]; + return raw.replace(/\\(.)/g, "$1"); + } + + // fallback: return as-is + return sparqlValue; +} + /** * Uses performanceDataPerKb object to create the engine runtime for each query comparison table * Gives the user the ability to selectively hide queries to reduce the clutter @@ -325,21 +361,26 @@ function createCompareResultsTable(kb, enginesToDisplay) { if (majorityResultSize !== null && !failed && actualSize !== majorityResultSize) { warningSymbol = ` `; popoverContent += - (popoverContent ? " " : "") + `Warning: Result size (${actualSize}) differs from majority (${majorityResultSize}).`; + (popoverContent ? " " : "") + + `Warning: Result size (${actualSize}) differs from majority (${majorityResultSize}).`; } let runtimeText = `${formatNumber(parseFloat(runtime))} s${warningSymbol}`; - let popoverTitle = "No results returned" + let popoverTitle = null; if (actualSize === 1 && result.headers.length === 1) { - popoverTitle = `Single result: ${result.results[0]}` + popoverTitle = `Single result: ${extractCoreValue(result.results[0])}`; } - else if (actualSize >= 1) { - popoverTitle = `Total result(s): ${result.result_size}`; + const resultSizeClass = !document.querySelector("#showResultSize").checked ? "d-none" : ""; + const resultSizeLine = `
${actualSize}
`; + const cellInnerHTML = ` + ${runtimeText} + ${resultSizeLine} + `; + if (popoverTitle) { + popoverContent = `${EscapeAttribute(popoverTitle)}
${EscapeAttribute(popoverContent)}` + } + else { + popoverContent = EscapeAttribute(popoverContent) } - // const resultSizeLine = `
${actualSize}
`; - // const cellInnerHTML = ` - // ${runtimeText} - // ${resultSizeLine} - // `; // row.innerHTML += `${runtimeText}`; row.innerHTML += ` @@ -348,13 +389,14 @@ function createCompareResultsTable(kb, enginesToDisplay) { class="text-end ${resultClass}" data-bs-toggle="popover" data-bs-trigger="hover focus" - title="${EscapeAttribute(popoverTitle)}" - data-bs-content="${EscapeAttribute(popoverContent)}" + data-bs-html="true" + data-bs-content="${popoverContent}" > - ${runtimeText} + ${cellInnerHTML} `; } + // title="${EscapeAttribute(popoverTitle)}" if (!document.querySelector("#compareExecDiv").classList.contains("d-none")) { row.style.cursor = "pointer"; row.addEventListener("click", function () { diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html index 55204160..e23a0a49 100644 --- a/src/qlever/evaluation/www/index.html +++ b/src/qlever/evaluation/www/index.html @@ -145,6 +145,12 @@
+
+ + +
diff --git a/src/qlever/evaluation/www/treant.css b/src/qlever/evaluation/www/treant.css index 60d4bbb1..9960e7a1 100644 --- a/src/qlever/evaluation/www/treant.css +++ b/src/qlever/evaluation/www/treant.css @@ -59,3 +59,8 @@ table td { padding-top: 0.2em !important; padding-bottom: 0.2em !important; } + +td[title]:hover, +td[data-bs-toggle="popover"]:hover { + cursor: pointer; +} From f61ca44da3963375b9bf9678d9db0f0093ee2d5e Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Thu, 17 Apr 2025 15:40:04 +0200 Subject: [PATCH 24/55] Added thousand separators and single result text to resultSize in engine comparison --- .../evaluation/www/engines-comparison.js | 32 ++++++------------- src/qlever/evaluation/www/helper.js | 17 ++++++++++ 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index c9b3d837..95e20ac1 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -276,23 +276,6 @@ function getMajorityResultSize(kb, engines, queryId) { return majorityResultSize; } -function extractCoreValue(sparqlValue) { - if (sparqlValue.startsWith("<") && sparqlValue.endsWith(">")) { - // URI - return sparqlValue.slice(1, -1); - } - - const literalMatch = sparqlValue.match(/^"((?:[^"\\]|\\.)*)"/); - if (literalMatch) { - // Decode escape sequences (e.g. \" \\n etc.) - const raw = literalMatch[1]; - return raw.replace(/\\(.)/g, "$1"); - } - - // fallback: return as-is - return sparqlValue; -} - /** * Uses performanceDataPerKb object to create the engine runtime for each query comparison table * Gives the user the ability to selectively hide queries to reduce the clutter @@ -345,7 +328,7 @@ function createCompareResultsTable(kb, enginesToDisplay) { continue; } let runtime = result.runtime_info.client_time; - const failed = !Array.isArray(result.results); + const failed = result.headers.length === 0 || !Array.isArray(result.results); let resultClass = failed ? "bg-danger bg-opacity-25" : ""; if (resultClass === "" && runtime === bestRuntime) { resultClass = "bg-success bg-opacity-25"; @@ -362,15 +345,18 @@ function createCompareResultsTable(kb, enginesToDisplay) { warningSymbol = ` `; popoverContent += (popoverContent ? " " : "") + - `Warning: Result size (${actualSize}) differs from majority (${majorityResultSize}).`; + `Warning: Result size (${format(actualSize)}) differs from majority (${format(majorityResultSize)}).`; } let runtimeText = `${formatNumber(parseFloat(runtime))} s${warningSymbol}`; let popoverTitle = null; - if (actualSize === 1 && result.headers.length === 1) { - popoverTitle = `Single result: ${extractCoreValue(result.results[0])}`; - } const resultSizeClass = !document.querySelector("#showResultSize").checked ? "d-none" : ""; - const resultSizeLine = `
${actualSize}
`; + let resultSizeText = format(actualSize); + if (actualSize === 1 && result.headers.length === 1 && Array.isArray(result.results) && result.results.length == 1) { + let singleResult = extractCoreValue(result.results[0]); + singleResult = parseInt(singleResult) ? format(singleResult) : singleResult; + resultSizeText = `1 [${singleResult}]`; + } + const resultSizeLine = `
${resultSizeText}
`; const cellInnerHTML = ` ${runtimeText} ${resultSizeLine} diff --git a/src/qlever/evaluation/www/helper.js b/src/qlever/evaluation/www/helper.js index b98ae0db..7a81526f 100644 --- a/src/qlever/evaluation/www/helper.js +++ b/src/qlever/evaluation/www/helper.js @@ -189,3 +189,20 @@ function showModal(modalNode, attributes = {}, fromPopState = false) { modal.show(); } } + +function extractCoreValue(sparqlValue) { + if (sparqlValue?.startsWith("<") && sparqlValue?.endsWith(">")) { + // URI + return sparqlValue.slice(1, -1); + } + + const literalMatch = sparqlValue?.match(/^"((?:[^"\\]|\\.)*)"/); + if (literalMatch) { + // Decode escape sequences (e.g. \" \\n etc.) + const raw = literalMatch[1]; + return raw.replace(/\\(.)/g, "$1"); + } + + // fallback: return as-is + return sparqlValue; +} From 3a1249b6b59217c8a1324c885cb8b5aa99521f01 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Thu, 17 Apr 2025 15:40:50 +0200 Subject: [PATCH 25/55] Added show resultSize option to queryDetailsModal --- src/qlever/evaluation/www/index.html | 11 ++++++- src/qlever/evaluation/www/query-details.js | 38 +++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html index e23a0a49..ffc6c1b6 100644 --- a/src/qlever/evaluation/www/index.html +++ b/src/qlever/evaluation/www/index.html @@ -74,8 +74,17 @@ aria-labelledby="runtimes-tab" tabindex="0">
-
+ +
+
Engine
+
+ + +

diff --git a/src/qlever/evaluation/www/query-details.js b/src/qlever/evaluation/www/query-details.js index 0953aaac..4ea5c2d0 100644 --- a/src/qlever/evaluation/www/query-details.js +++ b/src/qlever/evaluation/www/query-details.js @@ -55,6 +55,25 @@ function setListenersForQueriesTabs() { if (kb && engine) { await openQueryDetailsModal(kb, engine, selectedQuery, tab); } + const resultSizeCheckbox = document.querySelector("#showResultSizeQd"); + + if (!resultSizeCheckbox.hasEventListener) { + resultSizeCheckbox.addEventListener("change", function () { + const tdElements = modalNode.querySelectorAll("td"); + if (resultSizeCheckbox.checked) { + tdElements.forEach((td) => { + const resultSizeDiv = td.querySelector("div.text-muted.small"); + resultSizeDiv?.classList.remove("d-none"); + }); + } else { + tdElements.forEach((td) => { + const resultSizeDiv = td.querySelector("div.text-muted.small"); + resultSizeDiv?.classList.add("d-none"); + }); + } + }); + resultSizeCheckbox.hasEventListener = true; + } }); // Handle the modal's `hidden.bs.modal` event @@ -249,10 +268,27 @@ function createQueryTable(queryResult, tabBody) { runtime = "N/A"; } + const actualSize = query.result_size ? query.result_size : 0; + const resultSizeClass = !document.querySelector("#showResultSizeQd").checked ? "d-none" : ""; + let resultSizeText = format(actualSize); + if (actualSize === 1 && query.headers.length === 1 && Array.isArray(query.results) && query.results.length == 1) { + let singleResult = extractCoreValue(query.results[0]); + singleResult = parseInt(singleResult) ? format(singleResult) : singleResult; + resultSizeText = `1 [${singleResult}]`; + } + const resultSizeLine = `
${resultSizeText}
`; + const cellInnerHTML = ` + ${runtime} + ${resultSizeLine} + `; + + const failed = query.headers.length === 0 || !Array.isArray(query.results); + const failedTitle = failed ? EscapeAttribute(query.results) : ""; + resultClass = query.headers.length === 0 || !Array.isArray(query.results) ? "bg-danger bg-opacity-25" : ""; tabRow.innerHTML = ` ${query.query} - ${runtime} + ${cellInnerHTML} `; tabBody.appendChild(tabRow); }); From 681d56c6b420b34e89510cbc876cda7ec30147cd Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Thu, 17 Apr 2025 15:41:18 +0200 Subject: [PATCH 26/55] Only read yaml files from output directory in main --- src/qlever/evaluation/www/main.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index 8c327569..419455ee 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -126,7 +126,9 @@ async function getOutputFiles(url) { // Parse the HTML response to extract file names const parser = new DOMParser(); const htmlDoc = parser.parseFromString(data, "text/html"); - const fileList = Array.from(htmlDoc.querySelectorAll("a")).map((link) => link.textContent.trim()); + const fileList = Array.from(htmlDoc.querySelectorAll("a")) + .map((link) => link.textContent.trim()) + .filter((file) => file.endsWith(".yaml") || file.endsWith(".yml")); for (const file of fileList) { const parts = file.split("."); if (parts.length === 4 && parts[2] === "results") { From d4fd02415bba83700d329ba43517fd6ed9cf3f89 Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Thu, 17 Apr 2025 18:14:25 +0200 Subject: [PATCH 27/55] Minor fix --- src/qlever/commands/example_queries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 6c13717c..d8f66fdf 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -195,7 +195,7 @@ def pretty_printed_query(self, query: str, show_prefixes: bool) -> str: return query_pretty_printed.rstrip() except Exception: log.error( - "Failed to pretty-print query, returning original query: {e}" + f"Failed to pretty-print query, returning original query: {e}" ) return query.rstrip() From 8e47373f2e900074fbfb93c53fbef91d03e70fa7 Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Thu, 17 Apr 2025 18:15:23 +0200 Subject: [PATCH 28/55] Fix the previous fix --- src/qlever/commands/example_queries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index d8f66fdf..39abcbf9 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -193,7 +193,7 @@ def pretty_printed_query(self, query: str, show_prefixes: bool) -> str: pretty_print_query_cmd, return_output=True ) return query_pretty_printed.rstrip() - except Exception: + except Exception as e: log.error( f"Failed to pretty-print query, returning original query: {e}" ) From f784660043b343eee6c83a2d45eb2df1debaaf82 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 18 Apr 2025 16:14:08 +0200 Subject: [PATCH 29/55] Fix extractCoreValue error --- src/qlever/evaluation/www/engines-comparison.js | 3 ++- src/qlever/evaluation/www/query-details.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index 95e20ac1..8a06b5f1 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -352,7 +352,8 @@ function createCompareResultsTable(kb, enginesToDisplay) { const resultSizeClass = !document.querySelector("#showResultSize").checked ? "d-none" : ""; let resultSizeText = format(actualSize); if (actualSize === 1 && result.headers.length === 1 && Array.isArray(result.results) && result.results.length == 1) { - let singleResult = extractCoreValue(result.results[0]); + const resultValue = Array.isArray(result.results[0]) ? result.results[0][0] : result.results[0]; + let singleResult = extractCoreValue(resultValue); singleResult = parseInt(singleResult) ? format(singleResult) : singleResult; resultSizeText = `1 [${singleResult}]`; } diff --git a/src/qlever/evaluation/www/query-details.js b/src/qlever/evaluation/www/query-details.js index 4ea5c2d0..f8ecfcd9 100644 --- a/src/qlever/evaluation/www/query-details.js +++ b/src/qlever/evaluation/www/query-details.js @@ -272,7 +272,8 @@ function createQueryTable(queryResult, tabBody) { const resultSizeClass = !document.querySelector("#showResultSizeQd").checked ? "d-none" : ""; let resultSizeText = format(actualSize); if (actualSize === 1 && query.headers.length === 1 && Array.isArray(query.results) && query.results.length == 1) { - let singleResult = extractCoreValue(query.results[0]); + const resultValue = Array.isArray(query.results[0]) ? query.results[0][0] : query.results[0]; + let singleResult = extractCoreValue(resultValue); singleResult = parseInt(singleResult) ? format(singleResult) : singleResult; resultSizeText = `1 [${singleResult}]`; } From 4511583afb7b02bbed084967d0aebfce1680a057 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Wed, 23 Apr 2025 00:08:51 +0200 Subject: [PATCH 30/55] Create a symlink instead of a copy of yaml file in cwd for `example-queries` command --- src/qlever/commands/example_queries.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 39abcbf9..fb602a1e 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -884,8 +884,8 @@ def write_query_data_to_yaml( output_dir = Path(__file__).parent.parent / "evaluation" / "output" output_dir.mkdir(parents=True, exist_ok=True) yaml_file_path = output_dir / out_file - with open(yaml_file_path, "wb") as eval_yaml_file, open( - out_file, "wb" - ) as cwd_yaml_file: + with open(yaml_file_path, "wb") as eval_yaml_file: yaml.dump(query_data, eval_yaml_file) - yaml.dump(query_data, cwd_yaml_file) + symlink_path = Path(out_file) + if not symlink_path.exists(): + symlink_path.symlink_to(yaml_file_path) From 6de7471c4ec9c61fceb09ef83467b376bae5a70f Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Wed, 23 Apr 2025 00:09:51 +0200 Subject: [PATCH 31/55] Fix binding being null error when parsing sparql-results+json in `example-queries` --- src/qlever/commands/example_queries.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index fb602a1e..e87d4e5c 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -848,8 +848,12 @@ def get_query_results( results_str = run_command(get_result_cmd, return_output=True) results_json = json.loads(results_str) results = [] - for binding in results_json["bindings"]: + bindings = results_json.get("bindings", []) + for binding in bindings: result = [] + if not binding or not isinstance(binding, dict): + results.append([]) + continue for obj in binding.values(): value = '"' + obj["value"] + '"' if obj["type"] == "uri": From 5062bb985378ccb2e1e0e78bb17f1d4095b5114b Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Wed, 23 Apr 2025 00:13:24 +0200 Subject: [PATCH 32/55] Add query_name to engineStats map in engine comparison for easier generation of compareResultsTable --- .../evaluation/www/engines-comparison.js | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index 8a06b5f1..930d0a4c 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -181,7 +181,6 @@ function getEnginesToDisplay(kb) { * - Updates the modal content and displays the query details. * - Manages the state of the query execution tree and tab content. * - * @async * @param {string} kb - The selected knowledge base */ function openComparisonModal(kb) { @@ -230,25 +229,27 @@ function openComparisonModal(kb) { hideSpinner(); } -function getBestRuntime(kb, engines, queryId) { +function getBestRuntime(engines, engineStats) { best_time = Infinity; for (let engine of engines) { - const result = performanceDataPerKb[kb][engine]["queries"][queryId]; + const result = engineStats[engine]; + if (!result) continue; let runtime = parseFloat(result.runtime_info.client_time); let failed = result.headers.length === 0 || !Array.isArray(result.results); if (!failed && runtime < best_time) { best_time = runtime; } } - return best_time; + return isFinite(best_time) ? best_time : null; } -function getMajorityResultSize(kb, engines, queryId) { +function getMajorityResultSize(engines, engineStats) { const sizeCounts = new Map(); let validResultFound = false; for (let engine of engines) { - const result = performanceDataPerKb[kb][engine]["queries"][queryId]; + const result = engineStats[engine]; + if (!result) continue; const failed = result.headers.length === 0 || !Array.isArray(result.results); if (!failed && typeof result.result_size === "number" && result.result_size !== 0) { @@ -282,8 +283,7 @@ function getMajorityResultSize(kb, engines, queryId) { * @param kb Name of the knowledge base for which to get engine runtimes * @return HTML table with queries as rows and engine runtimes as columns */ -function createCompareResultsTable(kb, enginesToDisplay) { - let queryCount = 0; +function createCompareResultsTable(kb, engines) { const table = document.createElement("table"); table.classList.add("table", "table-hover", "table-bordered", "w-auto"); @@ -298,31 +298,39 @@ function createCompareResultsTable(kb, enginesToDisplay) { // Create dynamic headers and add them to the header row headerRow.innerHTML = "Query"; - const engines = enginesToDisplay; - let engineIndexForQueriesList = 0; - for (let i = 0; i < engines.length; i++) { - if (performanceDataPerKb[kb][engines[i]]["queries"].length > queryCount) { - queryCount = performanceDataPerKb[kb][engines[i]]["queries"].length; - engineIndexForQueriesList = i; - } - headerRow.innerHTML += `${engines[i]}`; + for (const engine of engines) { + headerRow.innerHTML += `${engine}`; } thead.appendChild(headerRow); table.appendChild(thead); + const queryLookup = {}; + for (const [engine, { queries }] of Object.entries(performanceDataPerKb[kb])) { + for (const { query, ...rest } of queries) { + if (!queryLookup[query]) { + queryLookup[query] = {}; + } + queryLookup[query][engine] = rest; + } + } // Create the table body and add rows and cells const tbody = document.createElement("tbody"); - for (let i = 0; i < queryCount; i++) { + + for (const [query, engineStats] of Object.entries(queryLookup)) { const row = document.createElement("tr"); - const title = EscapeAttribute(performanceDataPerKb[kb][engines[engineIndexForQueriesList]]["queries"][i]["sparql"]); - row.innerHTML += `${ - performanceDataPerKb[kb][engines[engineIndexForQueriesList]]["queries"][i]["query"] - }`; - const bestRuntime = getBestRuntime(kb, engines, i); - const majorityResultSize = getMajorityResultSize(kb, engines, i); - for (let engine of engines) { - const result = performanceDataPerKb[kb][engine]["queries"][i]; + let title; + for (const engineStat of Object.values(engineStats)) { + if (engineStat?.sparql) { + title = EscapeAttribute(engineStat.sparql); + break; + } + } + row.innerHTML += `${query}`; + const bestRuntime = getBestRuntime(engines, engineStats); + const majorityResultSize = getMajorityResultSize(engines, engineStats); + for (const engine of engines) { + const result = engineStats[engine]; if (!result) { row.innerHTML += "N/A"; continue; @@ -363,10 +371,9 @@ function createCompareResultsTable(kb, enginesToDisplay) { ${resultSizeLine} `; if (popoverTitle) { - popoverContent = `${EscapeAttribute(popoverTitle)}
${EscapeAttribute(popoverContent)}` - } - else { - popoverContent = EscapeAttribute(popoverContent) + popoverContent = `${EscapeAttribute(popoverTitle)}
${EscapeAttribute(popoverContent)}`; + } else { + popoverContent = EscapeAttribute(popoverContent); } // row.innerHTML += `${runtimeText}`; From a0552ace676b2c3c52e8e42c532b367ce9279e0a Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Wed, 23 Apr 2025 00:13:52 +0200 Subject: [PATCH 33/55] Make extractCoreValue function even more robust --- .../evaluation/www/engines-comparison.js | 14 ++++++- src/qlever/evaluation/www/helper.js | 37 ++++++++++++------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index 930d0a4c..51b7e932 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -359,8 +359,18 @@ function createCompareResultsTable(kb, engines) { let popoverTitle = null; const resultSizeClass = !document.querySelector("#showResultSize").checked ? "d-none" : ""; let resultSizeText = format(actualSize); - if (actualSize === 1 && result.headers.length === 1 && Array.isArray(result.results) && result.results.length == 1) { - const resultValue = Array.isArray(result.results[0]) ? result.results[0][0] : result.results[0]; + if ( + actualSize === 1 && + result.headers.length === 1 && + Array.isArray(result.results) && + result.results.length == 1 + ) { + let resultValue; + if (Array.isArray(result.results[0]) && result.results[0].length > 0) { + resultValue = result.results[0][0]; + } else { + resultValue = result.results[0]; + } let singleResult = extractCoreValue(resultValue); singleResult = parseInt(singleResult) ? format(singleResult) : singleResult; resultSizeText = `1 [${singleResult}]`; diff --git a/src/qlever/evaluation/www/helper.js b/src/qlever/evaluation/www/helper.js index 7a81526f..694f9a30 100644 --- a/src/qlever/evaluation/www/helper.js +++ b/src/qlever/evaluation/www/helper.js @@ -65,10 +65,9 @@ function addQueryStatistics(queryData) { runtimeArray.push(runtime); totalTime += runtime; totalLogTime += Math.max(Math.log(runtime), 0.001); - if (query.headers.length === 0 && typeof(query.results) == "string") { - failedQueries++ - } - else { + if (query.headers.length === 0 && typeof query.results == "string") { + failedQueries++; + } else { if (runtime < 1) { queriesUnder1s++; } @@ -115,13 +114,17 @@ function getTxtData(txtContent) { * @returns {string} */ function EscapeAttribute(text) { - return text.replace(/[&<>"']/g, match => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - }[match])); + return text.replace( + /[&<>"']/g, + (match) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[match]) + ); } /** @@ -191,12 +194,20 @@ function showModal(modalNode, attributes = {}, fromPopState = false) { } function extractCoreValue(sparqlValue) { - if (sparqlValue?.startsWith("<") && sparqlValue?.endsWith(">")) { + if (Array.isArray(sparqlValue)) { + if (sparqlValue.length === 0) return ""; + sparqlValue = sparqlValue[0]; + } + if (typeof sparqlValue !== "string" || sparqlValue.trim() === "") { + return ""; + } + + if (sparqlValue.startsWith("<") && sparqlValue.endsWith(">")) { // URI return sparqlValue.slice(1, -1); } - const literalMatch = sparqlValue?.match(/^"((?:[^"\\]|\\.)*)"/); + const literalMatch = sparqlValue.match(/^"((?:[^"\\]|\\.)*)"/); if (literalMatch) { // Decode escape sequences (e.g. \" \\n etc.) const raw = literalMatch[1]; From 13edef827162ad4165e3f6902a32376c3243a5af Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 25 Apr 2025 22:26:24 +0200 Subject: [PATCH 34/55] Updated args to `--result-file` and `--results-dir` in `example-queries` --- src/qlever/commands/example_queries.py | 76 ++++++++++++++++---------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index e87d4e5c..0e0185aa 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -21,7 +21,7 @@ from qlever.log import log, mute_log from qlever.util import run_command, run_curl_command -MAX_RESULT_SIZE = 50 +MAX_RESULT_SIZE = 20 class ExampleQueriesCommand(QleverCommand): @@ -163,20 +163,23 @@ def additional_arguments(self, subparser) -> None: help="When showing the query, also show the prefixes", ) subparser.add_argument( - "--generate-output-file", - action="store_true", - default=False, - help="Generate output file in the 'output' directory", - ) - subparser.add_argument( - "--backend-name", - default=None, - help="Name for the backend that would be used in performance comparison", + "--results-dir", + type=str, + default=".", + help=( + "The directory where the yaml result file would be saved " + "for evaluation web app (Default = current working directory)" + ), ) subparser.add_argument( - "--output-basename", + "--result-file", + type=str, default=None, - help="Name for the dataset that would be used in performance comparison", + help=( + "Name that would be used for result yaml file. " + "Make sure it is of the form . " + "for e.g.: wikidata.jena or dblp.qlever+" + ), ) def pretty_printed_query(self, query: str, show_prefixes: bool) -> str: @@ -286,13 +289,28 @@ def execute(self, args) -> bool: log.error("Cannot have both --remove-offset-and-limit and --limit") return False - if args.generate_output_file: - if args.output_basename is None or args.backend_name is None: + dataset, engine = None, None + if args.result_file is not None: + result_file_parts = args.result_file.split(".") + if len(result_file_parts) != 2: log.error( - "Both --output-basename and --backend-name parameters" - " must be passed when --generate-output-file is passed" + "Make sure --result-file is of the form . " + "for e.g.: wikidata.jena or dblp.qlever+" ) return False + results_dir_path = Path(args.results_dir) + if results_dir_path.exists(): + if not results_dir_path.is_dir(): + log.error( + f"{results_dir_path} exists but is not a directory." + ) + return False + else: + log.info( + f"Creating results directory: {results_dir_path.absolute()}" + ) + results_dir_path.mkdir(parents=True, exist_ok=True) + dataset, engine = result_file_parts # If `args.accept` is `application/sparql-results+json` or # `application/qlever-results+json` or `AUTO`, we need `jq`. @@ -327,8 +345,8 @@ def execute(self, args) -> bool: not args.sparql_endpoint or args.sparql_endpoint.startswith("https://qlever") ) - if args.generate_output_file: - is_qlever = is_qlever or "qlever" in args.backend_name.lower() + if engine is not None: + is_qlever = is_qlever or "qlever" in engine.lower() if args.clear_cache == "yes": if is_qlever: log.warning( @@ -680,7 +698,7 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: log.info("") # Get the yaml record if output file needs to be generated - if args.generate_output_file: + if args.result_file is not None: result_length = None if error_msg is not None else 1 result_length = ( result_size @@ -714,9 +732,8 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: assert len(query_times) + num_failed == len(example_query_lines) if len(yaml_records["queries"]) != 0: - outfile = ( - f"{args.output_basename}.{args.backend_name}.results.yaml" - ) + outfile_name = f"{dataset}.{engine}.results.yaml" + outfile = Path(args.results_dir) / outfile_name self.write_query_data_to_yaml( query_data=yaml_records, out_file=outfile, @@ -878,18 +895,17 @@ def get_query_results( @staticmethod def write_query_data_to_yaml( - query_data: dict[str, list[dict[str, Any]]], out_file: str + query_data: dict[str, list[dict[str, Any]]], out_file: Path ) -> None: """ Write yaml record for all queries to output yaml file """ yaml = YAML() yaml.default_flow_style = False - output_dir = Path(__file__).parent.parent / "evaluation" / "output" - output_dir.mkdir(parents=True, exist_ok=True) - yaml_file_path = output_dir / out_file - with open(yaml_file_path, "wb") as eval_yaml_file: + with open(out_file, "wb") as eval_yaml_file: yaml.dump(query_data, eval_yaml_file) - symlink_path = Path(out_file) - if not symlink_path.exists(): - symlink_path.symlink_to(yaml_file_path) + log.info("") + log.info( + f"Generated result yaml file: {out_file.stem}{out_file.suffix} " + f"in the directory {out_file.parent.resolve()}" + ) From 40c9d75f95c2ec4becd6b820f1fb64b6706a9f54 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 25 Apr 2025 22:27:55 +0200 Subject: [PATCH 35/55] Serve performanceData json directly for web app by reading yaml files from `--results-dir` --- src/qlever/commands/serve_evaluation_app.py | 120 +++++++++++++++++--- 1 file changed, 102 insertions(+), 18 deletions(-) diff --git a/src/qlever/commands/serve_evaluation_app.py b/src/qlever/commands/serve_evaluation_app.py index 76ea6f8d..b34d7fcc 100644 --- a/src/qlever/commands/serve_evaluation_app.py +++ b/src/qlever/commands/serve_evaluation_app.py @@ -1,8 +1,13 @@ from __future__ import annotations +import json +import math from functools import partial from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path +from urllib.parse import unquote + +from ruamel.yaml import YAML from qlever.command import QleverCommand from qlever.log import log @@ -10,6 +15,84 @@ EVAL_DIR = Path(__file__).parent.parent / "evaluation" +def get_query_stats(queries: list[dict]) -> dict[str, float | int]: + failed, under_1, bw_1_to_5, over_5 = 0, 0, 0, 0 + total_time, total_log_time = 0.0, 0.0 + runtimes = [] + for query in queries: + runtime = float(query["runtime_info"]["client_time"]) + runtimes.append(runtime) + total_time += runtime + total_log_time += max(math.log(runtime), 0.001) + if len(query["headers"]) == 0 and isinstance(query["results"], str): + failed += 1 + elif runtime <= 1: + under_1 += 1 + elif runtime > 5: + over_5 += 1 + else: + bw_1_to_5 += 1 + total_queries = len(queries) + query_data = { + "ameanTime": total_time / total_queries, + "gmeanTime": math.exp(total_log_time / total_queries), + "medianTime": sorted(runtimes)[total_queries // 2], + "under1s": (under_1 / total_queries) * 100, + "between1to5s": (bw_1_to_5 / total_queries) * 100, + "over5s": (over_5 / total_queries) * 100, + "failed": (failed / total_queries) * 100, + } + return query_data + + +def create_performance_data(yaml_dir: Path) -> dict | None: + yaml_parser = YAML(typ="safe") + performance_data = {} + if not yaml_dir.is_dir(): + return None + for yaml_file in yaml_dir.glob("*.results.yaml"): + file_name_split = yaml_file.stem.split(".") + if len(file_name_split) != 3: + continue + dataset, engine, _ = file_name_split + if performance_data.get(dataset) is None: + performance_data[dataset] = {} + if performance_data[dataset].get(engine) is None: + performance_data[dataset][engine] = {} + with yaml_file.open("r", encoding="utf-8") as queries_file: + queries_data = yaml_parser.load(queries_file) + query_stats = get_query_stats(queries_data["queries"]) + performance_data[dataset][engine] = {**query_stats, **queries_data} + return performance_data + + +class CustomHTTPRequestHandler(SimpleHTTPRequestHandler): + def __init__(self, *args, yaml_dir: Path | None = None, **kwargs) -> None: + self.yaml_dir = yaml_dir + super().__init__(*args, **kwargs) + + def do_GET(self) -> None: + path = unquote(self.path) + + if path == "/yaml_data": + try: + data = create_performance_data(self.yaml_dir) + json_data = json.dumps(data, indent=2).encode("utf-8") + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(json_data))) + self.end_headers() + self.wfile.write(json_data) + + except Exception as e: + self.send_response(500) + self.end_headers() + self.wfile.write(f"Error loading YAMLs: {e}".encode("utf-8")) + else: + super().do_GET() + + class ServeEvaluationAppCommand(QleverCommand): """ Class for executing the `serve-evaluation-app` command. @@ -37,31 +120,32 @@ def additional_arguments(self, subparser) -> None: "served (Default = 8000)" ), ) + ( + subparser.add_argument( + "--host", + type=str, + default="localhost", + help=( + "Host where the Performance comparison webapp will be " + "served (Default = localhost)" + ), + ), + ) subparser.add_argument( - "--host", + "--results-dir", type=str, - default="localhost", + default=".", help=( - "Host where the Performance comparison webapp will be " - "served (Default = localhost)" + "Path to the directory where yaml result files from " + "example-queries are saved (Default = current working dir)" ), ) - subparser.add_argument( - "--show-files-only", - action="store_true", - default=False, - help="Show list of yaml files that will be used for comparison", - ) def execute(self, args) -> bool: - if args.show_files_only: - output_dir = EVAL_DIR / "output" - for yaml_file in output_dir.iterdir(): - if yaml_file.is_file(): - log.info(yaml_file.name) - return True - - handler = partial(SimpleHTTPRequestHandler, directory=EVAL_DIR) + yaml_dir = Path(args.results_dir) + handler = partial( + CustomHTTPRequestHandler, directory=EVAL_DIR, yaml_dir=yaml_dir + ) httpd = HTTPServer(("", args.port), handler) log.info( f"Performance Comparison Web App is available at " From b74d1cf503c1c4e54c47fd848aaf0929ba153d5f Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 25 Apr 2025 22:28:34 +0200 Subject: [PATCH 36/55] Simplified main.js to work directly with performanceData json from server --- src/qlever/evaluation/www/helper.js | 97 ------------------ src/qlever/evaluation/www/main.js | 149 ++++++---------------------- 2 files changed, 31 insertions(+), 215 deletions(-) diff --git a/src/qlever/evaluation/www/helper.js b/src/qlever/evaluation/www/helper.js index 694f9a30..cb4625cf 100644 --- a/src/qlever/evaluation/www/helper.js +++ b/src/qlever/evaluation/www/helper.js @@ -28,85 +28,6 @@ function format(number) { return number.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,"); } -/** - * Fetches and processes a YAML file from a specified URL. - * - * @async - * @param {string} yamlFileUrl - The URL of the YAML file to fetch. - * @returns {Promise} A promise that resolves to an array of objects parsed from the YAML file. - * Will log an error if fetching or processing the YAML file fails. - */ -async function getYamlData(yamlFileUrl, headers = {}) { - // Fetch the YAML file and process its content - try { - const response = await fetch(outputUrl + yamlFileUrl, { headers }); - if (!response.ok) { - throw new Error(`Failed to fetch ${yamlFileUrl}`); - } - const yamlContent = await response.text(); - // Split the content into rows - const data = jsyaml.load(yamlContent); - return data; - } catch (error) { - console.error("Error fetching or processing YAML file:", error); - return null; - } -} - -function addQueryStatistics(queryData) { - let runtimeArray = []; - let totalTime = 0; - let totalLogTime = 0; - let queriesUnder1s = 0; - let queriesOver5s = 0; - let failedQueries = 0; - for (const query of queryData.queries) { - let runtime = parseFloat(query.runtime_info.client_time); - runtimeArray.push(runtime); - totalTime += runtime; - totalLogTime += Math.max(Math.log(runtime), 0.001); - if (query.headers.length === 0 && typeof query.results == "string") { - failedQueries++; - } else { - if (runtime < 1) { - queriesUnder1s++; - } - if (runtime > 5) { - queriesOver5s++; - } - } - } - let n = queryData.queries.length; - queryData.ameanTime = totalTime / n; - queryData.gmeanTime = Math.exp(totalLogTime / n); - queryData.medianTime = median(runtimeArray); - queryData.under1s = (queriesUnder1s / n) * 100; - queryData.over5s = (queriesOver5s / n) * 100; - queryData.failed = (failedQueries / n) * 100; - queryData.between1to5s = 100 - queryData.under1s - queryData.over5s - queryData.failed; -} - -/** - * Processes the content of a TXT file and returns its lines as an array of strings. - * - * @param {string} txtContent - The content of the TXT file as a string. - * @returns {string[]} An array of strings representing each line in the TXT file. - * Will log an error if processing the TXT content fails. - */ -function getTxtData(txtContent) { - // Fetch the TSV file and process its content - try { - // Split the content into rows - if (!txtContent) { - return []; - } - const rows = txtContent.replace(/\r/g, "").trim().split("\n"); - return rows; - } catch (error) { - console.error("Error processing TXT file:", error); - } -} - /** * Escape text for an attribute * Source: https://stackoverflow.com/a/77873486 @@ -143,24 +64,6 @@ function hideSpinner() { document.querySelector("#spinner").classList.add("d-none"); } -/** - * Calculates the median value from an array of numbers. - * - * @param {number[]} values - An array of numbers. - * @returns {number} The median value, or -1 if the array is empty. - */ -function median(values) { - if (values.length === 0) { - return -1; - } - - values = [...values].sort((a, b) => a - b); - - const half = Math.floor(values.length / 2); - - return values.length % 2 ? values[half] : (values[half - 1] + values[half]) / 2; -} - /** * Set multiple attributes on a given DOM node dynamically. * Simplifies setting multiple attributes at once by iterating over a key-value pair object. diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index 419455ee..1683f849 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -57,93 +57,6 @@ function addEventListenersForCard(cardNode) { }); } -/** - * Create promises out of eval and fail logs and return the results - * @param fileUrls fileUrls array from getFileUrls function - * @return Promise that is reolved with array of results of fetching eval and fail logs - * @async - */ -async function fetchAndProcessFiles(fileUrls) { - const fetchPromises = fileUrls.map(async (url) => { - try { - //const content = await response.text(); - const content = await getYamlData(url, { - headers: { - "Cache-Control": "no-cache", - }, - }); - if (content == null) { - throw new Error(`Failed to fetch ${url}`); - } - console.log(`File ${url} content: ${content}`); - // Process the content as needed - return { status: "fulfilled", value: content }; - } catch (error) { - console.error(`Error fetching ${url}: ${error.message}`); - // Handle the error gracefully - return { status: "rejected", reason: error.message }; - } - }); - - const results = await Promise.allSettled(fetchPromises); - return results; // Return the results to be accessed later -} - -/** - * Fetch all the relevant metrics required by populateCard function - * @param results Array withresults from fetching yaml file - * @param fileList fileList array from getOutputFiles function - */ -function processResults(results, fileList) { - for (let i = 0; i < results.length; i++) { - let fileNameComponents = fileList[i].split("."); - const kb = fileNameComponents[0]; - const engine = fileNameComponents[1]; - if (results[i].status == "fulfilled" && results[i].value.status == "fulfilled") { - const queryData = results[i].value.value; - addQueryStatistics(queryData); - for (const query of queryData.queries) { - if (query.headers.length !== 0 && query.runtime_info.hasOwnProperty("query_execution_tree")) { - execTreeEngines.push(engine); - break; - } - } - performanceDataPerKb[kb][engine] = queryData; - } - } -} - -/** - * Populate global sparqlEngines and kbs array based on files in the output directory - * @param url url of the output directory - * @async - */ -async function getOutputFiles(url) { - try { - const response = await fetch(url); - const data = await response.text(); - - // Parse the HTML response to extract file names - const parser = new DOMParser(); - const htmlDoc = parser.parseFromString(data, "text/html"); - const fileList = Array.from(htmlDoc.querySelectorAll("a")) - .map((link) => link.textContent.trim()) - .filter((file) => file.endsWith(".yaml") || file.endsWith(".yml")); - for (const file of fileList) { - const parts = file.split("."); - if (parts.length === 4 && parts[2] === "results") { - const kb = parts[0]; - if (!kbs.includes(kb)) kbs.push(kb); - const engine = parts[1]; - if (!sparqlEngines.includes(engine)) sparqlEngines.push(engine); - } - } - return fileList; - } catch (error) { - console.error("Error fetching file list:", error); - } -} - /** * Hide a modal if it is currently open. * Adds a custom `pop-triggered` attribute to the modal so that modal.hide() doesn't execute any code after closing @@ -269,41 +182,41 @@ function getSanitizedQAndT(q, t) { // Use the DOMContentLoaded event listener to ensure the DOM is ready document.addEventListener("DOMContentLoaded", async function () { - getOutputFiles(outputUrl).then(async function (fileList) { - // Fetch the card template - const response = await fetch("card-template.html"); - const templateText = await response.text(); + // Fetch the card template + const response = await fetch("card-template.html"); + const templateText = await response.text(); - // Create a virtual DOM element to hold the template - const tempDiv = document.createElement("div"); - tempDiv.innerHTML = templateText; - const cardTemplate = tempDiv.querySelector("#cardTemplate"); + // Create a virtual DOM element to hold the template + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = templateText; + const cardTemplate = tempDiv.querySelector("#cardTemplate"); - const fragment = document.createDocumentFragment(); + const fragment = document.createDocumentFragment(); - for (let kb of kbs) { - performanceDataPerKb[kb.toLowerCase()] = {}; + try { + const response = await fetch("/yaml_data"); + if (!response.ok) { + throw new Error(`Server error: ${response.status}`); } - - // For all the tsv files in the output folder, create bootstrap card and display on main page - fetchAndProcessFiles(fileList).then(async (results) => { - processResults(results, fileList, fragment, cardTemplate); - for (const kb of Object.keys(performanceDataPerKb)) { - fragment.appendChild(populateCard(cardTemplate, kb)); - } - document.getElementById("cardsContainer").appendChild(fragment); - $("#cardsContainer table").tablesorter({ - theme: "bootstrap", - sortStable: true, - sortInitialOrder: "desc", - }); - // Navigate to the correct page (or modal) based on the url - await showPageFromUrl(); + performanceDataPerKb = await response.json(); + for (const kb of Object.keys(performanceDataPerKb)) { + fragment.appendChild(populateCard(cardTemplate, kb)); + } + document.getElementById("cardsContainer").appendChild(fragment); + $("#cardsContainer table").tablesorter({ + theme: "bootstrap", + sortStable: true, + sortInitialOrder: "desc", }); + // Navigate to the correct page (or modal) based on the url + await showPageFromUrl(); + } catch (error) { + console.error("Failed to fetch performance data:", error); + return null; + } - // Setup event listeners for queryDetailsModal, comparisonModal and compareExecModal - setListenersForQueriesTabs(); - setListenersForCompareExecModal(); - setListenersForEnginesComparison(); - }); + // Setup event listeners for queryDetailsModal, comparisonModal and compareExecModal + setListenersForQueriesTabs(); + setListenersForCompareExecModal(); + setListenersForEnginesComparison(); }); From 4ff94f785fb09c208115d1e2ffebd36cbc3260d0 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 25 Apr 2025 22:29:42 +0200 Subject: [PATCH 37/55] Added right-click to copy tooltip and display toast to compareResults table --- .../evaluation/www/engines-comparison.js | 58 ++++++++++++------- src/qlever/evaluation/www/index.html | 10 ++++ 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index 51b7e932..19558ae9 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -215,6 +215,22 @@ function openComparisonModal(kb) { // Create a DocumentFragment to build the table const fragment = document.createDocumentFragment(); const table = createCompareResultsTable(kb, enginesToDisplay); + table.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const td = e.target.closest("td"); + if (td && td.title) { + const textToCopy = td.title.trim(); + navigator.clipboard + .writeText(textToCopy) + .then(() => { + const copyToast = bootstrap.Toast.getOrCreateInstance(document.getElementById("copyToast")); + copyToast.show(); + }) + .catch((err) => { + console.error("Copy failed:", err); + }); + } + }); fragment.appendChild(table); @@ -293,7 +309,8 @@ function createCompareResultsTable(kb, engines) { headerRow.title = ` Click on a column to sort it in descending or ascending order. Sort multiple columns simultaneously by holding down the Shift key - and clicking a second, third or even fourth column header! + and clicking a second, third or even fourth column header!\n + Right click on any table cell to copy the content of its tooltip! `; // Create dynamic headers and add them to the header row @@ -356,7 +373,6 @@ function createCompareResultsTable(kb, engines) { `Warning: Result size (${format(actualSize)}) differs from majority (${format(majorityResultSize)}).`; } let runtimeText = `${formatNumber(parseFloat(runtime))} s${warningSymbol}`; - let popoverTitle = null; const resultSizeClass = !document.querySelector("#showResultSize").checked ? "d-none" : ""; let resultSizeText = format(actualSize); if ( @@ -380,25 +396,25 @@ function createCompareResultsTable(kb, engines) { ${runtimeText} ${resultSizeLine} `; - if (popoverTitle) { - popoverContent = `${EscapeAttribute(popoverTitle)}
${EscapeAttribute(popoverContent)}`; - } else { - popoverContent = EscapeAttribute(popoverContent); - } - - // row.innerHTML += `${runtimeText}`; - row.innerHTML += ` - - ${cellInnerHTML} - - `; + // if (popoverTitle) { + // popoverContent = `${EscapeAttribute(popoverTitle)}
${EscapeAttribute(popoverContent)}`; + // } else { + popoverContent = EscapeAttribute(popoverContent); + // } + + row.innerHTML += `${cellInnerHTML}`; + // row.innerHTML += ` + // + // ${cellInnerHTML} + // + // `; } // title="${EscapeAttribute(popoverTitle)}" if (!document.querySelector("#compareExecDiv").classList.contains("d-none")) { diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html index ffc6c1b6..fec2e666 100644 --- a/src/qlever/evaluation/www/index.html +++ b/src/qlever/evaluation/www/index.html @@ -244,6 +244,16 @@
+
+ +
From 9ca6b992fa31e5945983c6d43b03c36a76ae3cba Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sat, 26 Apr 2025 17:19:20 +0200 Subject: [PATCH 38/55] Consider only successful queries for web app main page query stats --- src/qlever/commands/serve_evaluation_app.py | 55 ++++++++++++--------- src/qlever/evaluation/www/helper.js | 1 + 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/qlever/commands/serve_evaluation_app.py b/src/qlever/commands/serve_evaluation_app.py index b34d7fcc..0b1e4397 100644 --- a/src/qlever/commands/serve_evaluation_app.py +++ b/src/qlever/commands/serve_evaluation_app.py @@ -15,33 +15,44 @@ EVAL_DIR = Path(__file__).parent.parent / "evaluation" -def get_query_stats(queries: list[dict]) -> dict[str, float | int]: - failed, under_1, bw_1_to_5, over_5 = 0, 0, 0, 0 - total_time, total_log_time = 0.0, 0.0 +def get_query_stats(queries: list[dict]) -> dict[str, float | None]: + query_data = { + "ameanTime": None, + "gmeanTime": None, + "medianTime": None, + "under1s": 0.0, + "between1to5s": 0.0, + "over5s": 0.0, + "failed": 0.0, + } + failed = under_1 = bw_1_to_5 = over_5 = 0 + total_time = total_log_time = 0.0 runtimes = [] for query in queries: - runtime = float(query["runtime_info"]["client_time"]) - runtimes.append(runtime) - total_time += runtime - total_log_time += max(math.log(runtime), 0.001) if len(query["headers"]) == 0 and isinstance(query["results"], str): failed += 1 - elif runtime <= 1: - under_1 += 1 - elif runtime > 5: - over_5 += 1 else: - bw_1_to_5 += 1 - total_queries = len(queries) - query_data = { - "ameanTime": total_time / total_queries, - "gmeanTime": math.exp(total_log_time / total_queries), - "medianTime": sorted(runtimes)[total_queries // 2], - "under1s": (under_1 / total_queries) * 100, - "between1to5s": (bw_1_to_5 / total_queries) * 100, - "over5s": (over_5 / total_queries) * 100, - "failed": (failed / total_queries) * 100, - } + runtime = float(query["runtime_info"]["client_time"]) + total_time += runtime + total_log_time += max(math.log(runtime), 0.001) + runtimes.append(runtime) + if runtime <= 1: + under_1 += 1 + elif runtime > 5: + over_5 += 1 + else: + bw_1_to_5 += 1 + total_successful = len(runtimes) + if total_successful == 0: + query_data["failed"] = 100.0 + else: + query_data["ameanTime"] = total_time / total_successful + query_data["gmeanTime"] = math.exp(total_log_time / total_successful) + query_data["medianTime"] = sorted(runtimes)[total_successful // 2] + query_data["under1s"] = (under_1 / total_successful) * 100 + query_data["between1to5s"] = (bw_1_to_5 / total_successful) * 100 + query_data["over5s"] = (over_5 / total_successful) * 100 + query_data["failed"] = (failed / len(queries)) * 100 return query_data diff --git a/src/qlever/evaluation/www/helper.js b/src/qlever/evaluation/www/helper.js index cb4625cf..46c29d3d 100644 --- a/src/qlever/evaluation/www/helper.js +++ b/src/qlever/evaluation/www/helper.js @@ -15,6 +15,7 @@ var very_high_query_time_ms = 1000; * @returns {string} The formatted number as a string. */ function formatNumber(number) { + if (Number.isNaN(number)) return "N/A " return number.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } From 4af9295243ec91a70db6b4f5f4b8c21190b90fa2 Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Thu, 1 May 2025 19:03:44 +0200 Subject: [PATCH 39/55] Set default `widht-error-message` to 50 (from other PR) --- src/qlever/commands/example_queries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 0e0185aa..17233c0b 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -134,7 +134,7 @@ def additional_arguments(self, subparser) -> None: subparser.add_argument( "--width-error-message", type=int, - default=80, + default=50, help="Width for printing the error message (0 = no limit)", ) subparser.add_argument( From 476e81158a6e4d66482a2d73c37c41b5e8f566f6 Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Fri, 2 May 2025 18:17:38 +0200 Subject: [PATCH 40/55] Bump version to 0.5.23 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9ca26152..59e4fddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "qlever" description = "Command-line tool for using the QLever graph database" -version = "0.5.22" +version = "0.5.23" authors = [ { name = "Hannah Bast", email = "bast@cs.uni-freiburg.de" } ] From 453389272c93c70d55db87bf5458ae082f191f9f Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 9 May 2025 00:56:15 +0200 Subject: [PATCH 41/55] Fix yaml_data path and augment performanceDataPerKb for ease of use in comparison --- src/qlever/evaluation/www/main.js | 32 ++++++++++++- src/qlever/evaluation/www/treant.css | 68 ++++++++++++++-------------- 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index 1683f849..971d75ba 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -180,6 +180,35 @@ function getSanitizedQAndT(q, t) { return { selectedQuery: selectedQuery, tab: tab }; } +function augmentPerformanceDataPerKb(performanceDataPerKb) { + for (const engines of Object.values(performanceDataPerKb)) { + for (const { queries } of Object.values(engines)) { + for (const query of queries) { + const failed = query.headers.length === 0 || !Array.isArray(query.results); + let singleResult = null; + if ( + query.result_size === 1 && + query.headers.length === 1 && + Array.isArray(query.results) && + query.results.length == 1 + ) { + let resultValue; + if (Array.isArray(query.results[0]) && query.results[0].length > 0) { + resultValue = query.results[0][0]; + } else { + resultValue = query.results[0]; + } + singleResult = extractCoreValue(resultValue); + singleResult = parseInt(singleResult) ? format(singleResult) : singleResult; + } + query.failed = failed; + query.singleResult = singleResult; + query.result_size = query.result_size ? query.result_size : 0; + } + } + } +} + // Use the DOMContentLoaded event listener to ensure the DOM is ready document.addEventListener("DOMContentLoaded", async function () { // Fetch the card template @@ -194,11 +223,12 @@ document.addEventListener("DOMContentLoaded", async function () { const fragment = document.createDocumentFragment(); try { - const response = await fetch("/yaml_data"); + const response = await fetch(window.location.origin + "/yaml_data"); if (!response.ok) { throw new Error(`Server error: ${response.status}`); } performanceDataPerKb = await response.json(); + augmentPerformanceDataPerKb(performanceDataPerKb); for (const kb of Object.keys(performanceDataPerKb)) { fragment.appendChild(populateCard(cardTemplate, kb)); } diff --git a/src/qlever/evaluation/www/treant.css b/src/qlever/evaluation/www/treant.css index 9960e7a1..9a4ab796 100644 --- a/src/qlever/evaluation/www/treant.css +++ b/src/qlever/evaluation/www/treant.css @@ -11,41 +11,41 @@ .Treant > .node img { border: none; float: left; } .node { - padding: 2px; - border-radius: 3px; - background-color: #FEFEFE; - border: 1px solid #000; - min-width: 20em; - max-width: 30em; - color: black; - } - - .node > p { margin: 0; } - .node-name { font-weight: bold; } - .node-size:before { content: "Size: "; } - .node-cols:before { content: "Cols: "; } - .node-size:before { content: "Size: "; } - .node-time:before { content: "Time: "; } - .node-time:after { content: "ms"; } - div.cached .node-time:after { content: "ms (cached -> 0ms)"; } - .node-total { display: none; } - .node-cached { display: none; } - .node.cached { color: grey; border: 1px solid grey; } - .node.high { background-color: #FFF7F7; } - .node.veryhigh { background-color: #FFEEEE; } - .node.high.cached { background-color: #FFFFF7; } - .node.veryhigh.cached { background-color: #FFFFEE; } + padding: 2px; + border-radius: 3px; + background-color: #FEFEFE; + border: 1px solid #000; + min-width: 20em; + max-width: 30em; + color: black; +} + +.node > p { margin: 0; } +.node-name { font-weight: bold; } +.node-size:before { content: "Size: "; } +.node-cols:before { content: "Cols: "; } +.node-size:before { content: "Size: "; } +.node-time:before { content: "Time: "; } +.node-time:after { content: "ms"; } +div.cached .node-time:after { content: "ms (cached -> 0ms)"; } +.node-total { display: none; } +.node-cached { display: none; } +.node.cached { color: grey; border: 1px solid grey; } +.node.high { background-color: #FFF7F7; } +.node.veryhigh { background-color: #FFEEEE; } +.node.high.cached { background-color: #FFFFF7; } +.node.veryhigh.cached { background-color: #FFFFEE; } - #spinner { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(186, 179, 179, 0.7); - justify-content: center; - align-items: center; - z-index: 10000; +#spinner { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(186, 179, 179, 0.7); + justify-content: center; + align-items: center; + z-index: 10000; } .font-size-30 { font-size: 30%; } From 24c938c53d0cda59ab87957682dcdafdf716f991 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 9 May 2025 00:56:59 +0200 Subject: [PATCH 42/55] Add result_size to tooltip and no consensus warning symbol --- .../evaluation/www/engines-comparison.js | 94 +++++++++++-------- 1 file changed, 53 insertions(+), 41 deletions(-) diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js index 19558ae9..1fcd1b92 100644 --- a/src/qlever/evaluation/www/engines-comparison.js +++ b/src/qlever/evaluation/www/engines-comparison.js @@ -261,36 +261,41 @@ function getBestRuntime(engines, engineStats) { function getMajorityResultSize(engines, engineStats) { const sizeCounts = new Map(); - let validResultFound = false; for (let engine of engines) { const result = engineStats[engine]; if (!result) continue; - const failed = result.headers.length === 0 || !Array.isArray(result.results); + if (result.failed) continue; - if (!failed && typeof result.result_size === "number" && result.result_size !== 0) { - validResultFound = true; - const size = result.result_size; - sizeCounts.set(size, (sizeCounts.get(size) || 0) + 1); - } + const resultSize = result.singleResult ? result.singleResult : format(result.result_size); + sizeCounts.set(resultSize, (sizeCounts.get(resultSize) || 0) + 1); } - if (!validResultFound || sizeCounts.size === 0) { + if (sizeCounts.size === 0) { // All results failed or only had result_size = 0 return null; } let majorityResultSize = null; let maxCount = 0; + let tie = false; for (const [size, count] of sizeCounts.entries()) { if (count > maxCount) { maxCount = count; majorityResultSize = size; + tie = false; + } else if (count === maxCount) { + tie = true; } } - return majorityResultSize; + return tie ? "no_consensus" : majorityResultSize; +} + +function resultSizeToDisplay(result) { + const resultSizeText = result.singleResult ? `1 [${result.singleResult}]` : result.result_size.toString(); + return resultSizeText; } /** @@ -336,61 +341,68 @@ function createCompareResultsTable(kb, engines) { for (const [query, engineStats] of Object.entries(queryLookup)) { const row = document.createElement("tr"); + + // Get full sparql query from any engine let title; for (const engineStat of Object.values(engineStats)) { if (engineStat?.sparql) { - title = EscapeAttribute(engineStat.sparql); + title = engineStat.sparql; break; } } - row.innerHTML += `${query}`; + warningSymbol = ``; + const bestRuntime = getBestRuntime(engines, engineStats); const majorityResultSize = getMajorityResultSize(engines, engineStats); + let showRowWarning = ""; + if (majorityResultSize === "no_consensus") { + showRowWarning = warningSymbol; + title = `The result sizes for the engines do not match!\n\n${title}`; + } + + row.innerHTML += `${query} ${showRowWarning}`; + for (const engine of engines) { const result = engineStats[engine]; if (!result) { row.innerHTML += "N/A"; continue; } + // set result class to show failed and best runtime queries let runtime = result.runtime_info.client_time; - const failed = result.headers.length === 0 || !Array.isArray(result.results); - let resultClass = failed ? "bg-danger bg-opacity-25" : ""; - if (resultClass === "" && runtime === bestRuntime) { + let resultClass = result.failed ? "bg-danger bg-opacity-25" : ""; + if (!result.failed && runtime === bestRuntime) { resultClass = "bg-success bg-opacity-25"; } + let popoverContent = ""; - let warningSymbol = ""; - const actualSize = result.result_size ? result.result_size : 0; - if (failed) { + if (result.failed) { popoverContent = result.results; - } else if (resultClass.includes("bg-success")) { - popoverContent = "Best runtime for this query!"; + } else { + popoverContent = `Result size: ${resultSizeToDisplay(result)}`; } - if (majorityResultSize !== null && !failed && actualSize !== majorityResultSize) { - warningSymbol = ` `; - popoverContent += - (popoverContent ? " " : "") + - `Warning: Result size (${format(actualSize)}) differs from majority (${format(majorityResultSize)}).`; - } - let runtimeText = `${formatNumber(parseFloat(runtime))} s${warningSymbol}`; - const resultSizeClass = !document.querySelector("#showResultSize").checked ? "d-none" : ""; - let resultSizeText = format(actualSize); - if ( - actualSize === 1 && - result.headers.length === 1 && - Array.isArray(result.results) && - result.results.length == 1 - ) { - let resultValue; - if (Array.isArray(result.results[0]) && result.results[0].length > 0) { - resultValue = result.results[0][0]; + + // Add warning if result size doesn"t match + let showWarning = ""; + if (!["no_consensus", null].includes(majorityResultSize) && !result.failed) { + if (result.singleResult) { + if (result.singleResult !== majorityResultSize) { + showWarning = warningSymbol; + popoverContent += `\nWarning: Result size ${result.singleResult} differs from majority ${majorityResultSize}.`; + } } else { - resultValue = result.results[0]; + if (format(result.result_size) !== majorityResultSize) { + showWarning = warningSymbol; + popoverContent += `\nWarning: Result size ${format( + result.result_size + )} differs from majority ${majorityResultSize}.`; + } } - let singleResult = extractCoreValue(resultValue); - singleResult = parseInt(singleResult) ? format(singleResult) : singleResult; - resultSizeText = `1 [${singleResult}]`; } + let runtimeText = `${formatNumber(parseFloat(runtime))} s ${showWarning}`; + + const resultSizeClass = !document.querySelector("#showResultSize").checked ? "d-none" : ""; + let resultSizeText = resultSizeToDisplay(result); const resultSizeLine = `
${resultSizeText}
`; const cellInnerHTML = ` ${runtimeText} From 46a78b90680d2e928f2160e6245fee2345cd4937 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 9 May 2025 01:51:38 +0200 Subject: [PATCH 43/55] Update `example-queries` and `serve-evaluation-app` commands to use pyyaml instead of ruamel.yaml --- pyproject.toml | 2 +- src/qlever/commands/example_queries.py | 18 ++++++++---------- src/qlever/commands/serve_evaluation_app.py | 5 ++--- src/qlever/commands/ui.py | 18 +++++++++++------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6f586339..8c5a0aed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Database :: Front-Ends" ] -dependencies = [ "psutil", "termcolor", "argcomplete", "pyyaml", "ruamel.yaml", "rdflib" ] +dependencies = [ "psutil", "termcolor", "argcomplete", "pyyaml", "rdflib" ] [project.urls] Github = "https://github.com/ad-freiburg/qlever" diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 82307ec3..02041e8a 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -11,13 +11,13 @@ from pathlib import Path from typing import Any +import yaml from rdflib import Graph -from ruamel.yaml import YAML -from ruamel.yaml.scalarstring import LiteralScalarString from termcolor import colored from qlever.command import QleverCommand from qlever.commands.clear_cache import ClearCacheCommand +from qlever.commands.ui import dict_to_yaml from qlever.log import log, mute_log from qlever.util import run_command, run_curl_command @@ -216,10 +216,9 @@ def parse_queries_file(queries_file: str) -> dict[str, list[str, str]]: """ Parse a YAML file and validate its structure. """ - yaml = YAML(typ="safe") - with open(queries_file, "r", encoding="utf-8") as file: + with open(queries_file, "r", encoding="utf-8") as q_file: try: - data = yaml.load(file) # Load YAML safely + data = yaml.safe_load(q_file) # Load YAML safely except yaml.YAMLError as exc: log.error(f"Error parsing {queries_file} file: {exc}") @@ -801,7 +800,7 @@ def get_record_for_yaml( """ record = { "query": query, - "sparql": LiteralScalarString(sparql), + "sparql": sparql, "runtime_info": {}, } if result_size is None: @@ -900,10 +899,9 @@ def write_query_data_to_yaml( """ Write yaml record for all queries to output yaml file """ - yaml = YAML() - yaml.default_flow_style = False - with open(out_file, "wb") as eval_yaml_file: - yaml.dump(query_data, eval_yaml_file) + config_yaml = dict_to_yaml(query_data) + with open(out_file, "w") as eval_yaml_file: + eval_yaml_file.write(config_yaml) log.info("") log.info( f"Generated result yaml file: {out_file.stem}{out_file.suffix} " diff --git a/src/qlever/commands/serve_evaluation_app.py b/src/qlever/commands/serve_evaluation_app.py index 0b1e4397..76b83402 100644 --- a/src/qlever/commands/serve_evaluation_app.py +++ b/src/qlever/commands/serve_evaluation_app.py @@ -7,7 +7,7 @@ from pathlib import Path from urllib.parse import unquote -from ruamel.yaml import YAML +import yaml from qlever.command import QleverCommand from qlever.log import log @@ -57,7 +57,6 @@ def get_query_stats(queries: list[dict]) -> dict[str, float | None]: def create_performance_data(yaml_dir: Path) -> dict | None: - yaml_parser = YAML(typ="safe") performance_data = {} if not yaml_dir.is_dir(): return None @@ -71,7 +70,7 @@ def create_performance_data(yaml_dir: Path) -> dict | None: if performance_data[dataset].get(engine) is None: performance_data[dataset][engine] = {} with yaml_file.open("r", encoding="utf-8") as queries_file: - queries_data = yaml_parser.load(queries_file) + queries_data = yaml.safe_load(queries_file) query_stats = get_query_stats(queries_data["queries"]) performance_data[dataset][engine] = {**query_stats, **queries_data} return performance_data diff --git a/src/qlever/commands/ui.py b/src/qlever/commands/ui.py index fb49ee78..b7bbd686 100644 --- a/src/qlever/commands/ui.py +++ b/src/qlever/commands/ui.py @@ -13,13 +13,16 @@ # Return a YAML string for the given dictionary. Format values with # newlines using the "|" style. -def dict_to_yaml(dictionary): - # Custom representer for yaml, which uses the "|" style only for - # multiline strings. - # - # NOTE: We replace all `\r\n` with `\n` because otherwise the `|` style - # does not work as expected. - class MultiLineDumper(yaml.Dumper): +def dict_to_yaml(dictionary: dict) -> str: + """ + Custom representer for yaml, which uses the "|" style only for + multiline strings. + + NOTE: We replace all `\r\n` with `\n` because otherwise the `|` style + does not work as expected. + """ + + class MultiLineDumper(yaml.SafeDumper): def represent_scalar(self, tag, value, style=None): value = value.replace("\r\n", "\n") if isinstance(value, str) and "\n" in value: @@ -30,6 +33,7 @@ def represent_scalar(self, tag, value, style=None): return yaml.dump( dictionary, sort_keys=False, + allow_unicode=True, Dumper=MultiLineDumper, ) From ccbd9e203223b5bdfa73319ca31ac6d3b5230da6 Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Fri, 9 May 2025 17:45:03 +0200 Subject: [PATCH 44/55] Fix path to `yaml_data` --- src/qlever/evaluation/www/main.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index 971d75ba..b3c55f41 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -223,7 +223,12 @@ document.addEventListener("DOMContentLoaded", async function () { const fragment = document.createDocumentFragment(); try { - const response = await fetch(window.location.origin + "/yaml_data"); + // Get the current URL without the part after the final `/` (and ignore a + // `/` at the end) + const yaml_path = window.location.origin + + window.location.pathname.replace(/\/$/, "").replace(/\/[^/]*$/, "/"); + const response = await fetch(yaml_path + "yaml_data"); + // const response = await fetch("../yaml_data"); if (!response.ok) { throw new Error(`Server error: ${response.status}`); } From 21f47fe2288e46ce2001ecc440b9329e90f3a6e6 Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Fri, 9 May 2025 17:47:27 +0200 Subject: [PATCH 45/55] Remove commented out line --- src/qlever/evaluation/www/main.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index b3c55f41..393464da 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -228,7 +228,6 @@ document.addEventListener("DOMContentLoaded", async function () { const yaml_path = window.location.origin + window.location.pathname.replace(/\/$/, "").replace(/\/[^/]*$/, "/"); const response = await fetch(yaml_path + "yaml_data"); - // const response = await fetch("../yaml_data"); if (!response.ok) { throw new Error(`Server error: ${response.status}`); } From e7a05ab7edd176aa110cf758e5fc54ba48b5cc60 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 16 May 2025 19:21:05 +0200 Subject: [PATCH 46/55] Make pull request changes --- src/qlever/commands/example_queries.py | 517 +++++++++++++++---------- 1 file changed, 303 insertions(+), 214 deletions(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 02041e8a..1bdf4e8e 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -11,8 +11,8 @@ from pathlib import Path from typing import Any +import rdflib import yaml -from rdflib import Graph from termcolor import colored from qlever.command import QleverCommand @@ -21,8 +21,6 @@ from qlever.log import log, mute_log from qlever.util import run_command, run_curl_command -MAX_RESULT_SIZE = 20 - class ExampleQueriesCommand(QleverCommand): """ @@ -59,19 +57,24 @@ def additional_arguments(self, subparser) -> None: help="SPARQL endpoint from fixed list (to save typing)", ) subparser.add_argument( - "--get-queries-cmd", + "--queries-tsv", type=str, - help="Command to get example queries as TSV (description, query)", + default=None, + help=( + "Path to a TSV file containing queries " + "(query_description, full_sparql_query)" + ), ) subparser.add_argument( - "--queries-file", + "--queries-yml", type=str, + default=None, help=( "Path to a YAML file containing queries. " "The YAML file should have a top-level " "key called 'queries', which is a list of dictionaries. " - "Each dictionary should contain 'query' for the query name " - "and 'sparql' for the SPARQL query." + "Each dictionary should contain 'query' for the query " + "description and 'sparql' for the full SPARQL query." ), ) subparser.add_argument( @@ -167,8 +170,8 @@ def additional_arguments(self, subparser) -> None: type=str, default=".", help=( - "The directory where the yaml result file would be saved " - "for evaluation web app (Default = current working directory)" + "The directory where the YML result file would be saved " + "for the evaluation web app (Default = current working directory)" ), ) subparser.add_argument( @@ -176,9 +179,18 @@ def additional_arguments(self, subparser) -> None: type=str, default=None, help=( - "Name that would be used for result yaml file. " + "Name that would be used for result YML file. " "Make sure it is of the form . " - "for e.g.: wikidata.jena or dblp.qlever+" + "for e.g.: wikidata.jena or dblp.qlever" + ), + ) + subparser.add_argument( + "--max-results-output-file", + type=int, + default=5, + help=( + "Maximum number of results per query in the output result " + "YML file (Default = 5)" ), ) @@ -212,29 +224,73 @@ def sparql_query_type(self, query: str) -> str: return "UNKNOWN" @staticmethod - def parse_queries_file(queries_file: str) -> dict[str, list[str, str]]: + def construct_get_queries_cmd( + queries_file: str, query_ids: str, query_regex: str, ui_config: str + ) -> str: + """ + Construct get_queries_cmd from queries_tsv file if present or use + example queries by using ui_config. Use query_ids and query_regex to + filter the queries + """ + get_queries_cmd = ( + f"cat {queries_file}" + if queries_file + else f"curl -sv https://qlever.cs.uni-freiburg.de/" + f"api/examples/{ui_config}" + ) + sed_arg = query_ids.replace(",", "p;").replace("-", ",") + "p" + get_queries_cmd += f" | sed -n '{sed_arg}'" + if query_regex: + get_queries_cmd += f" | grep -Pi {shlex.quote(query_regex)}" + return get_queries_cmd + + @staticmethod + def parse_queries_tsv( + queries_file: str, query_ids: str, query_regex: str, ui_config: str + ) -> list[str]: + """ + Parse the queries_tsv file (or example queries cmd if file absent) + and return a list of tab-separated queries (query_description, full_sparql_query) """ - Parse a YAML file and validate its structure. + get_queries_cmd = ExampleQueriesCommand.construct_get_queries_cmd( + queries_file, query_ids, query_regex, ui_config + ) + log.debug(get_queries_cmd) + try: + tsv_queries_str = run_command(get_queries_cmd, return_output=True) + if len(tsv_queries_str) == 0: + log.error("No queries found in the TSV queries file") + return [] + return tsv_queries_str.splitlines() + except Exception as exc: + log.error(f"Failed to read the TSV queries file: {exc}") + return [] + + @staticmethod + def parse_queries_yml( + queries_file: str, query_ids: str, query_regex: str + ) -> list[str]: + """ + Parse a YML file, validate its structure and return a list of + tab-separated queries (query_description, full_sparql_query) """ with open(queries_file, "r", encoding="utf-8") as q_file: try: data = yaml.safe_load(q_file) # Load YAML safely except yaml.YAMLError as exc: log.error(f"Error parsing {queries_file} file: {exc}") + return [] - error_msg = ( - "Error: YAML file must contain a top-level 'queries' key." - "Error: 'queries' must be a list." - "Error: Each item in 'queries' must contain 'query' and 'sparql' keys." - ) # Validate the structure if not isinstance(data, dict) or "queries" not in data: - log.error(error_msg) - return {} + log.error( + "Error: YAML file must contain a top-level 'queries' key" + ) + return [] if not isinstance(data["queries"], list): - log.error(error_msg) - return {} + log.error("Error: 'queries' key in YML file must hold a list.") + return [] for item in data["queries"]: if ( @@ -242,45 +298,155 @@ def parse_queries_file(queries_file: str) -> dict[str, list[str, str]]: or "query" not in item or "sparql" not in item ): - log.error(error_msg) - return {} + log.error( + "Error: Each item in 'queries' must contain " + "'query' and 'sparql' keys." + ) + return [] - return data + # Get the list of query indices to keep + total_queries = len(data["queries"]) + query_indices = [] + for part in query_ids.split(","): + if "-" in part: + start, end = part.split("-") + if end == "$": + end = total_queries + query_indices.extend(range(int(start) - 1, int(end))) + else: + idx = int(part) if part != "$" else total_queries + query_indices.append(idx - 1) - def get_example_queries( + try: + tsv_queries = [] + for query_idx in query_indices: + if query_idx >= total_queries: + log.error( + "Make sure --query-ids don't exceed the total " + "queries in the YML file" + ) + return [] + query = data["queries"][query_idx] + + # Only include queries that match the query_regex if present + if query_regex: + pattern = re.compile(query_regex, re.IGNORECASE) + if not any( + [ + pattern.search(query["query"]), + pattern.search(query["sparql"]), + ] + ): + continue + + tsv_queries.append(f"{query['query']}\t{query['sparql']}") + return tsv_queries + except Exception as exc: + log.error(f"Error parsing {queries_file} file: {exc}") + return [] + + def get_result_size( self, - queries_file: str | None = None, - get_queries_cmd: str | None = None, - ) -> list[str]: + count_only: bool, + query_type: str, + accept_header: str, + result_file: str, + ) -> tuple[int, int | None, dict[str, str] | None]: """ - Get example queries from get_queries_cmd or by reading the yaml file + Get the result size, single_int_result value (if single result) and + error_msg dict (if query failed) for different accept headers """ - # yaml file case -> convert to tsv (description \t query) - if queries_file is not None: - queries_data = self.parse_queries_file(queries_file) - queries = queries_data.get("queries") - if queries is None: - return [] - example_query_lines = [ - f"{query['query']}\t{query['sparql']}" for query in queries - ] - return example_query_lines - - # get_queries_cmd case -> run the command - if get_queries_cmd is not None: - # Get the example queries. - try: - example_query_lines = run_command( - get_queries_cmd, return_output=True + + def get_json_error_msg(e: Exception) -> dict[str, str]: + error_msg = { + "short": "Malformed JSON", + "long": "curl returned with code 200, " + "but the JSON is malformed: " + re.sub(r"\s+", " ", str(e)), + } + return error_msg + + result_size = 0 + single_int_result = error_msg = None + # CASE 0: The result is empty despite a 200 HTTP code (not a + # problem for CONSTRUCT and DESCRIBE queries). + if Path(result_file).stat().st_size == 0 and ( + not query_type == "CONSTRUCT" and not query_type == "DESCRIBE" + ): + result_size = 0 + error_msg = { + "short": "Empty result", + "long": "curl returned with code 200, but the result is empty", + } + + # CASE 1: Just counting the size of the result (TSV or JSON). + elif count_only: + if accept_header == "text/tab-separated-values": + result_size = run_command( + f"sed 1d {result_file}", return_output=True ) - if len(example_query_lines) == 0: - return [] - example_query_lines = example_query_lines.splitlines() - return example_query_lines - except Exception as e: - log.error(f"Failed to get example queries: {e}") - return [] - return [] + elif accept_header == "application/qlever-results+json": + try: + # sed cmd to get the number between 2nd and 3rd double_quotes + result_size = run_command( + f"jq '.res[0]' {result_file}" + " | sed 's/[^0-9]*\\([0-9]*\\).*/\\1/'", + return_output=True, + ) + except Exception as e: + error_msg = get_json_error_msg(e) + else: + try: + result_size = run_command( + f'jq -r ".results.bindings[0]' + f" | to_entries[0].value.value" + f' | tonumber" {result_file}', + return_output=True, + ) + except Exception as e: + error_msg = get_json_error_msg(e) + + # CASE 2: Downloading the full result (TSV, CSV, Turtle, JSON). + else: + if ( + accept_header == "text/tab-separated-values" + or accept_header == "text/csv" + ): + result_size = run_command( + f"sed 1d {result_file} | wc -l", return_output=True + ) + elif accept_header == "text/turtle": + result_size = run_command( + f"sed '1d;/^@prefix/d;/^\\s*$/d' {result_file} | wc -l", + return_output=True, + ) + elif accept_header == "application/qlever-results+json": + result_size = run_command( + f'jq -r ".resultsize" {result_file}', + return_output=True, + ) + else: + try: + result_size = int( + run_command( + f'jq -r ".results.bindings | length"' + f" {result_file}", + return_output=True, + ).rstrip() + ) + except Exception as e: + error_msg = get_json_error_msg(e) + if result_size == 1: + try: + single_int_result = int( + run_command( + f'jq -e -r ".results.bindings[0][] | .value"' + f" {result_file}", + return_output=True, + ).rstrip() + ) + except Exception: + pass + return int(result_size), single_int_result, error_msg def execute(self, args) -> bool: # We can't have both `--remove-offset-and-limit` and `--limit`. @@ -288,13 +454,14 @@ def execute(self, args) -> bool: log.error("Cannot have both --remove-offset-and-limit and --limit") return False + # Extract dataset and sparql_engine name from result file dataset, engine = None, None if args.result_file is not None: result_file_parts = args.result_file.split(".") if len(result_file_parts) != 2: log.error( "Make sure --result-file is of the form . " - "for e.g.: wikidata.jena or dblp.qlever+" + "for e.g.: wikidata.jena or dblp.qlever" ) return False results_dir_path = Path(args.results_dir) @@ -330,7 +497,7 @@ def execute(self, args) -> bool: log.error(f"Please install `jq` for {args.accept} ({e})") return False - # Handle shotcuts for SPARQL endpoint. + # Handle shortcuts for SPARQL endpoint. if args.sparql_endpoint_preset: args.sparql_endpoint = args.sparql_endpoint_preset @@ -361,22 +528,20 @@ def execute(self, args) -> bool: # Show what the command will do. get_queries_cmd = ( - args.get_queries_cmd - if args.get_queries_cmd - else f"curl -sv https://qlever.cs.uni-freiburg.de/" - f"api/examples/{args.ui_config}" + None + if any((args.queries_yml, args.queries_tsv)) + else self.construct_get_queries_cmd( + None, args.query_ids, args.query_regex, args.ui_config + ) ) - sed_arg = args.query_ids.replace(",", "p;").replace("-", ",") + "p" - get_queries_cmd += f" | sed -n '{sed_arg}'" - if args.query_regex: - get_queries_cmd += f" | grep -Pi {shlex.quote(args.query_regex)}" sparql_endpoint = ( args.sparql_endpoint if args.sparql_endpoint else f"{args.host_name}:{args.port}" ) + self.show( - f"Obtain queries via: {get_queries_cmd}\n" + f"Obtain queries via: {args.queries_yml or args.queries_tsv or get_queries_cmd}\n" f"SPARQL endpoint: {sparql_endpoint}\n" f"Accept header: {args.accept}\n" f"Download result for each query or just count:" @@ -387,15 +552,22 @@ def execute(self, args) -> bool: if args.show: return True - # Get the example queries either from queries_file or get_queries_cmd - example_query_lines = ( - self.get_example_queries(get_queries_cmd=get_queries_cmd) - if args.queries_file is None - else self.get_example_queries(queries_file=args.queries_file) + tsv_queries = ( + self.parse_queries_yml( + args.queries_yml, args.query_ids, args.query_regex + ) + if args.queries_yml + else self.parse_queries_tsv( + args.queries_tsv, + args.query_ids, + args.query_regex, + args.ui_config, + ) ) + log.debug(tsv_queries) - if len(example_query_lines) == 0: - log.error("No example queries matching the criteria found") + if len(tsv_queries) == 0 or not tsv_queries[0]: + log.error("No queries to process!") return False # We want the width of the query description to be an uneven number (in @@ -409,15 +581,15 @@ def execute(self, args) -> bool: # processing time (seconds). query_times = [] result_sizes = [] - yaml_records = {"queries": []} + result_yml_query_records = {"queries": []} num_failed = 0 - for example_query_line in example_query_lines: + for query_line in tsv_queries: # Parse description and query, and determine query type. - description, query = example_query_line.split("\t") + description, query = query_line.split("\t") if len(query) == 0: log.error("Could not parse description and query, line is:") log.info("") - log.info(example_query_line) + log.info(query_line) return False query_type = self.sparql_query_type(query) if args.add_query_type_to_description or args.accept == "AUTO": @@ -541,103 +713,42 @@ def execute(self, args) -> bool: "long": re.sub(r"\s+", " ", str(e)), } - def get_json_error_msg(e: Exception) -> dict[str, str]: - error_msg = { - "short": "Malformed JSON", - "long": "curl returned with code 200, " - "but the JSON is malformed: " - + re.sub(r"\s+", " ", str(e)), - } - return error_msg - # Get result size (via the command line, in order to avoid loading # a potentially large JSON file into Python, which is slow). if error_msg is None: - single_int_result = None - # CASE 0: The result is empty despite a 200 HTTP code (not a - # problem for CONSTRUCT and DESCRIBE queries). - if Path(result_file).stat().st_size == 0 and ( - not query_type == "CONSTRUCT" - and not query_type == "DESCRIBE" - ): - result_size = 0 - error_msg = { - "short": "Empty result", - "long": "curl returned with code 200, " - "but the result is empty", - } - - # CASE 1: Just counting the size of the result (TSV or JSON). - elif args.download_or_count == "count": - if accept_header == "text/tab-separated-values": - result_size = run_command( - f"sed 1d {result_file}", return_output=True - ) - elif accept_header == "application/qlever-results+json": - try: - # sed cmd to get the number between 2nd and 3rd double_quotes - result_size = run_command( - f"jq '.res[0]' {result_file}" - " | sed 's/[^0-9]*\\([0-9]*\\).*/\\1/'", - return_output=True, - ) - except Exception as e: - error_msg = get_json_error_msg(e) - else: - try: - result_size = run_command( - f'jq -r ".results.bindings[0]' - f" | to_entries[0].value.value" - f' | tonumber" {result_file}', - return_output=True, - ) - except Exception as e: - error_msg = get_json_error_msg(e) + result_size, single_int_result, error_msg = ( + self.get_result_size( + args.download_or_count == "count", + query_type, + accept_header, + result_file, + ) + ) - # CASE 2: Downloading the full result (TSV, CSV, Turtle, JSON). - else: - if ( - accept_header == "text/tab-separated-values" - or accept_header == "text/csv" - ): - result_size = run_command( - f"sed 1d {result_file} | wc -l", return_output=True - ) - elif accept_header == "text/turtle": - result_size = run_command( - f"sed '1d;/^@prefix/d;/^\\s*$/d' " - f"{result_file} | wc -l", - return_output=True, - ) - elif accept_header == "application/qlever-results+json": - result_size = run_command( - f'jq -r ".resultsize" {result_file}', - return_output=True, - ) - else: - try: - result_size = int( - run_command( - f'jq -r ".results.bindings | length"' - f" {result_file}", - return_output=True, - ).rstrip() - ) - except Exception as e: - error_msg = get_json_error_msg(e) - if result_size == 1: - try: - single_int_result = int( - run_command( - f'jq -e -r ".results.bindings[0][] | .value"' - f" {result_file}", - return_output=True, - ).rstrip() - ) - except Exception: - pass - - error_msg_for_yaml = {} + # Get the result yaml record if output file needs to be generated + if args.result_file is not None: + result_length = None if error_msg is not None else 1 + result_length = ( + result_size + if args.download_or_count == "download" + and result_length is not None + else result_length + ) + query_results = ( + error_msg if error_msg is not None else result_file + ) + query_record = self.get_result_yml_query_record( + query=description, + sparql=self.pretty_printed_query( + query, args.show_prefixes + ), + client_time=time_seconds, + result=query_results, + result_size=result_length, + max_result_size=args.max_results_output_file, + accept_header=accept_header, + ) + result_yml_query_records["queries"].append(query_record) # Print description, time, result in tabular form. if len(description) > width_query_description: @@ -662,8 +773,6 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: query_times.append(time_seconds) result_sizes.append(result_size) else: - for key in error_msg.keys(): - error_msg_for_yaml[key] = error_msg[key] num_failed += 1 if ( args.width_error_message > 0 @@ -696,47 +805,26 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: ) log.info("") - # Get the yaml record if output file needs to be generated - if args.result_file is not None: - result_length = None if error_msg is not None else 1 - result_length = ( - result_size - if args.download_or_count == "download" - and result_length is not None - else result_length - ) - results_for_yaml = ( - error_msg_for_yaml - if error_msg is not None - else result_file - ) - yaml_record = self.get_record_for_yaml( - query=description, - sparql=self.pretty_printed_query( - query, args.show_prefixes - ), - client_time=time_seconds, - result=results_for_yaml, - result_size=result_length, - accept_header=accept_header, - ) - yaml_records["queries"].append(yaml_record) - # Remove the result file (unless in debug mode). if args.log_level != "DEBUG": Path(result_file).unlink(missing_ok=True) # Check that each query has a time and a result size, or it failed. assert len(result_sizes) == len(query_times) - assert len(query_times) + num_failed == len(example_query_lines) - - if len(yaml_records["queries"]) != 0: - outfile_name = f"{dataset}.{engine}.results.yaml" - outfile = Path(args.results_dir) / outfile_name - self.write_query_data_to_yaml( - query_data=yaml_records, - out_file=outfile, - ) + assert len(query_times) + num_failed == len(tsv_queries) + + if args.result_file: + if len(result_yml_query_records["queries"]) != 0: + outfile_name = f"{dataset}.{engine}.results.yaml" + outfile = Path(args.results_dir) / outfile_name + self.write_query_records_to_result_file( + query_data=result_yml_query_records, + out_file=outfile, + ) + else: + log.error( + f"Nothing to write to output result YML file: {args.result_file}" + ) # Show statistics. if len(query_times) > 0: @@ -773,7 +861,7 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: log.info("") description = "Number of FAILED queries" num_failed_string = f"{num_failed:>6}" - if num_failed == len(example_query_lines): + if num_failed == len(tsv_queries): num_failed_string += " [all]" log.info( colored( @@ -786,13 +874,14 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: # Return success (has nothing to do with how many queries failed). return True - def get_record_for_yaml( + def get_result_yml_query_record( self, query: str, sparql: str, client_time: float, result: str | dict[str, str], result_size: int | None, + max_result_size: int, accept_header: str, ) -> dict[str, Any]: """ @@ -809,8 +898,8 @@ def get_record_for_yaml( else: record["result_size"] = result_size result_size = ( - MAX_RESULT_SIZE - if result_size > MAX_RESULT_SIZE + max_result_size + if result_size > max_result_size else result_size ) headers, results = self.get_query_results( @@ -882,7 +971,7 @@ def get_query_results( results.append(result) return results_json["headers"], results else: # text/turtle - graph = Graph() + graph = rdflib.Graph() graph.parse(result_file, format="turtle") headers = ["?subject", "?predicate", "?object"] results = [] @@ -893,7 +982,7 @@ def get_query_results( return headers, results @staticmethod - def write_query_data_to_yaml( + def write_query_records_to_result_file( query_data: dict[str, list[dict[str, Any]]], out_file: Path ) -> None: """ From e0cd4ba8bb8a84899c7047e0c072defbacce0013 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 23 May 2025 13:04:01 +0200 Subject: [PATCH 47/55] Some code formatting fixes --- src/qlever/commands/example_queries.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 1bdf4e8e..ecd16955 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -24,15 +24,18 @@ class ExampleQueriesCommand(QleverCommand): """ - Class for running a given sequence of example queries and showing - their processing times and result sizes. + Class for running a given sequence of benchmark or example queries and + showing their processing times and result sizes. """ def __init__(self): pass def description(self) -> str: - return "Run the given queries and show their processing times and result sizes" + return ( + "Run the given queries and show their processing times " + "and result sizes" + ) def should_have_qleverfile(self) -> bool: return False @@ -250,7 +253,8 @@ def parse_queries_tsv( ) -> list[str]: """ Parse the queries_tsv file (or example queries cmd if file absent) - and return a list of tab-separated queries (query_description, full_sparql_query) + and return a list of tab-separated queries + (query_description, full_sparql_query) """ get_queries_cmd = ExampleQueriesCommand.construct_get_queries_cmd( queries_file, query_ids, query_regex, ui_config @@ -564,7 +568,6 @@ def execute(self, args) -> bool: args.ui_config, ) ) - log.debug(tsv_queries) if len(tsv_queries) == 0 or not tsv_queries[0]: log.error("No queries to process!") @@ -885,7 +888,7 @@ def get_result_yml_query_record( accept_header: str, ) -> dict[str, Any]: """ - Construct a dictionary with query information for yaml file + Construct a dictionary with query information for output result yaml file """ record = { "query": query, @@ -925,7 +928,7 @@ def get_query_results( self, result_file: str, result_size: int, accept_header: str ) -> tuple[list[str], list[list[str]]]: """ - Return headers and results as a tuple + Return headers and query results as a tuple for various accept headers """ if accept_header in ("text/tab-separated-values", "text/csv"): separator = "," if accept_header == "text/csv" else "\t" @@ -936,6 +939,7 @@ def get_query_results( headers = next(reader) results = [row for row in reader] return headers, results + elif accept_header == "application/qlever-results+json": get_result_cmd = ( f"jq '{{headers: .selected, results: .res[0:{result_size}]}}' " @@ -944,6 +948,7 @@ def get_query_results( results_str = run_command(get_result_cmd, return_output=True) results_json = json.loads(results_str) return results_json["headers"], results_json["results"] + elif accept_header == "application/sparql-results+json": get_result_cmd = ( f"jq '{{headers: .head.vars, " @@ -970,6 +975,7 @@ def get_query_results( result.append(value) results.append(result) return results_json["headers"], results + else: # text/turtle graph = rdflib.Graph() graph.parse(result_file, format="turtle") From 3a17f05d0f8cb0ea79ddcba8e94813f6539062c0 Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Fri, 23 May 2025 22:45:21 +0200 Subject: [PATCH 48/55] Minor revision of help text --- src/qlever/commands/example_queries.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index ecd16955..5dadaa0c 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -182,9 +182,8 @@ def additional_arguments(self, subparser) -> None: type=str, default=None, help=( - "Name that would be used for result YML file. " - "Make sure it is of the form . " - "for e.g.: wikidata.jena or dblp.qlever" + "Base name used for the result YML file, should be of the " + "form `.`, e.g., `wikidata.qlever`" ), ) subparser.add_argument( @@ -464,15 +463,15 @@ def execute(self, args) -> bool: result_file_parts = args.result_file.split(".") if len(result_file_parts) != 2: log.error( - "Make sure --result-file is of the form . " - "for e.g.: wikidata.jena or dblp.qlever" + "The argument of --result-file should be of the form " + "`.`, e.g., `wikidata.qlever`" ) return False results_dir_path = Path(args.results_dir) if results_dir_path.exists(): if not results_dir_path.is_dir(): log.error( - f"{results_dir_path} exists but is not a directory." + f"{results_dir_path} exists but is not a directory" ) return False else: From 49cc9b74b6faffe65449d7c2b6584f559cd8191e Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 6 Jun 2025 16:09:58 +0200 Subject: [PATCH 49/55] Change `example-queries` to `benchmark-queries` and some changes for the pr --- ...xample_queries.py => benchmark_queries.py} | 198 +++++++++--------- 1 file changed, 100 insertions(+), 98 deletions(-) rename src/qlever/commands/{example_queries.py => benchmark_queries.py} (91%) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/benchmark_queries.py similarity index 91% rename from src/qlever/commands/example_queries.py rename to src/qlever/commands/benchmark_queries.py index 5dadaa0c..a180dbf5 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/benchmark_queries.py @@ -22,7 +22,7 @@ from qlever.util import run_command, run_curl_command -class ExampleQueriesCommand(QleverCommand): +class BenchmarkQueriesCommand(QleverCommand): """ Class for running a given sequence of benchmark or example queries and showing their processing times and result sizes. @@ -33,8 +33,8 @@ def __init__(self): def description(self) -> str: return ( - "Run the given queries and show their processing times " - "and result sizes" + "Run the given benchmark or example queries and show their " + "processing times and result sizes" ) def should_have_qleverfile(self) -> bool: @@ -64,7 +64,7 @@ def additional_arguments(self, subparser) -> None: type=str, default=None, help=( - "Path to a TSV file containing queries " + "Path to a TSV file containing benchmark queries " "(query_description, full_sparql_query)" ), ) @@ -73,7 +73,7 @@ def additional_arguments(self, subparser) -> None: type=str, default=None, help=( - "Path to a YAML file containing queries. " + "Path to a YAML file containing benchmark queries. " "The YAML file should have a top-level " "key called 'queries', which is a list of dictionaries. " "Each dictionary should contain 'query' for the query " @@ -93,6 +93,15 @@ def additional_arguments(self, subparser) -> None: help="Only consider example queries matching " "this regex (using grep -Pi)", ) + subparser.add_argument( + "--example-queries", + action="store_true", + default=False, + help=( + "Run the example-queries for the given --ui-config " + "instead of the benchmark queries from a tsv/yml file" + ), + ) subparser.add_argument( "--download-or-count", choices=["download", "count"], @@ -226,41 +235,50 @@ def sparql_query_type(self, query: str) -> str: return "UNKNOWN" @staticmethod - def construct_get_queries_cmd( - queries_file: str, query_ids: str, query_regex: str, ui_config: str - ) -> str: + def filter_tsv_queries( + tsv_queries: list[str], query_ids: str, query_regex: str + ) -> list[str]: """ Construct get_queries_cmd from queries_tsv file if present or use example queries by using ui_config. Use query_ids and query_regex to filter the queries """ - get_queries_cmd = ( - f"cat {queries_file}" - if queries_file - else f"curl -sv https://qlever.cs.uni-freiburg.de/" - f"api/examples/{ui_config}" - ) - sed_arg = query_ids.replace(",", "p;").replace("-", ",") + "p" - get_queries_cmd += f" | sed -n '{sed_arg}'" - if query_regex: - get_queries_cmd += f" | grep -Pi {shlex.quote(query_regex)}" - return get_queries_cmd + # Get the list of query indices to keep + total_queries = len(tsv_queries) + query_indices = [] + for part in query_ids.split(","): + if "-" in part: + start, end = part.split("-") + if end == "$": + end = total_queries + query_indices.extend(range(int(start) - 1, int(end))) + else: + idx = int(part) if part != "$" else total_queries + query_indices.append(idx - 1) + + try: + filtered_queries = [] + for query_idx in query_indices: + if query_idx >= total_queries: + continue + query = tsv_queries[query_idx] + + # Only include queries that match the query_regex if present + if query_regex: + pattern = re.compile(query_regex, re.IGNORECASE) + if not pattern.search(query): + continue + + filtered_queries.append(query) + return filtered_queries + except Exception as exc: + log.error(f"Error filtering queries: {exc}") + return [] @staticmethod - def parse_queries_tsv( - queries_file: str, query_ids: str, query_regex: str, ui_config: str - ) -> list[str]: - """ - Parse the queries_tsv file (or example queries cmd if file absent) - and return a list of tab-separated queries - (query_description, full_sparql_query) - """ - get_queries_cmd = ExampleQueriesCommand.construct_get_queries_cmd( - queries_file, query_ids, query_regex, ui_config - ) - log.debug(get_queries_cmd) + def fetch_tsv_queries_from_cmd(queries_cmd: str) -> list[str]: try: - tsv_queries_str = run_command(get_queries_cmd, return_output=True) + tsv_queries_str = run_command(queries_cmd, return_output=True) if len(tsv_queries_str) == 0: log.error("No queries found in the TSV queries file") return [] @@ -268,11 +286,19 @@ def parse_queries_tsv( except Exception as exc: log.error(f"Failed to read the TSV queries file: {exc}") return [] + + @staticmethod + def parse_queries_tsv(queries_file: str) -> list[str]: + """ + Parse the queries_tsv file + and return a list of tab-separated queries + (query_description, full_sparql_query) + """ + get_queries_cmd = f"cat {queries_file}" + return BenchmarkQueriesCommand.fetch_tsv_queries_from_cmd(get_queries_cmd) @staticmethod - def parse_queries_yml( - queries_file: str, query_ids: str, query_regex: str - ) -> list[str]: + def parse_queries_yml(queries_file: str) -> list[str]: """ Parse a YML file, validate its structure and return a list of tab-separated queries (query_description, full_sparql_query) @@ -307,46 +333,9 @@ def parse_queries_yml( ) return [] - # Get the list of query indices to keep - total_queries = len(data["queries"]) - query_indices = [] - for part in query_ids.split(","): - if "-" in part: - start, end = part.split("-") - if end == "$": - end = total_queries - query_indices.extend(range(int(start) - 1, int(end))) - else: - idx = int(part) if part != "$" else total_queries - query_indices.append(idx - 1) - - try: - tsv_queries = [] - for query_idx in query_indices: - if query_idx >= total_queries: - log.error( - "Make sure --query-ids don't exceed the total " - "queries in the YML file" - ) - return [] - query = data["queries"][query_idx] - - # Only include queries that match the query_regex if present - if query_regex: - pattern = re.compile(query_regex, re.IGNORECASE) - if not any( - [ - pattern.search(query["query"]), - pattern.search(query["sparql"]), - ] - ): - continue - - tsv_queries.append(f"{query['query']}\t{query['sparql']}") - return tsv_queries - except Exception as exc: - log.error(f"Error parsing {queries_file} file: {exc}") - return [] + return [ + f"{query['query']}\t{query['sparql']}" for query in data["queries"] + ] def get_result_size( self, @@ -383,7 +372,7 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: # CASE 1: Just counting the size of the result (TSV or JSON). elif count_only: - if accept_header == "text/tab-separated-values": + if accept_header in ("text/tab-separated-values", "text/csv"): result_size = run_command( f"sed 1d {result_file}", return_output=True ) @@ -410,10 +399,7 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: # CASE 2: Downloading the full result (TSV, CSV, Turtle, JSON). else: - if ( - accept_header == "text/tab-separated-values" - or accept_header == "text/csv" - ): + if accept_header in ("text/tab-separated-values", "text/csv"): result_size = run_command( f"sed 1d {result_file} | wc -l", return_output=True ) @@ -500,6 +486,27 @@ def execute(self, args) -> bool: log.error(f"Please install `jq` for {args.accept} ({e})") return False + if not any((args.queries_tsv, args.queries_yml, args.example_queries)): + log.error( + "No benchmark or example queries to read! Either pass benchmark " + "queries using --queries-tsv or --queries-yml, or pass the " + "argument --example-queries to run example queries for the " + f"given ui_config {args.ui_config}" + ) + return False + + if all((args.queries_tsv, args.queries_yml)): + log.error("Cannot have both --queries-tsv and --queries-yml") + return False + + if any((args.queries_tsv, args.queries_yml)) and args.example_queries: + queries_file_arg = "tsv" if args.queries_tsv else "yml" + log.error( + f"Cannot have both --queries-{queries_file_arg} and " + "--example-queries" + ) + return False + # Handle shortcuts for SPARQL endpoint. if args.sparql_endpoint_preset: args.sparql_endpoint = args.sparql_endpoint_preset @@ -530,12 +537,9 @@ def execute(self, args) -> bool: args.clear_cache = "no" # Show what the command will do. - get_queries_cmd = ( - None - if any((args.queries_yml, args.queries_tsv)) - else self.construct_get_queries_cmd( - None, args.query_ids, args.query_regex, args.ui_config - ) + example_queries_cmd = ( + "curl -sv https://qlever.cs.uni-freiburg.de/" + f"api/examples/{args.ui_config}" ) sparql_endpoint = ( args.sparql_endpoint @@ -544,7 +548,7 @@ def execute(self, args) -> bool: ) self.show( - f"Obtain queries via: {args.queries_yml or args.queries_tsv or get_queries_cmd}\n" + f"Obtain queries via: {args.queries_yml or args.queries_tsv or example_queries_cmd}\n" f"SPARQL endpoint: {sparql_endpoint}\n" f"Accept header: {args.accept}\n" f"Download result for each query or just count:" @@ -555,17 +559,15 @@ def execute(self, args) -> bool: if args.show: return True - tsv_queries = ( - self.parse_queries_yml( - args.queries_yml, args.query_ids, args.query_regex - ) - if args.queries_yml - else self.parse_queries_tsv( - args.queries_tsv, - args.query_ids, - args.query_regex, - args.ui_config, - ) + if args.queries_yml: + tsv_queries_list = self.parse_queries_yml(args.queries_yml) + elif args.queries_tsv: + tsv_queries_list = self.parse_queries_tsv(args.queries_tsv) + else: + tsv_queries_list = self.fetch_tsv_queries_from_cmd(example_queries_cmd) + + tsv_queries = self.filter_tsv_queries( + tsv_queries_list, args.query_ids, args.query_regex ) if len(tsv_queries) == 0 or not tsv_queries[0]: From 8aa666d549f497ae0ce46ee7d4545b3352af3f69 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 13 Jun 2025 03:11:41 +0200 Subject: [PATCH 50/55] Separate get_single_int_result method and added docstrings --- src/qlever/commands/benchmark_queries.py | 84 +++++++++++++++--------- 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/src/qlever/commands/benchmark_queries.py b/src/qlever/commands/benchmark_queries.py index a180dbf5..3198e896 100644 --- a/src/qlever/commands/benchmark_queries.py +++ b/src/qlever/commands/benchmark_queries.py @@ -239,9 +239,8 @@ def filter_tsv_queries( tsv_queries: list[str], query_ids: str, query_regex: str ) -> list[str]: """ - Construct get_queries_cmd from queries_tsv file if present or use - example queries by using ui_config. Use query_ids and query_regex to - filter the queries + Given a tab-separated list of queries, filter them and keep the + ones which are a part of query_ids or match with query_regex """ # Get the list of query indices to keep total_queries = len(tsv_queries) @@ -277,6 +276,10 @@ def filter_tsv_queries( @staticmethod def fetch_tsv_queries_from_cmd(queries_cmd: str) -> list[str]: + """ + Execute the given bash command to fetch tsv queries and return a + list of tab-separated queries (query_description, full_sparql_query) + """ try: tsv_queries_str = run_command(queries_cmd, return_output=True) if len(tsv_queries_str) == 0: @@ -286,16 +289,17 @@ def fetch_tsv_queries_from_cmd(queries_cmd: str) -> list[str]: except Exception as exc: log.error(f"Failed to read the TSV queries file: {exc}") return [] - + @staticmethod def parse_queries_tsv(queries_file: str) -> list[str]: """ - Parse the queries_tsv file - and return a list of tab-separated queries + Parse the queries_tsv file and return a list of tab-separated queries (query_description, full_sparql_query) """ get_queries_cmd = f"cat {queries_file}" - return BenchmarkQueriesCommand.fetch_tsv_queries_from_cmd(get_queries_cmd) + return BenchmarkQueriesCommand.fetch_tsv_queries_from_cmd( + get_queries_cmd + ) @staticmethod def parse_queries_yml(queries_file: str) -> list[str]: @@ -343,10 +347,10 @@ def get_result_size( query_type: str, accept_header: str, result_file: str, - ) -> tuple[int, int | None, dict[str, str] | None]: + ) -> tuple[int, dict[str, str] | None]: """ - Get the result size, single_int_result value (if single result) and - error_msg dict (if query failed) for different accept headers + Get the result size and error_msg dict (if query failed) for + different accept headers """ def get_json_error_msg(e: Exception) -> dict[str, str]: @@ -358,7 +362,7 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: return error_msg result_size = 0 - single_int_result = error_msg = None + error_msg = None # CASE 0: The result is empty despite a 200 HTTP code (not a # problem for CONSTRUCT and DESCRIBE queries). if Path(result_file).stat().st_size == 0 and ( @@ -397,7 +401,7 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: except Exception as e: error_msg = get_json_error_msg(e) - # CASE 2: Downloading the full result (TSV, CSV, Turtle, JSON). + # CASE 2: Downloading the full result (TSV, CSV, Turtle, JSON). else: if accept_header in ("text/tab-separated-values", "text/csv"): result_size = run_command( @@ -424,18 +428,27 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: ) except Exception as e: error_msg = get_json_error_msg(e) - if result_size == 1: - try: - single_int_result = int( - run_command( - f'jq -e -r ".results.bindings[0][] | .value"' - f" {result_file}", - return_output=True, - ).rstrip() - ) - except Exception: - pass - return int(result_size), single_int_result, error_msg + return int(result_size), error_msg + + @staticmethod + def get_single_int_result(result_file: str) -> int | None: + """ + When downloading the full result of a query with accept header as + application/sparql-results+json and result_size == 1, get the single + integer result value (if any) + """ + single_int_result = None + try: + single_int_result = int( + run_command( + f'jq -e -r ".results.bindings[0][] | .value"' + f" {result_file}", + return_output=True, + ).rstrip() + ) + except Exception: + pass + return single_int_result def execute(self, args) -> bool: # We can't have both `--remove-offset-and-limit` and `--limit`. @@ -564,7 +577,9 @@ def execute(self, args) -> bool: elif args.queries_tsv: tsv_queries_list = self.parse_queries_tsv(args.queries_tsv) else: - tsv_queries_list = self.fetch_tsv_queries_from_cmd(example_queries_cmd) + tsv_queries_list = self.fetch_tsv_queries_from_cmd( + example_queries_cmd + ) tsv_queries = self.filter_tsv_queries( tsv_queries_list, args.query_ids, args.query_regex @@ -720,14 +735,19 @@ def execute(self, args) -> bool: # Get result size (via the command line, in order to avoid loading # a potentially large JSON file into Python, which is slow). if error_msg is None: - result_size, single_int_result, error_msg = ( - self.get_result_size( - args.download_or_count == "count", - query_type, - accept_header, - result_file, - ) + result_size, error_msg = self.get_result_size( + args.download_or_count == "count", + query_type, + accept_header, + result_file, ) + single_int_result = None + if ( + result_size == 1 + and accept_header == "application/sparql-results+json" + and args.download_or_count == "download" + ): + single_int_result = self.get_single_int_result(result_file) # Get the result yaml record if output file needs to be generated if args.result_file is not None: From 766049001a452ba9448ed96bc4c809bd1b9e9c33 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 13 Jun 2025 16:39:26 +0200 Subject: [PATCH 51/55] Add tests for get_result_size and get_single_int_result --- src/qlever/commands/benchmark_queries.py | 11 +- .../test_benchmark_queries_methods.py | 188 ++++++++++++++++++ test/qlever/conftest.py | 16 ++ 3 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 test/qlever/commands/test_benchmark_queries_methods.py create mode 100644 test/qlever/conftest.py diff --git a/src/qlever/commands/benchmark_queries.py b/src/qlever/commands/benchmark_queries.py index 3198e896..54998099 100644 --- a/src/qlever/commands/benchmark_queries.py +++ b/src/qlever/commands/benchmark_queries.py @@ -413,10 +413,13 @@ def get_json_error_msg(e: Exception) -> dict[str, str]: return_output=True, ) elif accept_header == "application/qlever-results+json": - result_size = run_command( - f'jq -r ".resultsize" {result_file}', - return_output=True, - ) + try: + result_size = run_command( + f'jq -r ".resultsize" {result_file}', + return_output=True, + ) + except Exception as e: + error_msg = get_json_error_msg(e) else: try: result_size = int( diff --git a/test/qlever/commands/test_benchmark_queries_methods.py b/test/qlever/commands/test_benchmark_queries_methods.py new file mode 100644 index 00000000..39f0e3da --- /dev/null +++ b/test/qlever/commands/test_benchmark_queries_methods.py @@ -0,0 +1,188 @@ +import pytest + +from qlever.commands.benchmark_queries import BenchmarkQueriesCommand + +MODULE = "qlever.commands.benchmark_queries" + +JSON_ACCEPT_HEADERS_AND_RESULT_FILES = [ + ("application/sparql-results+json", "result.json"), + ("application/qlever-results+json", "result.json"), +] + +ALL_ACCEPT_HEADERS_AND_RESULT_FILES = [ + ("text/csv", "result.csv"), + ("text/tab-separated-values", "result.tsv"), + *JSON_ACCEPT_HEADERS_AND_RESULT_FILES, +] + + +@pytest.mark.parametrize("download_or_count", ["count", "download"]) +@pytest.mark.parametrize( + "accept_header, result_file", ALL_ACCEPT_HEADERS_AND_RESULT_FILES +) +def test_empty_result_non_construct_describe( + mock_command, + download_or_count, + accept_header, + result_file, +): + mock_path_stat = mock_command(MODULE, "Path.stat") + mock_path_stat.return_value.st_size = 0 + run_cmd_mock = mock_command(MODULE, "run_command") + + size, err = BenchmarkQueriesCommand().get_result_size( + count_only=download_or_count == "count", + query_type="SELECT", + accept_header=accept_header, + result_file=result_file, + ) + + assert size == 0 + assert err["short"] == "Empty result" + assert ( + err["long"] == "curl returned with code 200, but the result is empty" + ) + run_cmd_mock.assert_not_called() + + +@pytest.mark.parametrize("download_or_count", ["count", "download"]) +@pytest.mark.parametrize( + "accept_header, result_file", ALL_ACCEPT_HEADERS_AND_RESULT_FILES +) +@pytest.mark.parametrize("query_type", ["CONSTRUCT", "DESCRIBE"]) +def test_empty_result_construct_describe( + mock_command, + download_or_count, + query_type, + accept_header, + result_file, +): + mock_path_stat = mock_command(MODULE, "Path.stat") + mock_path_stat.return_value.st_size = 0 + run_cmd_mock = mock_command(MODULE, "run_command") + run_cmd_mock.return_value = "42" + + size, err = BenchmarkQueriesCommand().get_result_size( + count_only=download_or_count == "count", + query_type=query_type, + accept_header=accept_header, + result_file=result_file, + ) + + assert size == 42 + assert err is None + + +@pytest.mark.parametrize("download_or_count", ["count", "download"]) +@pytest.mark.parametrize( + "accept_header, result_file", ALL_ACCEPT_HEADERS_AND_RESULT_FILES +) +def test_count_and_download_success( + mock_command, + download_or_count, + accept_header, + result_file, +): + mock_path_stat = mock_command(MODULE, "Path.stat") + mock_path_stat.return_value.st_size = 100 + + run_cmd_mock = mock_command(MODULE, "run_command") + run_cmd_mock.return_value = "42" + + size, err = BenchmarkQueriesCommand().get_result_size( + count_only=download_or_count == "count", + query_type="SELECT", + accept_header=accept_header, + result_file=result_file, + ) + + run_cmd_mock.assert_called_once() + assert size == 42 + assert err is None + + +def test_download_turtle_success(mock_command): + mock_path_stat = mock_command(MODULE, "Path.stat") + mock_path_stat.return_value.st_size = 100 + run_cmd_mock = mock_command(MODULE, "run_command") + run_cmd_mock.return_value = "42" + + size, err = BenchmarkQueriesCommand().get_result_size( + count_only=False, + query_type="SELECT", + accept_header="text/turtle", + result_file="result.ttl", + ) + + run_cmd_mock.assert_called_once() + assert size == 42 + assert err is None + + +@pytest.mark.parametrize("download_or_count", ["count", "download"]) +@pytest.mark.parametrize( + "accept_header, result_file", JSON_ACCEPT_HEADERS_AND_RESULT_FILES +) +def test_download_and_count_json_malformed( + mock_command, + download_or_count, + accept_header, + result_file, +): + mock_path_stat = mock_command(MODULE, "Path.stat") + mock_path_stat.return_value.st_size = 100 + + run_cmd_mock = mock_command(MODULE, "run_command") + run_cmd_mock.side_effect = Exception("jq failed") + + size, err = BenchmarkQueriesCommand().get_result_size( + count_only=download_or_count == "count", + query_type="SELECT", + accept_header=accept_header, + result_file=result_file, + ) + + run_cmd_mock.assert_called_once() + assert size == 0 + assert err["short"] == "Malformed JSON" + assert ( + "curl returned with code 200, but the JSON is malformed: " + in err["long"] + ) + assert "jq failed" in err["long"] + + +def test_single_int_result_success(mock_command): + run_cmd_mock = mock_command(MODULE, "run_command") + run_cmd_mock.return_value = "123" + + single_int_result = BenchmarkQueriesCommand().get_single_int_result( + "result.json" + ) + + run_cmd_mock.assert_called_once() + assert single_int_result == 123 + + +def test_single_int_result_non_int_fail(mock_command): + run_cmd_mock = mock_command(MODULE, "run_command") + run_cmd_mock.return_value = "abc" + + single_int_result = BenchmarkQueriesCommand().get_single_int_result( + "result.json" + ) + + run_cmd_mock.assert_called_once() + assert single_int_result is None + + +def test_single_int_result_failure(mock_command): + run_cmd_mock = mock_command(MODULE, "run_command") + run_cmd_mock.side_effect = Exception("jq failed") + + single_int_result = BenchmarkQueriesCommand().get_single_int_result( + "result.json" + ) + + run_cmd_mock.assert_called_once() + assert single_int_result is None diff --git a/test/qlever/conftest.py b/test/qlever/conftest.py new file mode 100644 index 00000000..9f825cb1 --- /dev/null +++ b/test/qlever/conftest.py @@ -0,0 +1,16 @@ +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def mock_command(monkeypatch): + def _mock(module_name: str, function_name: str, override=None): + if override: + monkeypatch.setattr(f"{module_name}.{function_name}", override) + return override + mock = MagicMock(name=f"{function_name}_mock") + monkeypatch.setattr(f"{module_name}.{function_name}", mock) + return mock + + return _mock From e89b9b73941bc789abddc5bce62c6b87a3504b72 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Thu, 26 Jun 2025 15:04:36 +0200 Subject: [PATCH 52/55] Changed tsv query representation to tuple[str, str] and some pr changes --- src/qlever/commands/benchmark_queries.py | 85 +++++++++++------------- 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/src/qlever/commands/benchmark_queries.py b/src/qlever/commands/benchmark_queries.py index 54998099..12f8de53 100644 --- a/src/qlever/commands/benchmark_queries.py +++ b/src/qlever/commands/benchmark_queries.py @@ -98,8 +98,8 @@ def additional_arguments(self, subparser) -> None: action="store_true", default=False, help=( - "Run the example-queries for the given --ui-config " - "instead of the benchmark queries from a tsv/yml file" + "Run the example queries for the given --ui-config " + "instead of the benchmark queries from a TSV or YML file" ), ) subparser.add_argument( @@ -235,15 +235,16 @@ def sparql_query_type(self, query: str) -> str: return "UNKNOWN" @staticmethod - def filter_tsv_queries( - tsv_queries: list[str], query_ids: str, query_regex: str - ) -> list[str]: + def filter_queries( + queries: list[tuple[str, str]], query_ids: str, query_regex: str + ) -> list[tuple[str, str]]: """ - Given a tab-separated list of queries, filter them and keep the - ones which are a part of query_ids or match with query_regex + Given a list of queries (tuple of query desc and full sparql query), + filter them and keep the ones which are a part of query_ids + or match with query_regex """ # Get the list of query indices to keep - total_queries = len(tsv_queries) + total_queries = len(queries) query_indices = [] for part in query_ids.split(","): if "-" in part: @@ -257,55 +258,51 @@ def filter_tsv_queries( try: filtered_queries = [] + pattern = ( + re.compile(query_regex, re.IGNORECASE) if query_regex else None + ) for query_idx in query_indices: if query_idx >= total_queries: continue - query = tsv_queries[query_idx] + + query_desc, sparql = queries[query_idx] # Only include queries that match the query_regex if present - if query_regex: - pattern = re.compile(query_regex, re.IGNORECASE) - if not pattern.search(query): - continue + if pattern and not ( + pattern.search(query_desc) or pattern.search(sparql) + ): + continue - filtered_queries.append(query) + filtered_queries.append((query_desc, sparql)) return filtered_queries except Exception as exc: log.error(f"Error filtering queries: {exc}") return [] @staticmethod - def fetch_tsv_queries_from_cmd(queries_cmd: str) -> list[str]: + def parse_queries_tsv(queries_cmd: str) -> list[tuple[str, str]]: """ Execute the given bash command to fetch tsv queries and return a - list of tab-separated queries (query_description, full_sparql_query) + list of queries i.e. tuple(query_description, full_sparql_query) """ try: tsv_queries_str = run_command(queries_cmd, return_output=True) if len(tsv_queries_str) == 0: log.error("No queries found in the TSV queries file") return [] - return tsv_queries_str.splitlines() + return [ + tuple(line.split("\t")) + for line in tsv_queries_str.strip().splitlines() + ] except Exception as exc: log.error(f"Failed to read the TSV queries file: {exc}") return [] @staticmethod - def parse_queries_tsv(queries_file: str) -> list[str]: - """ - Parse the queries_tsv file and return a list of tab-separated queries - (query_description, full_sparql_query) - """ - get_queries_cmd = f"cat {queries_file}" - return BenchmarkQueriesCommand.fetch_tsv_queries_from_cmd( - get_queries_cmd - ) - - @staticmethod - def parse_queries_yml(queries_file: str) -> list[str]: + def parse_queries_yml(queries_file: str) -> list[tuple[str, str]]: """ Parse a YML file, validate its structure and return a list of - tab-separated queries (query_description, full_sparql_query) + queries i.e. tuple(query_description, full_sparql_query) """ with open(queries_file, "r", encoding="utf-8") as q_file: try: @@ -338,7 +335,7 @@ def parse_queries_yml(queries_file: str) -> list[str]: return [] return [ - f"{query['query']}\t{query['sparql']}" for query in data["queries"] + (query['query'], query['sparql']) for query in data["queries"] ] def get_result_size( @@ -438,7 +435,7 @@ def get_single_int_result(result_file: str) -> int | None: """ When downloading the full result of a query with accept header as application/sparql-results+json and result_size == 1, get the single - integer result value (if any) + integer result value (if any). """ single_int_result = None try: @@ -576,19 +573,17 @@ def execute(self, args) -> bool: return True if args.queries_yml: - tsv_queries_list = self.parse_queries_yml(args.queries_yml) + queries = self.parse_queries_yml(args.queries_yml) elif args.queries_tsv: - tsv_queries_list = self.parse_queries_tsv(args.queries_tsv) + queries = self.parse_queries_tsv(f"cat {args.queries_tsv}") else: - tsv_queries_list = self.fetch_tsv_queries_from_cmd( - example_queries_cmd - ) + queries = self.parse_queries_tsv(example_queries_cmd) - tsv_queries = self.filter_tsv_queries( - tsv_queries_list, args.query_ids, args.query_regex + filtered_queries = self.filter_queries( + queries, args.query_ids, args.query_regex ) - if len(tsv_queries) == 0 or not tsv_queries[0]: + if len(filtered_queries) == 0 or not filtered_queries[0]: log.error("No queries to process!") return False @@ -605,13 +600,11 @@ def execute(self, args) -> bool: result_sizes = [] result_yml_query_records = {"queries": []} num_failed = 0 - for query_line in tsv_queries: - # Parse description and query, and determine query type. - description, query = query_line.split("\t") + for description, query in filtered_queries: if len(query) == 0: log.error("Could not parse description and query, line is:") log.info("") - log.info(query_line) + log.info(f"{description}\t{query}") return False query_type = self.sparql_query_type(query) if args.add_query_type_to_description or args.accept == "AUTO": @@ -838,7 +831,7 @@ def execute(self, args) -> bool: # Check that each query has a time and a result size, or it failed. assert len(result_sizes) == len(query_times) - assert len(query_times) + num_failed == len(tsv_queries) + assert len(query_times) + num_failed == len(filtered_queries) if args.result_file: if len(result_yml_query_records["queries"]) != 0: @@ -888,7 +881,7 @@ def execute(self, args) -> bool: log.info("") description = "Number of FAILED queries" num_failed_string = f"{num_failed:>6}" - if num_failed == len(tsv_queries): + if num_failed == len(filtered_queries): num_failed_string += " [all]" log.info( colored( From 2f546484b08977131ee57af790213c756b1d50d1 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Thu, 26 Jun 2025 15:15:19 +0200 Subject: [PATCH 53/55] Deleted web-app from this branch for pr --- src/qlever/commands/serve_evaluation_app.py | 165 -- src/qlever/evaluation/www/card-template.html | 35 - .../evaluation/www/compare-exec-trees.js | 425 ---- .../evaluation/www/engines-comparison.js | 445 ---- src/qlever/evaluation/www/helper.js | 123 - src/qlever/evaluation/www/index.html | 260 -- src/qlever/evaluation/www/main.js | 256 -- src/qlever/evaluation/www/query-details.js | 473 ---- src/qlever/evaluation/www/treant.css | 66 - src/qlever/evaluation/www/treant.js | 2171 ----------------- 10 files changed, 4419 deletions(-) delete mode 100644 src/qlever/commands/serve_evaluation_app.py delete mode 100644 src/qlever/evaluation/www/card-template.html delete mode 100644 src/qlever/evaluation/www/compare-exec-trees.js delete mode 100644 src/qlever/evaluation/www/engines-comparison.js delete mode 100644 src/qlever/evaluation/www/helper.js delete mode 100644 src/qlever/evaluation/www/index.html delete mode 100644 src/qlever/evaluation/www/main.js delete mode 100644 src/qlever/evaluation/www/query-details.js delete mode 100644 src/qlever/evaluation/www/treant.css delete mode 100644 src/qlever/evaluation/www/treant.js diff --git a/src/qlever/commands/serve_evaluation_app.py b/src/qlever/commands/serve_evaluation_app.py deleted file mode 100644 index 76b83402..00000000 --- a/src/qlever/commands/serve_evaluation_app.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import annotations - -import json -import math -from functools import partial -from http.server import HTTPServer, SimpleHTTPRequestHandler -from pathlib import Path -from urllib.parse import unquote - -import yaml - -from qlever.command import QleverCommand -from qlever.log import log - -EVAL_DIR = Path(__file__).parent.parent / "evaluation" - - -def get_query_stats(queries: list[dict]) -> dict[str, float | None]: - query_data = { - "ameanTime": None, - "gmeanTime": None, - "medianTime": None, - "under1s": 0.0, - "between1to5s": 0.0, - "over5s": 0.0, - "failed": 0.0, - } - failed = under_1 = bw_1_to_5 = over_5 = 0 - total_time = total_log_time = 0.0 - runtimes = [] - for query in queries: - if len(query["headers"]) == 0 and isinstance(query["results"], str): - failed += 1 - else: - runtime = float(query["runtime_info"]["client_time"]) - total_time += runtime - total_log_time += max(math.log(runtime), 0.001) - runtimes.append(runtime) - if runtime <= 1: - under_1 += 1 - elif runtime > 5: - over_5 += 1 - else: - bw_1_to_5 += 1 - total_successful = len(runtimes) - if total_successful == 0: - query_data["failed"] = 100.0 - else: - query_data["ameanTime"] = total_time / total_successful - query_data["gmeanTime"] = math.exp(total_log_time / total_successful) - query_data["medianTime"] = sorted(runtimes)[total_successful // 2] - query_data["under1s"] = (under_1 / total_successful) * 100 - query_data["between1to5s"] = (bw_1_to_5 / total_successful) * 100 - query_data["over5s"] = (over_5 / total_successful) * 100 - query_data["failed"] = (failed / len(queries)) * 100 - return query_data - - -def create_performance_data(yaml_dir: Path) -> dict | None: - performance_data = {} - if not yaml_dir.is_dir(): - return None - for yaml_file in yaml_dir.glob("*.results.yaml"): - file_name_split = yaml_file.stem.split(".") - if len(file_name_split) != 3: - continue - dataset, engine, _ = file_name_split - if performance_data.get(dataset) is None: - performance_data[dataset] = {} - if performance_data[dataset].get(engine) is None: - performance_data[dataset][engine] = {} - with yaml_file.open("r", encoding="utf-8") as queries_file: - queries_data = yaml.safe_load(queries_file) - query_stats = get_query_stats(queries_data["queries"]) - performance_data[dataset][engine] = {**query_stats, **queries_data} - return performance_data - - -class CustomHTTPRequestHandler(SimpleHTTPRequestHandler): - def __init__(self, *args, yaml_dir: Path | None = None, **kwargs) -> None: - self.yaml_dir = yaml_dir - super().__init__(*args, **kwargs) - - def do_GET(self) -> None: - path = unquote(self.path) - - if path == "/yaml_data": - try: - data = create_performance_data(self.yaml_dir) - json_data = json.dumps(data, indent=2).encode("utf-8") - - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(json_data))) - self.end_headers() - self.wfile.write(json_data) - - except Exception as e: - self.send_response(500) - self.end_headers() - self.wfile.write(f"Error loading YAMLs: {e}".encode("utf-8")) - else: - super().do_GET() - - -class ServeEvaluationAppCommand(QleverCommand): - """ - Class for executing the `serve-evaluation-app` command. - """ - - def __init__(self): - pass - - def description(self) -> str: - return "Serve the SPARQL Engine performance comparison webapp" - - def should_have_qleverfile(self) -> bool: - return False - - def relevant_qleverfile_arguments(self) -> dict[str : list[str]]: - return {} - - def additional_arguments(self, subparser) -> None: - subparser.add_argument( - "--port", - type=int, - default=8000, - help=( - "Port where the Performance comparison webapp will be " - "served (Default = 8000)" - ), - ) - ( - subparser.add_argument( - "--host", - type=str, - default="localhost", - help=( - "Host where the Performance comparison webapp will be " - "served (Default = localhost)" - ), - ), - ) - subparser.add_argument( - "--results-dir", - type=str, - default=".", - help=( - "Path to the directory where yaml result files from " - "example-queries are saved (Default = current working dir)" - ), - ) - - def execute(self, args) -> bool: - yaml_dir = Path(args.results_dir) - handler = partial( - CustomHTTPRequestHandler, directory=EVAL_DIR, yaml_dir=yaml_dir - ) - httpd = HTTPServer(("", args.port), handler) - log.info( - f"Performance Comparison Web App is available at " - f"http://{args.host}:{args.port}/www" - ) - httpd.serve_forever() - return True diff --git a/src/qlever/evaluation/www/card-template.html b/src/qlever/evaluation/www/card-template.html deleted file mode 100644 index 911c8bc3..00000000 --- a/src/qlever/evaluation/www/card-template.html +++ /dev/null @@ -1,35 +0,0 @@ - diff --git a/src/qlever/evaluation/www/compare-exec-trees.js b/src/qlever/evaluation/www/compare-exec-trees.js deleted file mode 100644 index bb0d64ff..00000000 --- a/src/qlever/evaluation/www/compare-exec-trees.js +++ /dev/null @@ -1,425 +0,0 @@ -// Zoom settings -const baseTreeTextFontSize = 80; -const minimumZoomPercent = 30; -const maximumZoomPercent = 80; -const zoomChange = 10; - -/** - * Sets up event listeners for the execution tree comparison modal. - * - Handles clicks on the "Compare Execution Trees" button to display the comparison modal. - * - Ensures smooth reopening of the query comparison modal, scrolling to the last selected query. - * - Listens for actions to display and zoom in/out of the execution trees. - */ -function setListenersForCompareExecModal() { - // When the modal is hidden - document.querySelector("#compareExecTreeModal").addEventListener("hidden.bs.modal", function () { - // Don't execute any url or state based code when back/forward button clicked - if (document.querySelector("#compareExecTreeModal").getAttribute("pop-triggered")) { - document.querySelector("#compareExecTreeModal").removeAttribute("pop-triggered"); - return; - } - // Display query Comparison modal again and scroll automatically to the last selected query - const kb = document.querySelector("#compareExecTreeModal").getAttribute("data-kb"); - document.querySelector("#comparisonModal").setAttribute("data-kb", kb); - showModal(document.querySelector("#comparisonModal")); - }); - - // Event to create and draw 2 execution trees side-by-side for comparison - document.querySelector("#compareExecTreeModal").addEventListener("shown.bs.modal", function () { - handleCompareExecTrees("modalShow"); - }); - - // Event to handle zoom in/out of execution trees - document.querySelector("#compExecTreeTabContent").addEventListener("click", function (event) { - if (event.target.tagName === "BUTTON") { - const buttonId = event.target.id; - const purpose = buttonId.slice(0, -1); - const buttonClicked = document.querySelector("#" + buttonId); - const tree = buttonClicked.parentNode.parentNode.nextElementSibling; - const treeId = "#" + tree.id; - const currentFontSize = tree.querySelector(".node[class*=font-size-]").className.match(/font-size-(\d+)/)[1]; - // Zoom in and out for both trees when sync option enabled - if (document.getElementById("syncScrollCheck").checked) { - for (let treeId of ["#tree1", "#tree2"]) { - handleCompareExecTrees(purpose, treeId, Number.parseInt(currentFontSize)); - } - } else { - handleCompareExecTrees(purpose, treeId, Number.parseInt(currentFontSize)); - } - } - }); - - // Events to handle drag and scroll horizontally on compareExecTrees page - for (const treeDiv of ["#result-tree", "#tree1", "#tree2"]) { - var isDragging = false; - var initialX = 0; - var initialY = 0; - var currentTreeDiv = null; - - document.querySelector(treeDiv).addEventListener("mousedown", (e) => { - currentTreeDiv = treeDiv; - document.querySelector(currentTreeDiv).style.cursor = "grabbing"; - isDragging = true; - initialX = e.clientX; - initialY = e.clientY; - e.preventDefault(); - }); - document.querySelector(treeDiv).addEventListener("mousemove", (e) => { - if (isDragging) { - const deltaX = e.clientX - initialX; - const deltaY = e.clientY - initialY; - document.querySelector(currentTreeDiv).scrollLeft -= deltaX; - document.querySelector(currentTreeDiv).scrollTop -= deltaY; - if (document.getElementById("syncScrollCheck").checked && treeDiv !== "#result-tree") { - syncScroll(currentTreeDiv); - } - initialX = e.clientX; - initialY = e.clientY; - } - }); - // Sync scrolling and zooming if the option is selected - document.querySelector(treeDiv).addEventListener("scroll", () => { - if (document.getElementById("syncScrollCheck").checked && treeDiv !== "#result-tree") { - syncScroll(treeDiv); - } - }); - document.addEventListener("mouseup", () => { - isDragging = false; - if (document.querySelector(currentTreeDiv)) document.querySelector(currentTreeDiv).style.cursor = "grab"; - currentTreeDiv = null; - }); - } -} - -/** - * Synchronize the scrolling between the 2 compare exec trees if enabled - * @param {string} sourceTree - ID of the tree where the scroll is performed. Can be #tree1 or #tree2 - */ -function syncScroll(sourceTree) { - const sourceDiv = document.querySelector(sourceTree); - - for (const targetTree of ["#tree1", "#tree2"]) { - if (targetTree !== sourceTree) { - const targetDiv = document.querySelector(targetTree); - - // Match scroll position - targetDiv.scrollLeft = sourceDiv.scrollLeft; - targetDiv.scrollTop = sourceDiv.scrollTop; - } - } -} - -/** - * Updates the url with kb and pushes the page to history stack - * Calls the function to display the execution trees for comparison, with options to zoom in, zoom out, or reset zoom. - * @param {string} purpose - Purpose of the display (e.g., "modalShow", "zoomIn", "zoomOut"). - * @param {string} idOfTreeToZoom - ID of the tree element to zoom in/out. - * @param {number} currentFontSize - Current font size of the tree nodes. - */ -async function handleCompareExecTrees(purpose, idOfTreeToZoom, currentFontSize) { - const modalNode = document.querySelector("#compareExecTreeModal"); - const select1 = modalNode.getAttribute("data-s1"); - const select2 = modalNode.getAttribute("data-s2"); - const kb = modalNode.getAttribute("data-kb"); - const queryIndex = Number.parseInt(modalNode.getAttribute("data-qid")); - // Only when modal is shown and not when zoom buttons clicked - if (purpose === "modalShow") { - // If back/forward button, do nothing - if (modalNode.getAttribute("pop-triggered")) { - modalNode.removeAttribute("pop-triggered"); - } - // Else Update the url params and push the page to history stack - else { - const url = new URL(window.location); - url.searchParams.set("page", "compareExecTrees"); - url.searchParams.set("kb", kb); - url.searchParams.set("s1", select1); - url.searchParams.set("s2", select2); - url.searchParams.set("qid", queryIndex); - - const state = { page: "compareExecTrees", kb: kb, s1: select1, s2: select2, qid: queryIndex }; - // If this page is directly opened from url, replace the null state in history stack - if (window.history.state === null) { - window.history.replaceState(state, "", url); - } else { - window.history.pushState(state, "", url); - } - } - } - showCompareExecTrees(purpose, select1, select2, kb, queryIndex, idOfTreeToZoom, currentFontSize); -} - -/** - * Display the execution trees for comparison, with options to zoom in, zoom out, or reset zoom. - * @param {string} select1 - qlever engine version selected in first dropdown - * @param {string} select2 - qlever engine version selected in second dropdown - * @param {string} kb - selected Knowledge Base - * @param {number} queryIndex - array index of the selected query - * @param {string} purpose - Purpose of the display (e.g., "modalShow", "zoomIn", "zoomOut"). - * @param {string} idOfTreeToZoom - ID of the tree element to zoom in/out. - * @param {number} currentFontSize - Current font size of the tree nodes. - */ -function showCompareExecTrees(purpose, select1, select2, kb, queryIndex, idOfTreeToZoom, currentFontSize) { - const qlevers = [select1, select2]; - divIds = ["#engineTree1", "#engineTree2"]; - if (purpose === "modalShow") { - for (let i = 0; i < 2; i++) { - document.querySelector(divIds[i]).innerHTML = qlevers[i]; - } - } - const queries = performanceDataPerKb[kb][qlevers[0].toLowerCase()]["queries"]; - document.querySelector("#runtimeQuery").textContent = "Query: " + queries[queryIndex]["query"]; - document.querySelector("#runtimeQuery").title = queries[queryIndex]["sparql"].replace(/"/g, '\\"'); - qlevers.forEach((engine, index) => { - let runtime = - performanceDataPerKb[kb][engine.toLowerCase()]["queries"][queryIndex].runtime_info.query_execution_tree; - let treeid = "#tree" + (index + 1).toString(); - if (purpose === "modalShow" || idOfTreeToZoom === treeid) { - document.querySelector(treeid).replaceChildren(); - let tree = createExecTree(runtime, treeid); - drawExecTree(tree, treeid, purpose, currentFontSize); - } - }); -} - -/** - * Calculates the depth of a tree structure, where depth is the longest path from the root to any leaf node. - * @param {Object} obj - The tree node or root object. - * @returns {number} - The depth of the tree. - */ -function calculateTreeDepth(obj) { - // Base case: if the object has no children, return 1 - if (!obj.children || obj.children.length === 0) { - return 1; - } - // Initialize maxDepth to track the maximum depth - let maxDepth = 0; - // Calculate depth for each child and find the maximum depth - obj.children.forEach((child) => { - const depth = calculateTreeDepth(child); - maxDepth = Math.max(maxDepth, depth); - }); - // Return maximum depth + 1 (to account for the current node) - return maxDepth + 1; -} - -/** - * Determines the font size for a tree visualization based on its depth, ensuring text is appropriately sized. - * @param {number} fontSize - The base font size. - * @param {number} depth - The depth of the tree. - * @returns {number} - The adjusted font size. - */ -function getFontSizeForDepth(fontSize, depth) { - // If depth is greater than 4, reduce font size by 10 for each increment beyond 4 - if (depth > 4) { - fontSize -= (depth - 4) * zoomChange; - } - // Ensure font size doesn't go below 30 - fontSize = Math.max(fontSize, minimumZoomPercent); - return fontSize; -} - -/** - * Calculates the new font size for tree nodes based on the purpose (zoom in/out or modal show) and current size. - * @param {Object} tree - The tree structure object. - * @param {string} purpose - The purpose of font size adjustment (e.g., "modalShow", "zoomIn", "zoomOut"). - * @param {number} currentFontSize - The current font size of the tree nodes. - * @returns {number} - The new font size. - */ -function getNewFontSizeForTree(tree, purpose, currentFontSize) { - let treeDepth; - let newFontSize = currentFontSize ? currentFontSize : maximumZoomPercent; - if (purpose === "modalShow") { - treeDepth = calculateTreeDepth(tree.nodeStructure); - newFontSize = getFontSizeForDepth(baseTreeTextFontSize, treeDepth); - } else if (purpose === "zoomIn" && currentFontSize < maximumZoomPercent) { - newFontSize += zoomChange; - } else if (purpose === "zoomOut" && currentFontSize > minimumZoomPercent) { - newFontSize -= zoomChange; - } - return newFontSize; -} - -/** - * Generates an execution tree structure for visualization based on the runtime information. - * @param {Object} runtime - The runtime information containing the tree structure. - * @param {string} treeid - The ID of the HTML element where the tree will be rendered. - * @returns {Object} The tree structure ready for rendering with Treant.js. - */ -function createExecTree(runtime, treeid) { - try { - runtimeInfoForTreant(runtime); - let tree = treeid; - let treant_compare_tree = { - chart: { - container: tree, - rootOrientation: "NORTH", - connectors: { type: "step" }, - node: { HTMLclass: "font-size-" + maximumZoomPercent }, - }, - nodeStructure: runtime, - }; - return treant_compare_tree; - } catch (error) { - console.error("CreateExecTree error: ", error); - return {}; - } -} - -/** - * Draws the execution tree in the specified HTML container, applying zoom settings and highlighting nodes based on performance. - * - * @param {Object} treant_tree - The tree structure generated by Treant.js. - * @param {string} treeid - The ID of the HTML container where the tree is displayed. - * @param {string} purpose - The reason for drawing the tree ('modalShow', 'zoomIn', 'zoomOut'). - * @param {number} [currentFontSize] - The current font size of the tree nodes (optional). - */ -function drawExecTree(treant_tree, treeid, purpose, currentFontSize) { - if (treant_tree && Object.keys(treant_tree).length !== 0) { - const newFontSize = getNewFontSizeForTree(treant_tree, purpose, currentFontSize); - treant_tree.chart.node.HTMLclass = "font-size-" + newFontSize.toString(); - new Treant(treant_tree); - // Highlight node with high query times: cached -> yellow or light yellow, not - // cached -> red or light red. Also grey out cached nodes. - $("p.node-time") - .filter(function () { - return $(this).html() >= high_query_time_ms; - }) - .parent() - .addClass("high"); - $("p.node-time") - .filter(function () { - return $(this).html() >= very_high_query_time_ms; - }) - .parent() - .addClass("veryhigh"); - $("p.node-cached") - .filter(function () { - return $(this).html() == "true"; - }) - .parent() - .addClass("cached"); - document.querySelector(treeid).lastChild.scrollIntoView({ block: "nearest", inline: "center" }); - } -} - -/** - * Transforms runtime information into a format compatible with Treant.js for creating hierarchical execution trees. - * Propagates cached status through the tree nodes. - * - * @param {Object} runtime_info - The runtime information containing the query execution tree details. - * @param {boolean} [parent_cached=false] - Whether the parent node was cached (used to propagate caching status). - */ -function runtimeInfoForTreant(runtime_info, parent_cached = false) { - // Create text child with the information we want to see in the tree. - if (runtime_info["text"] == undefined) { - var text = {}; - if (runtime_info["column_names"] == undefined) { - runtime_info["column_names"] = ["not yet available"]; - } - text["name"] = runtime_info["description"] - .replace(/<.*[#\/\.](.*)>/, "<$1>") - .replace(/qlc_/g, "") - .replace(/\?[A-Z_]*/g, function (match) { - return match.toLowerCase(); - }) - .replace(/([a-z])([A-Z])/g, "$1-$2") - .replace(/^([a-zA-Z-])*/, function (match) { - return match.toUpperCase(); - }) - .replace(/([A-Z])-([A-Z])/g, "$1 $2") - .replace(/AVAILABLE /, "") - .replace(/a all/, "all"); - text["size"] = format(runtime_info["result_rows"]) + " x " + format(runtime_info["result_cols"]); - text["cols"] = runtime_info["column_names"] - .join(", ") - .replace(/qlc_/g, "") - .replace(/\?[A-Z_]*/g, function (match) { - return match.toLowerCase(); - }); - text["time"] = runtime_info["was_cached"] - ? runtime_info["details"]["original_operation_time"] - : runtime_info["operation_time"]; - text["total"] = text["time"]; - text["cached"] = parent_cached == true ? true : runtime_info["was_cached"]; - if (typeof text["cached"] != "boolean") { - text["cached"] = false; - } - // Save the original was_cached flag, before it's deleted, for use below. - for (var key in runtime_info) { - if (key != "children") { - delete runtime_info[key]; - } - } - runtime_info["text"] = text; - runtime_info["stackChildren"] = true; - - // Recurse over all children, propagating the was_cached flag from the - // original runtime_info to all nodes in the subtree. - runtime_info["children"].map((child) => runtimeInfoForTreant(child, text["cached"])); - // If result is cached, subtract time from children, to get the original - // operation time (instead of the original time for the whole subtree). - if (text["cached"]) { - runtime_info["children"].forEach(function (child) { - // text["time"] -= child["text"]["total"]; - }); - } - } -} - -/** - * Generates and displays the execution tree for a given query or retrieves it based on the selected engine and knowledge base. - * Handles rendering the tree in the modal's content. - * - * @param {Object} queryRow - The selected query's runtime information. - * @param {string} [purpose] - The reason for generating the tree ('modalShow', 'zoomIn', 'zoomOut'). - * @param {string} [treeid] - The ID of the tree container (optional). - * @param {number} [currentFontSize] - The current font size of the tree nodes (optional). - */ -function generateExecutionTree(queryRow, purpose, treeid, currentFontSize) { - if (queryRow === null && purpose !== undefined) { - const kb = document - .querySelector("#queryDetailsModal") - .querySelector("#runtimes-tab-pane") - .querySelector(".card-title") - .textContent.substring("Knowledge Graph - ".length) - .toLowerCase(); - const engine = document - .querySelector("#queryDetailsModal") - .querySelector(".modal-title") - .textContent.substring("SPARQL Engine - ".length) - .toLowerCase(); - const queryIndex = document.querySelector("#queryList").querySelector(".table-active").rowIndex - 1; - let runtime = performanceDataPerKb[kb][engine]["queries"][queryIndex].runtime_info.query_execution_tree; - document.querySelector(treeid).replaceChildren(); - let tree = createExecTree(runtime, treeid); - drawExecTree(tree, treeid, purpose, currentFontSize); - return; - } - if (!queryRow.runtime_info || !Object.hasOwn(queryRow.runtime_info, "query_execution_tree")) { - document.getElementById("tab3Content").replaceChildren(); - document.getElementById("result-tree").replaceChildren(); - if (queryRow.results) { - document - .querySelector("#tab3Content") - .replaceChildren(document.createTextNode("Execution tree not available for this engine!")); - } else { - document - .querySelector("#tab3Content") - .replaceChildren(document.createTextNode("No SPARQL results available for this query!")); - } - return; - } - document.getElementById("tab3Content").replaceChildren(); - document.getElementById("result-tree").replaceChildren(); - const exec_tree_tab = document.querySelector("#exec-tree-tab"); - exec_tree_tab.addEventListener( - "shown.bs.tab", - function () { - const runtime = queryRow.runtime_info.query_execution_tree; - const treant_tree = createExecTree(runtime, "#result-tree"); - drawExecTree(treant_tree, "#result-tree", "modalShow"); - }, - { once: true } - ); -} diff --git a/src/qlever/evaluation/www/engines-comparison.js b/src/qlever/evaluation/www/engines-comparison.js deleted file mode 100644 index 1fcd1b92..00000000 --- a/src/qlever/evaluation/www/engines-comparison.js +++ /dev/null @@ -1,445 +0,0 @@ -/** - * Sets event listeners for the Engines Comparison Modal - * - * - Listens for click events to hide selected queries, reset them or open CompareExecTrees Modal - * - Selects the previously selected row again and scrolls to it when user comes back - */ -function setListenersForEnginesComparison() { - // Event to display compareExecTree modal with Exec tree comparison for selected engines - document.querySelector("#compareExecTrees").addEventListener("click", (event) => { - event.preventDefault(); - compareExecutionTreesClicked(); - }); - - const comparisonModal = document.querySelector("#comparisonModal"); - // Before the modal is shown, update the url and history Stack and remove the previous table - comparisonModal.addEventListener("show.bs.modal", async function () { - const kb = comparisonModal.getAttribute("data-kb"); - if (kb) { - // If back/forward button, do nothing - if (comparisonModal.getAttribute("pop-triggered")) { - comparisonModal.removeAttribute("pop-triggered"); - } - // Else Update the url params and push the page to history stack - else { - const url = new URL(window.location); - url.search = ""; - console.log(url.searchParams); - url.searchParams.set("page", "comparison"); - url.searchParams.set("kb", kb); - const state = { page: "comparison", kb: kb }; - // If this page is directly opened from url, replace the null state in history stack - if (window.history.state === null) { - window.history.replaceState(state, "", url); - } else { - window.history.pushState(state, "", url); - } - } - - const tableContainer = document.getElementById("table-container"); - tableContainer.replaceChildren(); - document.querySelector("#comparisonKb").innerHTML = ""; - } - }); - - // After the modal is shown, populate the modal based on the selected kb - comparisonModal.addEventListener("shown.bs.modal", async function () { - const kb = comparisonModal.getAttribute("data-kb"); - if (kb) { - openComparisonModal(kb); - } - const popoverTriggerList = [].slice.call(comparisonModal.querySelectorAll('[data-bs-toggle="popover"]')); - popoverTriggerList.map(function (el) { - return new bootstrap.Popover(el); - }); - - const resultSizeCheckbox = document.querySelector("#showResultSize"); - - if (!resultSizeCheckbox.hasEventListener) { - resultSizeCheckbox.addEventListener("change", function () { - const tdElements = comparisonModal.querySelectorAll("td"); - if (resultSizeCheckbox.checked) { - tdElements.forEach((td) => { - const resultSizeDiv = td.querySelector("div.text-muted.small"); - resultSizeDiv?.classList.remove("d-none"); - }); - } else { - tdElements.forEach((td) => { - const resultSizeDiv = td.querySelector("div.text-muted.small"); - resultSizeDiv?.classList.add("d-none"); - }); - } - }); - resultSizeCheckbox.hasEventListener = true; - } - // Scroll to the previously selected row if user is coming back to this modal - const activeRow = comparisonModal.querySelector(".table-active"); - if (activeRow) { - activeRow.scrollIntoView({ - behavior: "auto", - block: "center", - inline: "center", - }); - } - }); - - // Handle the modal's `hidden.bs.modal` event - comparisonModal.addEventListener("hidden.bs.modal", function () { - // Don't execute any url or state based code when back/forward button clicked - if (comparisonModal.getAttribute("pop-triggered")) { - comparisonModal.removeAttribute("pop-triggered"); - return; - } - // Case: Modal was hidden as a result of clicking on compare execution trees button - if (comparisonModal.getAttribute("compare-exec-clicked")) { - const modalNode = document.querySelector("#compareExecTreeModal"); - - // Set kb, selected sparql engines and query attributes and show compareExecTreeModal - const kb = comparisonModal.getAttribute("data-kb"); - const select1 = document.querySelector("#select1").value; - const select2 = document.querySelector("#select2").value; - const queryIndex = document.querySelector("#comparisonModal .table-active").rowIndex - 1; - - modalNode.setAttribute("data-kb", kb); - modalNode.setAttribute("data-s1", select1); - modalNode.setAttribute("data-s2", select2); - modalNode.setAttribute("data-qid", queryIndex); - - comparisonModal.removeAttribute("compare-exec-clicked"); - showModal(document.querySelector("#compareExecTreeModal")); - } - // Case: Modal was closed as result of clicking on the close button - else { - // Navigate to the main page and update the url - const url = new URL(window.location); - url.searchParams.delete("page"); - url.searchParams.delete("kb"); - window.history.pushState({ page: "main" }, "", url); - } - }); -} - -/** - * Displays the execution tree comparison modal for the selected query. - * Alerts the user if no query is selected. - */ -function compareExecutionTreesClicked() { - const activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); - if (!activeRow) { - alert("Please select a query from the table!"); - return; - } - const kb = document.querySelector("#comparisonModal").getAttribute("data-kb"); - const select1 = document.querySelector("#select1").value; - const select2 = document.querySelector("#select2").value; - const queryIndex = document.querySelector("#comparisonModal .table-active").rowIndex - 1; - if ( - !performanceDataPerKb[kb][select1.toLowerCase()]["queries"][queryIndex] || - !performanceDataPerKb[kb][select2.toLowerCase()]["queries"][queryIndex] - ) { - alert("Execution tree not available for this query for these engines!"); - return; - } - document.querySelector("#comparisonModal").setAttribute("compare-exec-clicked", true); - const compareResultsmodal = bootstrap.Modal.getInstance(document.querySelector("#comparisonModal")); - compareResultsmodal.hide(); -} - -/** - * Handle event when compare results button is clicked on the main page - * @param kb name of the knowledge base - * Display the modal page where all the engine runtimes are compared against each other on a per query basis - */ -function handleCompareResultsClick(kb) { - const enginesToDisplay = getEnginesToDisplay(kb); - if (enginesToDisplay.length === 0) { - alert("All engines are unselected from comparison! Choose at least one or ideally more for comparison!"); - return; - } - document.querySelector("#comparisonModal").setAttribute("data-kb", kb); - showModal(document.querySelector("#comparisonModal")); -} - -function getEnginesToDisplay(kb) { - for (const cardBody of document.querySelectorAll(".card-body")) { - const selectedKb = cardBody.querySelector("h5").innerHTML.toLowerCase(); - if (kb === selectedKb) { - let enginesToShow = []; - for (const row of cardBody.querySelectorAll("tbody tr")) { - if (row.children[0].firstElementChild.checked) { - enginesToShow.push(row.children[1].innerHTML.toLowerCase()); - } - } - return enginesToShow; - } - } -} - -/** - * - Updates the url with kb and pushes the page to history stack - * - Fetches the query log and results based on the selected knowledge base (KB) and engine. - * - Updates the modal content and displays the query details. - * - Manages the state of the query execution tree and tab content. - * - * @param {string} kb - The selected knowledge base - */ -function openComparisonModal(kb) { - const enginesToDisplay = getEnginesToDisplay(kb); - console.log(enginesToDisplay); - - showSpinner(); - document.querySelector("#comparisonKb").innerHTML = kb; - const tableContainer = document.getElementById("table-container"); - // Populate the dropdowns with qlever engines for execution tree comparison - let select1 = document.querySelector("#select1"); - let select2 = document.querySelector("#select2"); - select1.innerHTML = ""; - select2.innerHTML = ""; - for (let engine of enginesToDisplay) { - //await addRuntimeToPerformanceDataPerKb(kb, engine); - if (performanceDataPerKb[kb].hasOwnProperty(engine) && execTreeEngines.includes(engine)) { - select1.add(new Option(engine)); - select2.add(new Option(engine)); - } - } - // If only 1 or less qlever engine, hide compare execution trees button - if (select1.options.length <= 1) { - document.querySelector("#compareExecDiv").classList.add("d-none"); - } else { - document.querySelector("#compareExecDiv").classList.remove("d-none"); - // By default show the first and second options when 2 or more options available - select1.selectedIndex = 0; - select2.selectedIndex = 1; - } - - // Create a DocumentFragment to build the table - const fragment = document.createDocumentFragment(); - const table = createCompareResultsTable(kb, enginesToDisplay); - table.addEventListener("contextmenu", (e) => { - e.preventDefault(); - const td = e.target.closest("td"); - if (td && td.title) { - const textToCopy = td.title.trim(); - navigator.clipboard - .writeText(textToCopy) - .then(() => { - const copyToast = bootstrap.Toast.getOrCreateInstance(document.getElementById("copyToast")); - copyToast.show(); - }) - .catch((err) => { - console.error("Copy failed:", err); - }); - } - }); - - fragment.appendChild(table); - - // Append the table to the container in a single operation - tableContainer.appendChild(fragment); - - $("#table-container table").tablesorter({ - theme: "bootstrap", - sortStable: true, - sortInitialOrder: "desc", - }); - hideSpinner(); -} - -function getBestRuntime(engines, engineStats) { - best_time = Infinity; - for (let engine of engines) { - const result = engineStats[engine]; - if (!result) continue; - let runtime = parseFloat(result.runtime_info.client_time); - let failed = result.headers.length === 0 || !Array.isArray(result.results); - if (!failed && runtime < best_time) { - best_time = runtime; - } - } - return isFinite(best_time) ? best_time : null; -} - -function getMajorityResultSize(engines, engineStats) { - const sizeCounts = new Map(); - - for (let engine of engines) { - const result = engineStats[engine]; - if (!result) continue; - if (result.failed) continue; - - const resultSize = result.singleResult ? result.singleResult : format(result.result_size); - sizeCounts.set(resultSize, (sizeCounts.get(resultSize) || 0) + 1); - } - - if (sizeCounts.size === 0) { - // All results failed or only had result_size = 0 - return null; - } - - let majorityResultSize = null; - let maxCount = 0; - let tie = false; - - for (const [size, count] of sizeCounts.entries()) { - if (count > maxCount) { - maxCount = count; - majorityResultSize = size; - tie = false; - } else if (count === maxCount) { - tie = true; - } - } - - return tie ? "no_consensus" : majorityResultSize; -} - -function resultSizeToDisplay(result) { - const resultSizeText = result.singleResult ? `1 [${result.singleResult}]` : result.result_size.toString(); - return resultSizeText; -} - -/** - * Uses performanceDataPerKb object to create the engine runtime for each query comparison table - * Gives the user the ability to selectively hide queries to reduce the clutter - * @param kb Name of the knowledge base for which to get engine runtimes - * @return HTML table with queries as rows and engine runtimes as columns - */ -function createCompareResultsTable(kb, engines) { - const table = document.createElement("table"); - table.classList.add("table", "table-hover", "table-bordered", "w-auto"); - - // Create the table header row - const thead = document.createElement("thead"); - const headerRow = document.createElement("tr"); - headerRow.title = ` - Click on a column to sort it in descending or ascending order. - Sort multiple columns simultaneously by holding down the Shift key - and clicking a second, third or even fourth column header!\n - Right click on any table cell to copy the content of its tooltip! - `; - - // Create dynamic headers and add them to the header row - headerRow.innerHTML = "Query"; - for (const engine of engines) { - headerRow.innerHTML += `${engine}`; - } - - thead.appendChild(headerRow); - table.appendChild(thead); - - const queryLookup = {}; - for (const [engine, { queries }] of Object.entries(performanceDataPerKb[kb])) { - for (const { query, ...rest } of queries) { - if (!queryLookup[query]) { - queryLookup[query] = {}; - } - queryLookup[query][engine] = rest; - } - } - // Create the table body and add rows and cells - const tbody = document.createElement("tbody"); - - for (const [query, engineStats] of Object.entries(queryLookup)) { - const row = document.createElement("tr"); - - // Get full sparql query from any engine - let title; - for (const engineStat of Object.values(engineStats)) { - if (engineStat?.sparql) { - title = engineStat.sparql; - break; - } - } - warningSymbol = ``; - - const bestRuntime = getBestRuntime(engines, engineStats); - const majorityResultSize = getMajorityResultSize(engines, engineStats); - let showRowWarning = ""; - if (majorityResultSize === "no_consensus") { - showRowWarning = warningSymbol; - title = `The result sizes for the engines do not match!\n\n${title}`; - } - - row.innerHTML += `${query} ${showRowWarning}`; - - for (const engine of engines) { - const result = engineStats[engine]; - if (!result) { - row.innerHTML += "N/A"; - continue; - } - // set result class to show failed and best runtime queries - let runtime = result.runtime_info.client_time; - let resultClass = result.failed ? "bg-danger bg-opacity-25" : ""; - if (!result.failed && runtime === bestRuntime) { - resultClass = "bg-success bg-opacity-25"; - } - - let popoverContent = ""; - if (result.failed) { - popoverContent = result.results; - } else { - popoverContent = `Result size: ${resultSizeToDisplay(result)}`; - } - - // Add warning if result size doesn"t match - let showWarning = ""; - if (!["no_consensus", null].includes(majorityResultSize) && !result.failed) { - if (result.singleResult) { - if (result.singleResult !== majorityResultSize) { - showWarning = warningSymbol; - popoverContent += `\nWarning: Result size ${result.singleResult} differs from majority ${majorityResultSize}.`; - } - } else { - if (format(result.result_size) !== majorityResultSize) { - showWarning = warningSymbol; - popoverContent += `\nWarning: Result size ${format( - result.result_size - )} differs from majority ${majorityResultSize}.`; - } - } - } - let runtimeText = `${formatNumber(parseFloat(runtime))} s ${showWarning}`; - - const resultSizeClass = !document.querySelector("#showResultSize").checked ? "d-none" : ""; - let resultSizeText = resultSizeToDisplay(result); - const resultSizeLine = `
${resultSizeText}
`; - const cellInnerHTML = ` - ${runtimeText} - ${resultSizeLine} - `; - // if (popoverTitle) { - // popoverContent = `${EscapeAttribute(popoverTitle)}
${EscapeAttribute(popoverContent)}`; - // } else { - popoverContent = EscapeAttribute(popoverContent); - // } - - row.innerHTML += `${cellInnerHTML}`; - // row.innerHTML += ` - // - // ${cellInnerHTML} - // - // `; - } - // title="${EscapeAttribute(popoverTitle)}" - if (!document.querySelector("#compareExecDiv").classList.contains("d-none")) { - row.style.cursor = "pointer"; - row.addEventListener("click", function () { - let activeRow = document.querySelector("#comparisonModal").querySelector(".table-active"); - if (activeRow) activeRow.classList.remove("table-active"); - row.classList.add("table-active"); - }); - } - tbody.appendChild(row); - } - table.appendChild(tbody); - - return table; -} diff --git a/src/qlever/evaluation/www/helper.js b/src/qlever/evaluation/www/helper.js deleted file mode 100644 index 46c29d3d..00000000 --- a/src/qlever/evaluation/www/helper.js +++ /dev/null @@ -1,123 +0,0 @@ -// Important Global variables -var sparqlEngines = []; -var execTreeEngines = []; -var kbs = []; -var outputUrl = window.location.pathname.replace("www", "output"); -var performanceDataPerKb = {}; - -var high_query_time_ms = 200; -var very_high_query_time_ms = 1000; - -/** - * Formats a number to include commas as thousands separators and ensures exactly two decimal places. - * - * @param {number} number - The number to format. - * @returns {string} The formatted number as a string. - */ -function formatNumber(number) { - if (Number.isNaN(number)) return "N/A " - return number.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -} - -/** - * Formats a number to include commas as thousands separators without ensuring decimal places. - * - * @param {number} number - The number to format. - * @returns {string} The formatted number as a string with commas as thousands separators. - */ -function format(number) { - return number.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,"); -} - -/** - * Escape text for an attribute - * Source: https://stackoverflow.com/a/77873486 - * @param {string} text - * @returns {string} - */ -function EscapeAttribute(text) { - return text.replace( - /[&<>"']/g, - (match) => - ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }[match]) - ); -} - -/** - * Displays the loading spinner by updating the relevant CSS classes. - */ -function showSpinner() { - document.querySelector("#spinner").classList.remove("d-none", "d-flex"); - document.querySelector("#spinner").classList.add("d-flex"); -} - -/** - * Hides the loading spinner by updating the relevant CSS classes. - */ -function hideSpinner() { - document.querySelector("#spinner").classList.remove("d-none", "d-flex"); - document.querySelector("#spinner").classList.add("d-none"); -} - -/** - * Set multiple attributes on a given DOM node dynamically. - * Simplifies setting multiple attributes at once by iterating over a key-value pair object. - * @param {HTMLElement} node - The DOM node on which attributes will be set. - * @param {Object} attributes - An object containing key-value pairs of attributes to set. - */ -function setAttributes(node, attributes) { - if (node) { - for (const [key, value] of Object.entries(attributes)) { - node.setAttribute(key, value); - } - } -} - -/** - * Display a Bootstrap modal and optionally set attributes dynamically. - * Ensures the modal is shown and adds a custom `pop-triggered` attribute for tracking purposes. - * @param {HTMLElement} modalNode - The DOM node representing the modal to be shown. - * @param {Object} [attributes={}] - Optional attributes to set on the modal before showing it. - * @param {boolean} fromPopState - Is the modal being shown when pop state event is fired. - */ -function showModal(modalNode, attributes = {}, fromPopState = false) { - if (modalNode) { - setAttributes(modalNode, attributes); - if (fromPopState) { - modalNode.setAttribute("pop-triggered", true); - } - const modal = bootstrap.Modal.getOrCreateInstance(modalNode); - modal.show(); - } -} - -function extractCoreValue(sparqlValue) { - if (Array.isArray(sparqlValue)) { - if (sparqlValue.length === 0) return ""; - sparqlValue = sparqlValue[0]; - } - if (typeof sparqlValue !== "string" || sparqlValue.trim() === "") { - return ""; - } - - if (sparqlValue.startsWith("<") && sparqlValue.endsWith(">")) { - // URI - return sparqlValue.slice(1, -1); - } - - const literalMatch = sparqlValue.match(/^"((?:[^"\\]|\\.)*)"/); - if (literalMatch) { - // Decode escape sequences (e.g. \" \\n etc.) - const raw = literalMatch[1]; - return raw.replace(/\\(.)/g, "$1"); - } - - // fallback: return as-is - return sparqlValue; -} diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html deleted file mode 100644 index fec2e666..00000000 --- a/src/qlever/evaluation/www/index.html +++ /dev/null @@ -1,260 +0,0 @@ - - - - - SPARQL Engine Comparison - - - - - - - - - - - - - - - - - - - - - - -
-

SPARQL Engine Comparison


-
- -
-
- Loading... -
-
- - - - - - -
- -
- - - - \ No newline at end of file diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js deleted file mode 100644 index 393464da..00000000 --- a/src/qlever/evaluation/www/main.js +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Create a bootstrap card with engine metrics from cardTemplate for the main page - * @param cardTemplate cardTemplate document node - * @param kb name of the knowledge base - * @param data metrics for each SPARQL engine for the given knowledge base - * @return A bootstrap card displaying SPARQL Engine metrics for the given kb - */ -function populateCard(cardTemplate, kb) { - const clone = document.importNode(cardTemplate.content, true); - const cardTitle = clone.querySelector("h5"); - clone.querySelector("button").addEventListener("click", handleCompareResultsClick.bind(null, kb)); - cardTitle.innerHTML = kb[0].toUpperCase() + kb.slice(1); - const cardBody = clone.querySelector("tbody"); - - Object.keys(performanceDataPerKb[kb]).forEach((engine) => { - const engineData = performanceDataPerKb[kb][engine]; - const row = document.createElement("tr"); - row.style.cursor = "pointer"; - row.addEventListener("click", handleRowClick); - row.innerHTML = ` - - - - ${engine} - ${formatNumber(parseFloat(engineData.failed))}% - ${formatNumber(parseFloat(engineData.gmeanTime))}s - ${formatNumber(parseFloat(engineData.ameanTime))}s - ${formatNumber(parseFloat(engineData.medianTime))}s - ${formatNumber(parseFloat(engineData.under1s))}% - ${formatNumber(parseFloat(engineData.between1to5s))}% - ${formatNumber(parseFloat(engineData.over5s))}% - `; - cardBody.appendChild(row); - addEventListenersForCard(clone.querySelector("table")); - }); - return clone; -} - -function addEventListenersForCard(cardNode) { - thead = cardNode.querySelector("thead"); - tbody = cardNode.querySelector("tbody"); - // If the header is checked, all the rows must be checked and vice-versa - thead.addEventListener("change", function (event) { - const headerCheckbox = event.target; - const rowCheckboxes = tbody.querySelectorAll(".row-checkbox"); - rowCheckboxes.forEach((checkbox) => { - checkbox.checked = headerCheckbox.checked; - }); - }); - - // Update the checker status of header based on if all rows are selected or not - tbody.addEventListener("change", function () { - const headerCheckbox = thead.querySelector("input"); - const rowCheckboxes = tbody.querySelectorAll(".row-checkbox"); - const allChecked = Array.from(rowCheckboxes).every((checkbox) => checkbox.checked); - headerCheckbox.checked = allChecked; - }); -} - -/** - * Hide a modal if it is currently open. - * Adds a custom `pop-triggered` attribute to the modal so that modal.hide() doesn't execute any code after closing - * @param {HTMLElement} modalNode - The DOM node representing the modal to be hidden. - */ -function hideModalIfOpened(modalNode) { - if (modalNode.classList.contains("show")) { - modalNode.setAttribute("pop-triggered", true); - bootstrap.Modal.getInstance(modalNode).hide(); - } -} - -/** - * Handle browser's back button actions by displaying or hiding modals based on the current state. - * Dynamically adjusts modal attributes and visibility depending on the `page` property in the `popstate` event's state. - * @param {PopStateEvent} event - The popstate event triggered by browser navigation actions. - */ -window.addEventListener("popstate", function (event) { - const comparisonModal = document.querySelector("#comparisonModal"); - const queryDetailsModal = document.querySelector("#queryDetailsModal"); - const compareExecTreesModal = document.querySelector("#compareExecTreeModal"); - - const state = event.state || {}; - const { page, kb, engine, q, t, s1, s2, qid } = state; - const { selectedQuery, tab } = getSanitizedQAndT(q, t); - - // Close all modals initially - //[comparisonModal, queryDetailsModal, compareExecTreesModal].forEach(hideModalIfOpened); - - switch (page) { - case "comparison": - [queryDetailsModal, compareExecTreesModal].forEach(hideModalIfOpened); - showModal(comparisonModal, { "data-kb": kb }, true); - break; - - case "queriesDetails": - [comparisonModal, compareExecTreesModal].forEach(hideModalIfOpened); - if (queryDetailsModal.classList.contains("show")) { - tab ? showTab(tab) : showTab(0); - } else { - showModal( - queryDetailsModal, - { "data-kb": kb, "data-engine": engine, "data-query": selectedQuery, "data-tab": tab }, - true - ); - } - break; - - case "compareExecTrees": - [comparisonModal, queryDetailsModal].forEach(hideModalIfOpened); - showModal(compareExecTreesModal, { "data-kb": kb, "data-s1": s1, "data-s2": s2, "data-qid": qid }, true); - break; - - case "main": - default: - [comparisonModal, queryDetailsModal, compareExecTreesModal].forEach(hideModalIfOpened); - // No action needed for the main page - break; - } -}); - -/** - * Display appropriate modals based on URL parameters. - * Reads URL query parameters to determine the `page` and associated attributes, - * then displays the corresponding modal. - * If no valid page parameter is found, the URL is reset to the main page. - * @async - */ -async function showPageFromUrl() { - const urlParams = new URLSearchParams(window.location.search); - const page = urlParams.get("page"); - const kb = urlParams.get("kb")?.toLowerCase(); - const { selectedQuery, tab } = getSanitizedQAndT(urlParams.get("q"), urlParams.get("t")); - const queryDetailsModal = document.querySelector("#queryDetailsModal"); - const comparisonModal = document.querySelector("#comparisonModal"); - const compareExecTreesModal = document.querySelector("#compareExecTreeModal"); - - switch (page) { - case "comparison": - showModal(comparisonModal, { "data-kb": kb }); - break; - - case "queriesDetails": - showModal(queryDetailsModal, { - "data-kb": kb, - "data-engine": urlParams.get("engine")?.toLowerCase(), - "data-query": selectedQuery, - "data-tab": tab, - }); - break; - - case "compareExecTrees": - showModal(compareExecTreesModal, { - "data-kb": kb, - "data-s1": urlParams.get("s1")?.toLowerCase(), - "data-s2": urlParams.get("s2")?.toLowerCase(), - "data-qid": urlParams.get("qid"), - }); - break; - - default: - // Navigate back to the main page if no valid page parameter - const url = new URL(window.location); - url.search = ""; - window.history.replaceState({ page: "main" }, "", url); - break; - } -} - -function getSanitizedQAndT(q, t) { - let selectedQuery = parseInt(q); - let tab = parseInt(t); - - if (isNaN(tab) || tab < 1 || tab > 3) { - tab = ""; - } - - if (isNaN(selectedQuery) || selectedQuery < 0) { - selectedQuery = ""; - } - return { selectedQuery: selectedQuery, tab: tab }; -} - -function augmentPerformanceDataPerKb(performanceDataPerKb) { - for (const engines of Object.values(performanceDataPerKb)) { - for (const { queries } of Object.values(engines)) { - for (const query of queries) { - const failed = query.headers.length === 0 || !Array.isArray(query.results); - let singleResult = null; - if ( - query.result_size === 1 && - query.headers.length === 1 && - Array.isArray(query.results) && - query.results.length == 1 - ) { - let resultValue; - if (Array.isArray(query.results[0]) && query.results[0].length > 0) { - resultValue = query.results[0][0]; - } else { - resultValue = query.results[0]; - } - singleResult = extractCoreValue(resultValue); - singleResult = parseInt(singleResult) ? format(singleResult) : singleResult; - } - query.failed = failed; - query.singleResult = singleResult; - query.result_size = query.result_size ? query.result_size : 0; - } - } - } -} - -// Use the DOMContentLoaded event listener to ensure the DOM is ready -document.addEventListener("DOMContentLoaded", async function () { - // Fetch the card template - const response = await fetch("card-template.html"); - const templateText = await response.text(); - - // Create a virtual DOM element to hold the template - const tempDiv = document.createElement("div"); - tempDiv.innerHTML = templateText; - const cardTemplate = tempDiv.querySelector("#cardTemplate"); - - const fragment = document.createDocumentFragment(); - - try { - // Get the current URL without the part after the final `/` (and ignore a - // `/` at the end) - const yaml_path = window.location.origin + - window.location.pathname.replace(/\/$/, "").replace(/\/[^/]*$/, "/"); - const response = await fetch(yaml_path + "yaml_data"); - if (!response.ok) { - throw new Error(`Server error: ${response.status}`); - } - performanceDataPerKb = await response.json(); - augmentPerformanceDataPerKb(performanceDataPerKb); - for (const kb of Object.keys(performanceDataPerKb)) { - fragment.appendChild(populateCard(cardTemplate, kb)); - } - document.getElementById("cardsContainer").appendChild(fragment); - $("#cardsContainer table").tablesorter({ - theme: "bootstrap", - sortStable: true, - sortInitialOrder: "desc", - }); - // Navigate to the correct page (or modal) based on the url - await showPageFromUrl(); - } catch (error) { - console.error("Failed to fetch performance data:", error); - return null; - } - - // Setup event listeners for queryDetailsModal, comparisonModal and compareExecModal - setListenersForQueriesTabs(); - setListenersForCompareExecModal(); - setListenersForEnginesComparison(); -}); diff --git a/src/qlever/evaluation/www/query-details.js b/src/qlever/evaluation/www/query-details.js deleted file mode 100644 index f8ecfcd9..00000000 --- a/src/qlever/evaluation/www/query-details.js +++ /dev/null @@ -1,473 +0,0 @@ -/** - * Sets event listeners for the tabs in queryDetailsModal - * - * - Listens for click events on query tab buttons and handles tab switching. - * - Controls the visibility of the modal footer based on the selected tab and content. - */ -function setListenersForQueriesTabs() { - const modalNode = document.querySelector("#queryDetailsModal"); - // Before the modal is shown, update the url and history Stack and remove the previous table - modalNode.addEventListener("show.bs.modal", async function () { - const kb = modalNode.getAttribute("data-kb"); - const engine = modalNode.getAttribute("data-engine"); - const selectedQuery = modalNode.getAttribute("data-query") ? modalNode.getAttribute("data-query") : null; - const tab = modalNode.getAttribute("data-tab") ? modalNode.getAttribute("data-tab") : null; - - if (kb && engine) { - // If back/forward button, do nothing - if (modalNode.getAttribute("pop-triggered")) { - modalNode.removeAttribute("pop-triggered"); - } - // Else Update the url params and push the page to history stack - else { - updateUrlAndState(kb, engine, selectedQuery, tab); - } - - // If the kb and engine are the same as previously opened, do nothing - const modalTitle = modalNode.querySelector(".modal-title"); - const tab1Content = modalNode.querySelector("#runtimes-tab-pane"); - if ( - modalTitle.textContent.split(" - ")[1] === engine && - tab1Content.querySelector(".card-title").innerHTML.split(" - ")[1] - ) { - return; - } - // Else clear the table with queries and runtimes before the modal is shown - else { - const tabBody = modalNode.querySelector("#queryList"); - tabBody.replaceChildren(); - // Populate modal title - modalTitle.textContent = "SPARQL Engine - "; - // Populate Knowledge base - tab1Content.querySelector(".card-title").innerHTML = "Knowledge Graph - "; - //bootstrap.Tab.getOrCreateInstance(document.querySelector("#runtimes-tab")).show(); - showTab(0); - } - } - }); - - // After the modal is shown, populate the modal based on kb and engine attributes - modalNode.addEventListener("shown.bs.modal", async function () { - const kb = modalNode.getAttribute("data-kb"); - const engine = modalNode.getAttribute("data-engine"); - const selectedQuery = modalNode.getAttribute("data-query") ? parseInt(modalNode.getAttribute("data-query")) : null; - const tab = modalNode.getAttribute("data-tab") ? parseInt(modalNode.getAttribute("data-tab")) : null; - if (kb && engine) { - await openQueryDetailsModal(kb, engine, selectedQuery, tab); - } - const resultSizeCheckbox = document.querySelector("#showResultSizeQd"); - - if (!resultSizeCheckbox.hasEventListener) { - resultSizeCheckbox.addEventListener("change", function () { - const tdElements = modalNode.querySelectorAll("td"); - if (resultSizeCheckbox.checked) { - tdElements.forEach((td) => { - const resultSizeDiv = td.querySelector("div.text-muted.small"); - resultSizeDiv?.classList.remove("d-none"); - }); - } else { - tdElements.forEach((td) => { - const resultSizeDiv = td.querySelector("div.text-muted.small"); - resultSizeDiv?.classList.add("d-none"); - }); - } - }); - resultSizeCheckbox.hasEventListener = true; - } - }); - - // Handle the modal's `hidden.bs.modal` event - modalNode.addEventListener("hidden.bs.modal", function () { - // Don't execute any url or state based code when back/forward button clicked - if (modalNode.getAttribute("pop-triggered")) { - modalNode.removeAttribute("pop-triggered"); - return; - } - // Remove modal-related parameters from the URL - const url = new URL(window.location); - url.searchParams.delete("page"); - url.searchParams.delete("kb"); - url.searchParams.delete("engine"); - url.searchParams.delete("q"); - url.searchParams.delete("t"); - window.history.pushState({ page: "main" }, "", url); - }); - - const triggerTabList = document.querySelectorAll("#myTab button"); - triggerTabList.forEach((triggerEl, index) => { - triggerEl.addEventListener("click", (event) => { - event.preventDefault(); - showTab(index); - const urlParams = new URLSearchParams(window.location.search); - updateUrlAndState(urlParams.get("kb"), urlParams.get("engine"), urlParams.get("q"), index); - }); - }); - - // Adds functionality to buttons in the modal footer for zooming in/out the execution tree - modalNode.querySelector(".modal-footer").addEventListener("click", function (event) { - if (event.target.tagName === "BUTTON") { - const purpose = event.target.id; - const treeId = "#result-tree"; - const tree = document.querySelector(treeId); - const currentFontSize = tree.querySelector(".node[class*=font-size-]").className.match(/font-size-(\d+)/)[1]; - generateExecutionTree(null, purpose, treeId, Number.parseInt(currentFontSize)); - } - }); -} - -function updateUrlAndState(kb, engine, selectedQuery, tab) { - const url = new URL(window.location); - url.searchParams.set("page", "queriesDetails"); - url.searchParams.set("kb", kb); - url.searchParams.set("engine", engine); - const state = { page: "queriesDetails", kb: kb, engine: engine }; - selectedQuery !== null && (state.q = selectedQuery.toString()) && url.searchParams.set("q", selectedQuery.toString()); - tab !== null && (state.t = tab.toString()) && url.searchParams.set("t", tab.toString()); - // If this page is directly opened from url, replace the null state in history stack - if (window.history.state === null) { - window.history.replaceState(state, "", url); - } else { - window.history.pushState(state, "", url); - } -} - -function fixUrlAndState(totalQueries, selectedQuery, tab) { - const url = new URL(window.location); - let updateState = false; - if (selectedQuery === null || selectedQuery >= totalQueries) { - url.searchParams.delete("q"); - updateState = true; - selectedQuery = null; - } - if (tab === null) { - url.searchParams.delete("t"); - updateState = true; - } - if (updateState) { - const currentState = window.history.state; - const newState = { ...currentState }; - selectedQuery === null && delete newState["q"]; - tab === null && delete newState["t"]; - history.replaceState(newState, "", url); - } - return { q: selectedQuery, t: tab }; -} - -/** - * Handles the click event on a row in the cards on main page. - * - * Set the kb and engine attribute based on the row clicked on by the user and open the modal - * - * @async - * @param {Event} event - The click event triggered by selecting a row. - */ -async function handleRowClick(event) { - if ( - event.target.classList.contains("row-checkbox") || - event.target.firstElementChild?.classList.contains("row-checkbox") - ) { - return; - } - const kb = event.currentTarget.closest(".card").querySelector("h5").innerHTML.toLowerCase(); - const engine = event.currentTarget.children[1].innerHTML.toLowerCase(); - const modalNode = document.querySelector("#queryDetailsModal"); - modalNode.setAttribute("data-kb", kb); - modalNode.setAttribute("data-engine", engine); - modalNode.setAttribute("data-query", ""); - modalNode.setAttribute("data-tab", ""); - showModal(modalNode); -} - -/** - * - Fetches the query log and results based on the selected knowledge base (KB) and engine. - * - Updates the modal content and displays the query details. - * - Manages the state of the query execution tree and tab content. - * - * @async - * @param {string} kb - The selected knowledge base - * @param {string} engine - The selected sparql engine - * @param {number} selectedQuery - Selected Query index in runtimes table, if any - * @param {number} tabToOpen - index of the tab to show - */ -async function openQueryDetailsModal(kb, engine, selectedQuery, tabToOpen) { - const modalNode = document.getElementById("queryDetailsModal"); - - // If the kb and engine are the same as previously opened, do nothing and display the modal as it is. - const modalTitle = modalNode.querySelector(".modal-title"); - const tab1Content = modalNode.querySelector("#runtimes-tab-pane"); - if (modalTitle.textContent.includes(engine) && tab1Content.querySelector(".card-title").innerHTML.includes(kb)) { - tabToOpen === null ? showTab(0) : showTab(tabToOpen); - return; - } - - // Fetch and display the runtime table with all queries and populate and display the first tab - showSpinner(); - // Populate modal title - modalTitle.textContent = "SPARQL Engine - " + engine; - // Populate knowledge base - tab1Content.querySelector(".card-title").innerHTML = "Knowledge Graph - " + kb; - - const tabBody = modalNode.querySelector("#queryList"); - const queryResult = performanceDataPerKb[kb][engine]["queries"]; - - if ( - queryResult && - queryResult[0].runtime_info.hasOwnProperty("query_execution_tree") && - !execTreeEngines.includes(engine) - ) { - execTreeEngines.push(engine); - } - - document.getElementById("resultsTable").replaceChildren(); - - for (let id of ["#tab2Content", "#tab3Content", "#tab4Content"]) { - const tabContent = modalNode.querySelector(id); - tabContent.replaceChildren(document.createTextNode("Please select a query from the table in Query runtimes tab")); - } - document.getElementById("result-tree").replaceChildren(); - - createQueryTable(queryResult, tabBody); - $("#runtimes-tab-pane table").tablesorter({ - theme: "bootstrap", - sortStable: true, - sortInitialOrder: "desc", - }); - - document.querySelector("#queryDetailsModal").querySelector(".modal-footer").classList.add("d-none"); - const { q, t } = fixUrlAndState(queryResult.length, selectedQuery, tabToOpen); - if (q !== null) { - document.querySelector("#queryList").querySelectorAll("tr")[q].classList.add("table-active"); - populateTabsFromSelectedRow(queryResult[q]); - } - if (t !== null) { - showTab(t); - } - hideSpinner(); -} - -/** - * Creates and populates the query table inside the modal with query results and runtimes. - * - * - Iterates over the query results and dynamically creates table rows. - * - Each row displays the query and its runtime, along with click event listeners. - * - * @param {Object[]} queryResult - The array of query result objects. - * @param {string} kb - The name of the knowledge base. - * @param {string} engine - The SPARQL engine used. - * @param {HTMLElement} tabBody - The #queryList element. - */ -function createQueryTable(queryResult, tabBody) { - queryResult.forEach((query, i) => { - const tabRow = document.createElement("tr"); - tabRow.style.cursor = "pointer"; - tabRow.addEventListener("click", handleTabRowClick.bind(null, query)); - let runtime; - if (query.runtime_info && Object.hasOwn(query.runtime_info, "client_time")) { - runtime = formatNumber(query.runtime_info.client_time); - } else { - runtime = "N/A"; - } - - const actualSize = query.result_size ? query.result_size : 0; - const resultSizeClass = !document.querySelector("#showResultSizeQd").checked ? "d-none" : ""; - let resultSizeText = format(actualSize); - if (actualSize === 1 && query.headers.length === 1 && Array.isArray(query.results) && query.results.length == 1) { - const resultValue = Array.isArray(query.results[0]) ? query.results[0][0] : query.results[0]; - let singleResult = extractCoreValue(resultValue); - singleResult = parseInt(singleResult) ? format(singleResult) : singleResult; - resultSizeText = `1 [${singleResult}]`; - } - const resultSizeLine = `
${resultSizeText}
`; - const cellInnerHTML = ` - ${runtime} - ${resultSizeLine} - `; - - const failed = query.headers.length === 0 || !Array.isArray(query.results); - const failedTitle = failed ? EscapeAttribute(query.results) : ""; - - resultClass = query.headers.length === 0 || !Array.isArray(query.results) ? "bg-danger bg-opacity-25" : ""; - tabRow.innerHTML = ` - ${query.query} - ${cellInnerHTML} - `; - tabBody.appendChild(tabRow); - }); -} - -/** - * Handles the click event on a query row within the query table. - * - * - Highlights the selected row and switches to the query details tab. - * - Generates the query SPARQL, execution results, and execution tree in their relevant tabs - * - * @param {Object} queryRow - The object representing the query details. - * @param {Event} event - The click event triggered by selecting a query row. - */ -function handleTabRowClick(queryRow, event) { - const kb = document.querySelector("#queryDetailsModal").getAttribute("data-kb"); - const engine = document.querySelector("#queryDetailsModal").getAttribute("data-engine"); - updateUrlAndState(kb, engine, event.currentTarget.rowIndex - 1, 1); - let activeRow = document.querySelector("#queryList").querySelector(".table-active"); - if (activeRow) { - if (activeRow.rowIndex === event.currentTarget.rowIndex) { - showTab(1); - return; - } - activeRow.classList.remove("table-active"); - } - event.currentTarget.classList.add("table-active"); - showSpinner(); - showTab(1); - populateTabsFromSelectedRow(queryRow); - hideSpinner(); -} - -/** - * - Generates the query SPARQL, execution results, and execution tree in their relevant tabs - * - * @param {Object} queryRow - The object representing the query details. - */ -function populateTabsFromSelectedRow(queryRow) { - const tab2Content = document.querySelector("#tab2Content"); - tab2Content.textContent = queryRow.sparql; - document.querySelector("#showMore").classList.replace("d-flex", "d-none"); - const showMoreCloneButton = document.querySelector("#showMoreButton").cloneNode(true); - document.querySelector("#showMore").replaceChild(showMoreCloneButton, document.querySelector("#showMoreButton")); - generateHTMLTable(queryRow); - generateExecutionTree(queryRow); -} - -/** - * - Display the tab corresponding to the passed tabIndex - * - * @param {number} tabIndex - index of the tab to display (0 - 3) - */ -function showTab(tabIndex) { - const tabNodeId = document.querySelectorAll("#myTab button")[tabIndex].id; - const tab = bootstrap.Tab.getOrCreateInstance(document.getElementById(tabNodeId)); - tab.show(); - // Enable zoom buttons for execution tree tab (2) - if (tabIndex === 2 && document.querySelector("#result-tree").children.length !== 0) { - document.querySelector("#queryDetailsModal").querySelector(".modal-footer").classList.remove("d-none"); - } -} - -/** - * Generates the query results table for a set of results. - * - * - Iterates over the provided results and creates table rows for each result entry. - * - Handles formatting of SPARQL results, stripping unnecessary data type information. - * - * @param {string[][]} results - A 2D array representing the query results. - */ -function generateQueryResultsTable(results, headers) { - const table = document.getElementById("resultsTable"); - const tableFragment = document.createDocumentFragment(); - - if (headers) { - const headerRow = document.createElement("tr"); - for (const header of headers) { - const headerCell = document.createElement("th"); - headerCell.textContent = header; - headerRow.appendChild(headerCell); - } - tableFragment.appendChild(headerRow); - } - - const index = results.length > 1000 ? 1000 : results.length; - // Create table rows - for (let i = 0; i < index; i++) { - const row = document.createElement("tr"); - for (let j = 0; j < results[i].length; j++) { - const cell = document.createElement("td"); - - // Extract only the value without the data type information - let value = results[i][j]; - if (!value) value = "N/A"; - else if (value.startsWith('"') && value.endsWith('"')) { - value = value.slice(1, -1); // Remove double quotes - } else if (value.includes("^^<")) { - value = value.split("^^<")[0]; // Remove data type - } - - cell.textContent = value; - row.appendChild(cell); - } - tableFragment.appendChild(row); - } - // Append the fragment to the table - table.appendChild(tableFragment); -} - -/** - * Displays additional query results if there are more than 1000 results. - * - * - Handles pagination of results when there are more than 1000 entries. - * - Dynamically loads more results when the "Show More" button is clicked. - * - * @param {string[][]} results - A 2D array representing the remaining query results. - */ -function displayMoreResults(results) { - if (results.length > 1000) { - generateQueryResultsTable(results.slice(0, 1000)); - let remainingResults = results.slice(1000); - document.querySelector("#showMoreButton").addEventListener( - "click", - function () { - displayMoreResults(remainingResults); - }, - { once: true } - ); - } else { - generateQueryResultsTable(results); - document.querySelector("#showMore").classList.replace("d-flex", "d-none"); - } -} - -/** - * Generates the HTML table for SPARQL query results and handles pagination if needed. - * - * - Displays a message if no results are available or if the query failed. - * - Generates the query results table and manages the "Show More" button for large result sets. - * - * @param {string[][] | Object} tableData - A 2D array of query results or an error message object. - */ -function generateHTMLTable(queryRow) { - const tableData = queryRow.results; - const headers = queryRow.headers; - document.getElementById("resultsTable").replaceChildren(); - if (!tableData) { - document - .getElementById("tab4Content") - .replaceChildren(document.createTextNode("No SPARQL results available for this query!")); - return; - } - if (!Array.isArray(tableData)) { - document.getElementById("tab4Content").replaceChildren(document.createTextNode(tableData)); - return; - } - document.getElementById("tab4Content").replaceChildren(); - document.getElementById("resultsTable").replaceChildren(); - const h5Text = document.createElement("h5"); - const totalResults = queryRow.result_size; - const resultsShown = totalResults <= 50 ? totalResults : 50; - h5Text.textContent = `${ - queryRow.result_size - } result(s) found for this query in ${queryRow.runtime_info.client_time.toPrecision( - 2 - )}s. Showing ${resultsShown} result(s)`; - document.getElementById("tab4Content").replaceChildren(h5Text); - generateQueryResultsTable(tableData, headers); - if (1000 < tableData.length) { - document.querySelector("#showMore").classList.replace("d-none", "d-flex"); - let remainingResults = tableData.slice(1000); - document.querySelector("#showMoreButton").addEventListener( - "click", - function displayResults() { - displayMoreResults(remainingResults); - }, - { once: true } - ); - } -} diff --git a/src/qlever/evaluation/www/treant.css b/src/qlever/evaluation/www/treant.css deleted file mode 100644 index 9a4ab796..00000000 --- a/src/qlever/evaluation/www/treant.css +++ /dev/null @@ -1,66 +0,0 @@ -/* required LIB STYLES */ -/* .Treant se automatski dodaje na svaki chart conatiner */ -.Treant { position: relative; overflow: hidden; padding: 0 !important; } -.Treant > .node, -.Treant > .pseudo { position: absolute; display: block; visibility: hidden; } -.Treant.Treant-loaded .node, -.Treant.Treant-loaded .pseudo { visibility: visible; } -.Treant > .pseudo { width: 0; height: 0; border: none; padding: 0; } -.Treant .collapse-switch { width: 3px; height: 3px; display: block; border: 1px solid black; position: absolute; top: 1px; right: 1px; cursor: pointer; } -.Treant .collapsed .collapse-switch { background-color: #868DEE; } -.Treant > .node img { border: none; float: left; } - -.node { - padding: 2px; - border-radius: 3px; - background-color: #FEFEFE; - border: 1px solid #000; - min-width: 20em; - max-width: 30em; - color: black; -} - -.node > p { margin: 0; } -.node-name { font-weight: bold; } -.node-size:before { content: "Size: "; } -.node-cols:before { content: "Cols: "; } -.node-size:before { content: "Size: "; } -.node-time:before { content: "Time: "; } -.node-time:after { content: "ms"; } -div.cached .node-time:after { content: "ms (cached -> 0ms)"; } -.node-total { display: none; } -.node-cached { display: none; } -.node.cached { color: grey; border: 1px solid grey; } -.node.high { background-color: #FFF7F7; } -.node.veryhigh { background-color: #FFEEEE; } -.node.high.cached { background-color: #FFFFF7; } -.node.veryhigh.cached { background-color: #FFFFEE; } - -#spinner { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(186, 179, 179, 0.7); - justify-content: center; - align-items: center; - z-index: 10000; -} - -.font-size-30 { font-size: 30%; } -.font-size-40 { font-size: 40%; } -.font-size-50 { font-size: 50%; } -.font-size-60 { font-size: 60%; } -.font-size-70 { font-size: 70%; } -.font-size-80 { font-size: 80%; } - -table td { - padding-top: 0.2em !important; - padding-bottom: 0.2em !important; -} - -td[title]:hover, -td[data-bs-toggle="popover"]:hover { - cursor: pointer; -} diff --git a/src/qlever/evaluation/www/treant.js b/src/qlever/evaluation/www/treant.js deleted file mode 100644 index 124d725b..00000000 --- a/src/qlever/evaluation/www/treant.js +++ /dev/null @@ -1,2171 +0,0 @@ -/* - * Treant-js - * - * (c) 2013 Fran Peručić - * Treant-js may be freely distributed under the MIT license. - * For all details and documentation: - * http://fperucic.github.io/treant-js - * - * Treant is an open-source JavaScipt library for visualization of tree diagrams. - * It implements the node positioning algorithm of John Q. Walker II "Positioning nodes for General Trees". - * - * References: - * Emilio Cortegoso Lobato: ECOTree.js v1.0 (October 26th, 2006) - * - * Contributors: - * Fran Peručić, https://github.com/fperucic - * Dave Goodchild, https://github.com/dlgoodchild - */ - -;( function() { - // Polyfill for IE to use startsWith - if (!String.prototype.startsWith) { - String.prototype.startsWith = function(searchString, position){ - return this.substr(position || 0, searchString.length) === searchString; - }; - } - - var $ = null; - - var UTIL = { - - /** - * Directly updates, recursively/deeply, the first object with all properties in the second object - * @param {object} applyTo - * @param {object} applyFrom - * @return {object} - */ - inheritAttrs: function( applyTo, applyFrom ) { - for ( var attr in applyFrom ) { - if ( applyFrom.hasOwnProperty( attr ) ) { - if ( ( applyTo[attr] instanceof Object && applyFrom[attr] instanceof Object ) && ( typeof applyFrom[attr] !== 'function' ) ) { - this.inheritAttrs( applyTo[attr], applyFrom[attr] ); - } - else { - applyTo[attr] = applyFrom[attr]; - } - } - } - return applyTo; - }, - - /** - * Returns a new object by merging the two supplied objects - * @param {object} obj1 - * @param {object} obj2 - * @returns {object} - */ - createMerge: function( obj1, obj2 ) { - var newObj = {}; - if ( obj1 ) { - this.inheritAttrs( newObj, this.cloneObj( obj1 ) ); - } - if ( obj2 ) { - this.inheritAttrs( newObj, obj2 ); - } - return newObj; - }, - - /** - * Takes any number of arguments - * @returns {*} - */ - extend: function() { - if ( $ ) { - Array.prototype.unshift.apply( arguments, [true, {}] ); - return $.extend.apply( $, arguments ); - } - else { - return UTIL.createMerge.apply( this, arguments ); - } - }, - - /** - * @param {object} obj - * @returns {*} - */ - cloneObj: function ( obj ) { - if ( Object( obj ) !== obj ) { - return obj; - } - var res = new obj.constructor(); - for ( var key in obj ) { - if ( obj.hasOwnProperty(key) ) { - res[key] = this.cloneObj(obj[key]); - } - } - return res; - }, - - /** - * @param {Element} el - * @param {string} eventType - * @param {function} handler - */ - addEvent: function( el, eventType, handler ) { - if ( $ ) { - $( el ).on( eventType+'.treant', handler ); - } - else if ( el.addEventListener ) { // DOM Level 2 browsers - el.addEventListener( eventType, handler, false ); - } - else if ( el.attachEvent ) { // IE <= 8 - el.attachEvent( 'on' + eventType, handler ); - } - else { // ancient browsers - el['on' + eventType] = handler; - } - }, - - /** - * @param {string} selector - * @param {boolean} raw - * @param {Element} parentEl - * @returns {Element|jQuery} - */ - findEl: function( selector, raw, parentEl ) { - parentEl = parentEl || document; - - if ( $ ) { - var $element = $( selector, parentEl ); - return ( raw? $element.get( 0 ): $element ); - } - else { - // todo: getElementsByName() - // todo: getElementsByTagName() - // todo: getElementsByTagNameNS() - if ( selector.charAt( 0 ) === '#' ) { - return parentEl.getElementById( selector.substring( 1 ) ); - } - else if ( selector.charAt( 0 ) === '.' ) { - var oElements = parentEl.getElementsByClassName( selector.substring( 1 ) ); - return ( oElements.length? oElements[0]: null ); - } - - throw new Error( 'Unknown container element' ); - } - }, - - getOuterHeight: function( element ) { - var nRoundingCompensation = 1; - if ( typeof element.getBoundingClientRect === 'function' ) { - return element.getBoundingClientRect().height; - } - else if ( $ ) { - return Math.ceil( $( element ).outerHeight() ) + nRoundingCompensation; - } - else { - return Math.ceil( - element.clientHeight - + UTIL.getStyle( element, 'border-top-width', true ) - + UTIL.getStyle( element, 'border-bottom-width', true ) - + UTIL.getStyle( element, 'padding-top', true ) - + UTIL.getStyle( element, 'padding-bottom', true ) - + nRoundingCompensation - ); - } - }, - - getOuterWidth: function( element ) { - var nRoundingCompensation = 1; - if ( typeof element.getBoundingClientRect === 'function' ) { - return element.getBoundingClientRect().width; - } - else if ( $ ) { - return Math.ceil( $( element ).outerWidth() ) + nRoundingCompensation; - } - else { - return Math.ceil( - element.clientWidth - + UTIL.getStyle( element, 'border-left-width', true ) - + UTIL.getStyle( element, 'border-right-width', true ) - + UTIL.getStyle( element, 'padding-left', true ) - + UTIL.getStyle( element, 'padding-right', true ) - + nRoundingCompensation - ); - } - }, - - getStyle: function( element, strCssRule, asInt ) { - var strValue = ""; - if ( document.defaultView && document.defaultView.getComputedStyle ) { - strValue = document.defaultView.getComputedStyle( element, '' ).getPropertyValue( strCssRule ); - } - else if( element.currentStyle ) { - strCssRule = strCssRule.replace(/\-(\w)/g, - function (strMatch, p1){ - return p1.toUpperCase(); - } - ); - strValue = element.currentStyle[strCssRule]; - } - //Number(elem.style.width.replace(/[^\d\.\-]/g, '')); - return ( asInt? parseFloat( strValue ): strValue ); - }, - - addClass: function( element, cssClass ) { - if ( $ ) { - $( element ).addClass( cssClass ); - } - else { - if ( !UTIL.hasClass( element, cssClass ) ) { - if ( element.classList ) { - element.classList.add( cssClass ); - } - else { - element.className += " "+cssClass; - } - } - } - }, - - hasClass: function(element, my_class) { - return (" " + element.className + " ").replace(/[\n\t]/g, " ").indexOf(" "+my_class+" ") > -1; - }, - - toggleClass: function ( element, cls, apply ) { - if ( $ ) { - $( element ).toggleClass( cls, apply ); - } - else { - if ( apply ) { - //element.className += " "+cls; - element.classList.add( cls ); - } - else { - element.classList.remove( cls ); - } - } - }, - - setDimensions: function( element, width, height ) { - if ( $ ) { - $( element ).width( width ).height( height ); - } - else { - element.style.width = width+'px'; - element.style.height = height+'px'; - } - }, - isjQueryAvailable: function() {return(typeof ($) !== 'undefined' && $);}, - }; - - /** - * ImageLoader is used for determining if all the images from the Tree are loaded. - * Node size (width, height) can be correctly determined only when all inner images are loaded - */ - var ImageLoader = function() { - this.reset(); - }; - - ImageLoader.prototype = { - - /** - * @returns {ImageLoader} - */ - reset: function() { - this.loading = []; - return this; - }, - - /** - * @param {TreeNode} node - * @returns {ImageLoader} - */ - processNode: function( node ) { - var aImages = node.nodeDOM.getElementsByTagName( 'img' ); - - var i = aImages.length; - while ( i-- ) { - this.create( node, aImages[i] ); - } - return this; - }, - - /** - * @returns {ImageLoader} - */ - removeAll: function( img_src ) { - var i = this.loading.length; - while ( i-- ) { - if ( this.loading[i] === img_src ) { - this.loading.splice( i, 1 ); - } - } - return this; - }, - - /** - * @param {TreeNode} node - * @param {Element} image - * @returns {*} - */ - create: function ( node, image ) { - var self = this, source = image.src; - - function imgTrigger() { - self.removeAll( source ); - node.width = node.nodeDOM.offsetWidth; - node.height = node.nodeDOM.offsetHeight; - } - - if ( image.src.indexOf( 'data:' ) !== 0 ) { - this.loading.push( source ); - - if ( image.complete ) { - return imgTrigger(); - } - - UTIL.addEvent( image, 'load', imgTrigger ); - UTIL.addEvent( image, 'error', imgTrigger ); // handle broken url-s - - // load event is not fired for cached images, force the load event - image.src += ( ( image.src.indexOf( '?' ) > 0)? '&': '?' ) + new Date().getTime(); - } - else { - imgTrigger(); - } - }, - - /** - * @returns {boolean} - */ - isNotLoading: function() { - return ( this.loading.length === 0 ); - } - }; - - /** - * Class: TreeStore - * TreeStore is used for holding initialized Tree objects - * Its purpose is to avoid global variables and enable multiple Trees on the page. - */ - var TreeStore = { - - store: [], - - /** - * @param {object} jsonConfig - * @returns {Tree} - */ - createTree: function( jsonConfig ) { - var nNewTreeId = this.store.length; - this.store.push( new Tree( jsonConfig, nNewTreeId ) ); - return this.get( nNewTreeId ); - }, - - /** - * @param {number} treeId - * @returns {Tree} - */ - get: function ( treeId ) { - return this.store[treeId]; - }, - - /** - * @param {number} treeId - * @returns {TreeStore} - */ - destroy: function( treeId ) { - var tree = this.get( treeId ); - if ( tree ) { - tree._R.remove(); - var draw_area = tree.drawArea; - - while ( draw_area.firstChild ) { - draw_area.removeChild( draw_area.firstChild ); - } - - var classes = draw_area.className.split(' '), - classes_to_stay = []; - - for ( var i = 0; i < classes.length; i++ ) { - var cls = classes[i]; - if ( cls !== 'Treant' && cls !== 'Treant-loaded' ) { - classes_to_stay.push(cls); - } - } - draw_area.style.overflowY = ''; - draw_area.style.overflowX = ''; - draw_area.className = classes_to_stay.join(' '); - - this.store[treeId] = null; - } - return this; - } - }; - - /** - * Tree constructor. - * @param {object} jsonConfig - * @param {number} treeId - * @constructor - */ - var Tree = function (jsonConfig, treeId ) { - - /** - * @param {object} jsonConfig - * @param {number} treeId - * @returns {Tree} - */ - this.reset = function( jsonConfig, treeId ) { - this.initJsonConfig = jsonConfig; - this.initTreeId = treeId; - - this.id = treeId; - - this.CONFIG = UTIL.extend( Tree.CONFIG, jsonConfig.chart ); - this.drawArea = UTIL.findEl( this.CONFIG.container, true ); - if ( !this.drawArea ) { - throw new Error( 'Failed to find element by selector "'+this.CONFIG.container+'"' ); - } - - UTIL.addClass( this.drawArea, 'Treant' ); - - // kill of any child elements that may be there - this.drawArea.innerHTML = ''; - - this.imageLoader = new ImageLoader(); - - this.nodeDB = new NodeDB( jsonConfig.nodeStructure, this ); - - // key store for storing reference to node connectors, - // key = nodeId where the connector ends - this.connectionStore = {}; - - this.loaded = false; - - this._R = new Raphael( this.drawArea, 100, 100 ); - - return this; - }; - - /** - * @returns {Tree} - */ - this.reload = function() { - this.reset( this.initJsonConfig, this.initTreeId ).redraw(); - return this; - }; - - this.reset( jsonConfig, treeId ); - }; - - Tree.prototype = { - - /** - * @returns {NodeDB} - */ - getNodeDb: function() { - return this.nodeDB; - }, - - /** - * @param {TreeNode} parentTreeNode - * @param {object} nodeDefinition - * @returns {TreeNode} - */ - addNode: function( parentTreeNode, nodeDefinition ) { - var dbEntry = this.nodeDB.get( parentTreeNode.id ); - - this.CONFIG.callback.onBeforeAddNode.apply( this, [parentTreeNode, nodeDefinition] ); - - var oNewNode = this.nodeDB.createNode( nodeDefinition, parentTreeNode.id, this ); - oNewNode.createGeometry( this ); - - oNewNode.parent().createSwitchGeometry( this ); - - this.positionTree(); - - this.CONFIG.callback.onAfterAddNode.apply( this, [oNewNode, parentTreeNode, nodeDefinition] ); - - return oNewNode; - }, - - /** - * @returns {Tree} - */ - redraw: function() { - this.positionTree(); - return this; - }, - - /** - * @param {function} callback - * @returns {Tree} - */ - positionTree: function( callback ) { - var self = this; - - if ( this.imageLoader.isNotLoading() ) { - var root = this.root(), - orient = this.CONFIG.rootOrientation; - - this.resetLevelData(); - - this.firstWalk( root, 0 ); - this.secondWalk( root, 0, 0, 0 ); - - this.positionNodes(); - - if ( this.CONFIG.animateOnInit ) { - setTimeout( - function() { - root.toggleCollapse(); - }, - this.CONFIG.animateOnInitDelay - ); - } - - if ( !this.loaded ) { - UTIL.addClass( this.drawArea, 'Treant-loaded' ); // nodes are hidden until .loaded class is added - if ( Object.prototype.toString.call( callback ) === "[object Function]" ) { - callback( self ); - } - self.CONFIG.callback.onTreeLoaded.apply( self, [root] ); - this.loaded = true; - } - - } - else { - setTimeout( - function() { - self.positionTree( callback ); - }, 10 - ); - } - return this; - }, - - /** - * In a first post-order walk, every node of the tree is assigned a preliminary - * x-coordinate (held in field node->prelim). - * In addition, internal nodes are given modifiers, which will be used to move their - * children to the right (held in field node->modifier). - * @param {TreeNode} node - * @param {number} level - * @returns {Tree} - */ - firstWalk: function( node, level ) { - node.prelim = null; - node.modifier = null; - - this.setNeighbors( node, level ); - this.calcLevelDim( node, level ); - - var leftSibling = node.leftSibling(); - - if ( node.childrenCount() === 0 || level == this.CONFIG.maxDepth ) { - // set preliminary x-coordinate - if ( leftSibling ) { - node.prelim = leftSibling.prelim + leftSibling.size() + this.CONFIG.siblingSeparation; - } - else { - node.prelim = 0; - } - } - else { - //node is not a leaf, firstWalk for each child - for ( var i = 0, n = node.childrenCount(); i < n; i++ ) { - this.firstWalk(node.childAt(i), level + 1); - } - - var midPoint = node.childrenCenter() - node.size() / 2; - - if ( leftSibling ) { - node.prelim = leftSibling.prelim + leftSibling.size() + this.CONFIG.siblingSeparation; - node.modifier = node.prelim - midPoint; - this.apportion( node, level ); - } - else { - node.prelim = midPoint; - } - - // handle stacked children positioning - if ( node.stackParent ) { // handle the parent of stacked children - node.modifier += this.nodeDB.get( node.stackChildren[0] ).size()/2 + node.connStyle.stackIndent; - } - else if ( node.stackParentId ) { // handle stacked children - node.prelim = 0; - } - } - return this; - }, - - /* - * Clean up the positioning of small sibling subtrees. - * Subtrees of a node are formed independently and - * placed as close together as possible. By requiring - * that the subtrees be rigid at the time they are put - * together, we avoid the undesirable effects that can - * accrue from positioning nodes rather than subtrees. - */ - apportion: function (node, level) { - var firstChild = node.firstChild(), - firstChildLeftNeighbor = firstChild.leftNeighbor(), - compareDepth = 1, - depthToStop = this.CONFIG.maxDepth - level; - - while( firstChild && firstChildLeftNeighbor && compareDepth <= depthToStop ) { - // calculate the position of the firstChild, according to the position of firstChildLeftNeighbor - - var modifierSumRight = 0, - modifierSumLeft = 0, - leftAncestor = firstChildLeftNeighbor, - rightAncestor = firstChild; - - for ( var i = 0; i < compareDepth; i++ ) { - leftAncestor = leftAncestor.parent(); - rightAncestor = rightAncestor.parent(); - modifierSumLeft += leftAncestor.modifier; - modifierSumRight += rightAncestor.modifier; - - // all the stacked children are oriented towards right so use right variables - if ( rightAncestor.stackParent !== undefined ) { - modifierSumRight += rightAncestor.size() / 2; - } - } - - // find the gap between two trees and apply it to subTrees - // and mathing smaller gaps to smaller subtrees - - var totalGap = (firstChildLeftNeighbor.prelim + modifierSumLeft + firstChildLeftNeighbor.size() + this.CONFIG.subTeeSeparation) - (firstChild.prelim + modifierSumRight ); - - if ( totalGap > 0 ) { - var subtreeAux = node, - numSubtrees = 0; - - // count all the subtrees in the LeftSibling - while ( subtreeAux && subtreeAux.id !== leftAncestor.id ) { - subtreeAux = subtreeAux.leftSibling(); - numSubtrees++; - } - - if ( subtreeAux ) { - var subtreeMoveAux = node, - singleGap = totalGap / numSubtrees; - - while ( subtreeMoveAux.id !== leftAncestor.id ) { - subtreeMoveAux.prelim += totalGap; - subtreeMoveAux.modifier += totalGap; - - totalGap -= singleGap; - subtreeMoveAux = subtreeMoveAux.leftSibling(); - } - } - } - - compareDepth++; - - firstChild = ( firstChild.childrenCount() === 0 )? - node.leftMost(0, compareDepth): - firstChild = firstChild.firstChild(); - - if ( firstChild ) { - firstChildLeftNeighbor = firstChild.leftNeighbor(); - } - } - }, - - /* - * During a second pre-order walk, each node is given a - * final x-coordinate by summing its preliminary - * x-coordinate and the modifiers of all the node's - * ancestors. The y-coordinate depends on the height of - * the tree. (The roles of x and y are reversed for - * RootOrientations of EAST or WEST.) - */ - secondWalk: function( node, level, X, Y ) { - if ( level <= this.CONFIG.maxDepth ) { - var xTmp = node.prelim + X, - yTmp = Y, align = this.CONFIG.nodeAlign, - orient = this.CONFIG.rootOrientation, - levelHeight, nodesizeTmp; - - if (orient === 'NORTH' || orient === 'SOUTH') { - levelHeight = this.levelMaxDim[level].height; - nodesizeTmp = node.height; - if (node.pseudo) { - node.height = levelHeight; - } // assign a new size to pseudo nodes - } - else if (orient === 'WEST' || orient === 'EAST') { - levelHeight = this.levelMaxDim[level].width; - nodesizeTmp = node.width; - if (node.pseudo) { - node.width = levelHeight; - } // assign a new size to pseudo nodes - } - - node.X = xTmp; - - if (node.pseudo) { // pseudo nodes need to be properly aligned, otherwise position is not correct in some examples - if (orient === 'NORTH' || orient === 'WEST') { - node.Y = yTmp; // align "BOTTOM" - } - else if (orient === 'SOUTH' || orient === 'EAST') { - node.Y = (yTmp + (levelHeight - nodesizeTmp)); // align "TOP" - } - - } else { - node.Y = ( align === 'CENTER' ) ? (yTmp + (levelHeight - nodesizeTmp) / 2) : - ( align === 'TOP' ) ? (yTmp + (levelHeight - nodesizeTmp)) : - yTmp; - } - - if ( orient === 'WEST' || orient === 'EAST' ) { - var swapTmp = node.X; - node.X = node.Y; - node.Y = swapTmp; - } - - if (orient === 'SOUTH' ) { - node.Y = -node.Y - nodesizeTmp; - } - else if ( orient === 'EAST' ) { - node.X = -node.X - nodesizeTmp; - } - - if ( node.childrenCount() !== 0 ) { - if ( node.id === 0 && this.CONFIG.hideRootNode ) { - // ako je root node Hiden onda nemoj njegovu dijecu pomaknut po Y osi za Level separation, neka ona budu na vrhu - this.secondWalk(node.firstChild(), level + 1, X + node.modifier, Y); - } - else { - this.secondWalk(node.firstChild(), level + 1, X + node.modifier, Y + levelHeight + this.CONFIG.levelSeparation); - } - } - - if ( node.rightSibling() ) { - this.secondWalk( node.rightSibling(), level, X, Y ); - } - } - }, - - /** - * position all the nodes, center the tree in center of its container - * 0,0 coordinate is in the upper left corner - * @returns {Tree} - */ - positionNodes: function() { - var self = this, - treeSize = { - x: self.nodeDB.getMinMaxCoord('X', null, null), - y: self.nodeDB.getMinMaxCoord('Y', null, null) - }, - - treeWidth = treeSize.x.max - treeSize.x.min, - treeHeight = treeSize.y.max - treeSize.y.min, - - treeCenter = { - x: treeSize.x.max - treeWidth/2, - y: treeSize.y.max - treeHeight/2 - }; - - this.handleOverflow(treeWidth, treeHeight); - - var - containerCenter = { - x: self.drawArea.clientWidth/2, - y: self.drawArea.clientHeight/2 - }, - - deltaX = containerCenter.x - treeCenter.x, - deltaY = containerCenter.y - treeCenter.y, - - // all nodes must have positive X or Y coordinates, handle this with offsets - negOffsetX = ((treeSize.x.min + deltaX) <= 0) ? Math.abs(treeSize.x.min) : 0, - negOffsetY = ((treeSize.y.min + deltaY) <= 0) ? Math.abs(treeSize.y.min) : 0, - i, len, node; - - // position all the nodes - for ( i = 0, len = this.nodeDB.db.length; i < len; i++ ) { - - node = this.nodeDB.get(i); - - self.CONFIG.callback.onBeforePositionNode.apply( self, [node, i, containerCenter, treeCenter] ); - - if ( node.id === 0 && this.CONFIG.hideRootNode ) { - self.CONFIG.callback.onAfterPositionNode.apply( self, [node, i, containerCenter, treeCenter] ); - continue; - } - - // if the tree is smaller than the draw area, then center the tree within drawing area - node.X += negOffsetX + ((treeWidth < this.drawArea.clientWidth) ? deltaX : this.CONFIG.padding); - node.Y += negOffsetY + ((treeHeight < this.drawArea.clientHeight) ? deltaY : this.CONFIG.padding); - - var collapsedParent = node.collapsedParent(), - hidePoint = null; - - if (collapsedParent) { - // position the node behind the connector point of the parent, so future animations can be visible - hidePoint = collapsedParent.connectorPoint( true ); - node.hide(hidePoint); - - } - else if (node.positioned) { - // node is already positioned, - node.show(); - } - else { // inicijalno stvaranje nodeova, postavi lokaciju - node.nodeDOM.style.left = node.X + 'px'; - node.nodeDOM.style.top = node.Y + 'px'; - node.positioned = true; - } - - if (node.id !== 0 && !(node.parent().id === 0 && this.CONFIG.hideRootNode)) { - this.setConnectionToParent(node, hidePoint); // skip the root node - } - else if (!this.CONFIG.hideRootNode && node.drawLineThrough) { - // drawlinethrough is performed for for the root node also - node.drawLineThroughMe(); - } - - self.CONFIG.callback.onAfterPositionNode.apply( self, [node, i, containerCenter, treeCenter] ); - } - return this; - }, - - /** - * Create Raphael instance, (optionally set scroll bars if necessary) - * @param {number} treeWidth - * @param {number} treeHeight - * @returns {Tree} - */ - handleOverflow: function( treeWidth, treeHeight ) { - var viewWidth = (treeWidth < this.drawArea.clientWidth) ? this.drawArea.clientWidth : treeWidth + this.CONFIG.padding*2, - viewHeight = (treeHeight < this.drawArea.clientHeight) ? this.drawArea.clientHeight : treeHeight + this.CONFIG.padding*2; - - this._R.setSize( viewWidth, viewHeight ); - - if ( this.CONFIG.scrollbar === 'resize') { - UTIL.setDimensions( this.drawArea, viewWidth, viewHeight ); - } - else if ( !UTIL.isjQueryAvailable() || this.CONFIG.scrollbar === 'native' ) { - - if ( this.drawArea.clientWidth < treeWidth ) { // is overflow-x necessary - this.drawArea.style.overflowX = "auto"; - } - - if ( this.drawArea.clientHeight < treeHeight ) { // is overflow-y necessary - this.drawArea.style.overflowY = "auto"; - } - } - // Fancy scrollbar relies heavily on jQuery, so guarding with if ( $ ) - else if ( this.CONFIG.scrollbar === 'fancy') { - var jq_drawArea = $( this.drawArea ); - if (jq_drawArea.hasClass('ps-container')) { // znaci da je 'fancy' vec inicijaliziran, treba updateat - jq_drawArea.find('.Treant').css({ - width: viewWidth, - height: viewHeight - }); - - jq_drawArea.perfectScrollbar('update'); - } - else { - var mainContainer = jq_drawArea.wrapInner('
'), - child = mainContainer.find('.Treant'); - - child.css({ - width: viewWidth, - height: viewHeight - }); - - mainContainer.perfectScrollbar(); - } - } // else this.CONFIG.scrollbar == 'None' - - return this; - }, - /** - * @param {TreeNode} treeNode - * @param {boolean} hidePoint - * @returns {Tree} - */ - setConnectionToParent: function( treeNode, hidePoint ) { - var stacked = treeNode.stackParentId, - connLine, - parent = ( stacked? this.nodeDB.get( stacked ): treeNode.parent() ), - - pathString = hidePoint? - this.getPointPathString(hidePoint): - this.getPathString(parent, treeNode, stacked); - - if ( this.connectionStore[treeNode.id] ) { - // connector already exists, update the connector geometry - connLine = this.connectionStore[treeNode.id]; - this.animatePath( connLine, pathString ); - } - else { - connLine = this._R.path( pathString ); - this.connectionStore[treeNode.id] = connLine; - - // don't show connector arrows por pseudo nodes - if ( treeNode.pseudo ) { - delete parent.connStyle.style['arrow-end']; - } - if ( parent.pseudo ) { - delete parent.connStyle.style['arrow-start']; - } - - connLine.attr( parent.connStyle.style ); - - if ( treeNode.drawLineThrough || treeNode.pseudo ) { - treeNode.drawLineThroughMe( hidePoint ); - } - } - treeNode.connector = connLine; - return this; - }, - - /** - * Create the path which is represented as a point, used for hiding the connection - * A path with a leading "_" indicates the path will be hidden - * See: http://dmitrybaranovskiy.github.io/raphael/reference.html#Paper.path - * @param {object} hidePoint - * @returns {string} - */ - getPointPathString: function( hidePoint ) { - return ["_M", hidePoint.x, ",", hidePoint.y, 'L', hidePoint.x, ",", hidePoint.y, hidePoint.x, ",", hidePoint.y].join(' '); - }, - - /** - * This method relied on receiving a valid Raphael Paper.path. - * See: http://dmitrybaranovskiy.github.io/raphael/reference.html#Paper.path - * A pathString is typically in the format of "M10,20L30,40" - * @param path - * @param {string} pathString - * @returns {Tree} - */ - animatePath: function( path, pathString ) { - if (path.hidden && pathString.charAt(0) !== "_") { // path will be shown, so show it - path.show(); - path.hidden = false; - } - - // See: http://dmitrybaranovskiy.github.io/raphael/reference.html#Element.animate - path.animate( - { - path: pathString.charAt(0) === "_"? - pathString.substring(1): - pathString // remove the "_" prefix if it exists - }, - this.CONFIG.animation.connectorsSpeed, - this.CONFIG.animation.connectorsAnimation, - function() { - if ( pathString.charAt(0) === "_" ) { // animation is hiding the path, hide it at the and of animation - path.hide(); - path.hidden = true; - } - } - ); - return this; - }, - - /** - * - * @param {TreeNode} from_node - * @param {TreeNode} to_node - * @param {boolean} stacked - * @returns {string} - */ - getPathString: function( from_node, to_node, stacked ) { - var startPoint = from_node.connectorPoint( true ), - endPoint = to_node.connectorPoint( false ), - orientation = this.CONFIG.rootOrientation, - connType = from_node.connStyle.type, - P1 = {}, P2 = {}; - - if ( orientation === 'NORTH' || orientation === 'SOUTH' ) { - P1.y = P2.y = (startPoint.y + endPoint.y) / 2; - - P1.x = startPoint.x; - P2.x = endPoint.x; - } - else if ( orientation === 'EAST' || orientation === 'WEST' ) { - P1.x = P2.x = (startPoint.x + endPoint.x) / 2; - - P1.y = startPoint.y; - P2.y = endPoint.y; - } - - // sp, p1, pm, p2, ep == "x,y" - var sp = startPoint.x+','+startPoint.y, p1 = P1.x+','+P1.y, p2 = P2.x+','+P2.y, ep = endPoint.x+','+endPoint.y, - pm = (P1.x + P2.x)/2 +','+ (P1.y + P2.y)/2, pathString, stackPoint; - - if ( stacked ) { // STACKED CHILDREN - - stackPoint = (orientation === 'EAST' || orientation === 'WEST')? - endPoint.x+','+startPoint.y: - startPoint.x+','+endPoint.y; - - if ( connType === "step" || connType === "straight" ) { - pathString = ["M", sp, 'L', stackPoint, 'L', ep]; - } - else if ( connType === "curve" || connType === "bCurve" ) { - var helpPoint, // used for nicer curve lines - indent = from_node.connStyle.stackIndent; - - if ( orientation === 'NORTH' ) { - helpPoint = (endPoint.x - indent)+','+(endPoint.y - indent); - } - else if ( orientation === 'SOUTH' ) { - helpPoint = (endPoint.x - indent)+','+(endPoint.y + indent); - } - else if ( orientation === 'EAST' ) { - helpPoint = (endPoint.x + indent) +','+startPoint.y; - } - else if ( orientation === 'WEST' ) { - helpPoint = (endPoint.x - indent) +','+startPoint.y; - } - pathString = ["M", sp, 'L', helpPoint, 'S', stackPoint, ep]; - } - - } - else { // NORMAL CHILDREN - if ( connType === "step" ) { - pathString = ["M", sp, 'L', p1, 'L', p2, 'L', ep]; - } - else if ( connType === "curve" ) { - pathString = ["M", sp, 'C', p1, p2, ep ]; - } - else if ( connType === "bCurve" ) { - pathString = ["M", sp, 'Q', p1, pm, 'T', ep]; - } - else if (connType === "straight" ) { - pathString = ["M", sp, 'L', sp, ep]; - } - } - - return pathString.join(" "); - }, - - /** - * Algorithm works from left to right, so previous processed node will be left neighbour of the next node - * @param {TreeNode} node - * @param {number} level - * @returns {Tree} - */ - setNeighbors: function( node, level ) { - node.leftNeighborId = this.lastNodeOnLevel[level]; - if ( node.leftNeighborId ) { - node.leftNeighbor().rightNeighborId = node.id; - } - this.lastNodeOnLevel[level] = node.id; - return this; - }, - - /** - * Used for calculation of height and width of a level (level dimensions) - * @param {TreeNode} node - * @param {number} level - * @returns {Tree} - */ - calcLevelDim: function( node, level ) { // root node is on level 0 - this.levelMaxDim[level] = { - width: Math.max( this.levelMaxDim[level]? this.levelMaxDim[level].width: 0, node.width ), - height: Math.max( this.levelMaxDim[level]? this.levelMaxDim[level].height: 0, node.height ) - }; - return this; - }, - - /** - * @returns {Tree} - */ - resetLevelData: function() { - this.lastNodeOnLevel = []; - this.levelMaxDim = []; - return this; - }, - - /** - * @returns {TreeNode} - */ - root: function() { - return this.nodeDB.get( 0 ); - } - }; - - /** - * NodeDB is used for storing the nodes. Each tree has its own NodeDB. - * @param {object} nodeStructure - * @param {Tree} tree - * @constructor - */ - var NodeDB = function ( nodeStructure, tree ) { - this.reset( nodeStructure, tree ); - }; - - NodeDB.prototype = { - - /** - * @param {object} nodeStructure - * @param {Tree} tree - * @returns {NodeDB} - */ - reset: function( nodeStructure, tree ) { - - this.db = []; - - var self = this; - - /** - * @param {object} node - * @param {number} parentId - */ - function iterateChildren( node, parentId ) { - var newNode = self.createNode( node, parentId, tree, null ); - - if ( node.children ) { - // pseudo node is used for descending children to the next level - if ( node.childrenDropLevel && node.childrenDropLevel > 0 ) { - while ( node.childrenDropLevel-- ) { - // pseudo node needs to inherit the connection style from its parent for continuous connectors - var connStyle = UTIL.cloneObj( newNode.connStyle ); - newNode = self.createNode( 'pseudo', newNode.id, tree, null ); - newNode.connStyle = connStyle; - newNode.children = []; - } - } - - var stack = ( node.stackChildren && !self.hasGrandChildren( node ) )? newNode.id: null; - - // children are positioned on separate levels, one beneath the other - if ( stack !== null ) { - newNode.stackChildren = []; - } - - for ( var i = 0, len = node.children.length; i < len ; i++ ) { - if ( stack !== null ) { - newNode = self.createNode( node.children[i], newNode.id, tree, stack ); - if ( ( i + 1 ) < len ) { - // last node cant have children - newNode.children = []; - } - } - else { - iterateChildren( node.children[i], newNode.id ); - } - } - } - } - - if ( tree.CONFIG.animateOnInit ) { - nodeStructure.collapsed = true; - } - - iterateChildren( nodeStructure, -1 ); // root node - - this.createGeometries( tree ); - - return this; - }, - - /** - * @param {Tree} tree - * @returns {NodeDB} - */ - createGeometries: function( tree ) { - var i = this.db.length; - - while ( i-- ) { - this.get( i ).createGeometry( tree ); - } - return this; - }, - - /** - * @param {number} nodeId - * @returns {TreeNode} - */ - get: function ( nodeId ) { - return this.db[nodeId]; // get TreeNode by ID - }, - - /** - * @param {function} callback - * @returns {NodeDB} - */ - walk: function( callback ) { - var i = this.db.length; - - while ( i-- ) { - callback.apply( this, [ this.get( i ) ] ); - } - return this; - }, - - /** - * - * @param {object} nodeStructure - * @param {number} parentId - * @param {Tree} tree - * @param {number} stackParentId - * @returns {TreeNode} - */ - createNode: function( nodeStructure, parentId, tree, stackParentId ) { - var node = new TreeNode( nodeStructure, this.db.length, parentId, tree, stackParentId ); - - this.db.push( node ); - - // skip root node (0) - if ( parentId >= 0 ) { - var parent = this.get( parentId ); - - // todo: refactor into separate private method - if ( nodeStructure.position ) { - if ( nodeStructure.position === 'left' ) { - parent.children.push( node.id ); - } - else if ( nodeStructure.position === 'right' ) { - parent.children.splice( 0, 0, node.id ); - } - else if ( nodeStructure.position === 'center' ) { - parent.children.splice( Math.floor( parent.children.length / 2 ), 0, node.id ); - } - else { - // edge case when there's only 1 child - var position = parseInt( nodeStructure.position ); - if ( parent.children.length === 1 && position > 0 ) { - parent.children.splice( 0, 0, node.id ); - } - else { - parent.children.splice( - Math.max( position, parent.children.length - 1 ), - 0, node.id - ); - } - } - } - else { - parent.children.push( node.id ); - } - } - - if ( stackParentId ) { - this.get( stackParentId ).stackParent = true; - this.get( stackParentId ).stackChildren.push( node.id ); - } - - return node; - }, - - getMinMaxCoord: function( dim, parent, MinMax ) { // used for getting the dimensions of the tree, dim = 'X' || 'Y' - // looks for min and max (X and Y) within the set of nodes - parent = parent || this.get(0); - - MinMax = MinMax || { // start with root node dimensions - min: parent[dim], - max: parent[dim] + ( ( dim === 'X' )? parent.width: parent.height ) - }; - - var i = parent.childrenCount(); - - while ( i-- ) { - var node = parent.childAt( i ), - maxTest = node[dim] + ( ( dim === 'X' )? node.width: node.height ), - minTest = node[dim]; - - if ( maxTest > MinMax.max ) { - MinMax.max = maxTest; - } - if ( minTest < MinMax.min ) { - MinMax.min = minTest; - } - - this.getMinMaxCoord( dim, node, MinMax ); - } - return MinMax; - }, - - /** - * @param {object} nodeStructure - * @returns {boolean} - */ - hasGrandChildren: function( nodeStructure ) { - var i = nodeStructure.children.length; - while ( i-- ) { - if ( nodeStructure.children[i].children ) { - return true; - } - } - return false; - } - }; - - /** - * TreeNode constructor. - * @param {object} nodeStructure - * @param {number} id - * @param {number} parentId - * @param {Tree} tree - * @param {number} stackParentId - * @constructor - */ - var TreeNode = function( nodeStructure, id, parentId, tree, stackParentId ) { - this.reset( nodeStructure, id, parentId, tree, stackParentId ); - }; - - TreeNode.prototype = { - - /** - * @param {object} nodeStructure - * @param {number} id - * @param {number} parentId - * @param {Tree} tree - * @param {number} stackParentId - * @returns {TreeNode} - */ - reset: function( nodeStructure, id, parentId, tree, stackParentId ) { - this.id = id; - this.parentId = parentId; - this.treeId = tree.id; - - this.prelim = 0; - this.modifier = 0; - this.leftNeighborId = null; - - this.stackParentId = stackParentId; - - // pseudo node is a node with width=height=0, it is invisible, but necessary for the correct positioning of the tree - this.pseudo = nodeStructure === 'pseudo' || nodeStructure['pseudo']; // todo: surely if nodeStructure is a scalar then the rest will error: - - this.meta = nodeStructure.meta || {}; - this.image = nodeStructure.image || null; - - this.link = UTIL.createMerge( tree.CONFIG.node.link, nodeStructure.link ); - - this.connStyle = UTIL.createMerge( tree.CONFIG.connectors, nodeStructure.connectors ); - this.connector = null; - - this.drawLineThrough = nodeStructure.drawLineThrough === false ? false : ( nodeStructure.drawLineThrough || tree.CONFIG.node.drawLineThrough ); - - this.collapsable = nodeStructure.collapsable === false ? false : ( nodeStructure.collapsable || tree.CONFIG.node.collapsable ); - this.collapsed = nodeStructure.collapsed; - - this.text = nodeStructure.text; - - // '.node' DIV - this.nodeInnerHTML = nodeStructure.innerHTML; - this.nodeHTMLclass = (tree.CONFIG.node.HTMLclass ? tree.CONFIG.node.HTMLclass : '') + // globally defined class for the nodex - (nodeStructure.HTMLclass ? (' ' + nodeStructure.HTMLclass) : ''); // + specific node class - - this.nodeHTMLid = nodeStructure.HTMLid; - - this.children = []; - - return this; - }, - - /** - * @returns {Tree} - */ - getTree: function() { - return TreeStore.get( this.treeId ); - }, - - /** - * @returns {object} - */ - getTreeConfig: function() { - return this.getTree().CONFIG; - }, - - /** - * @returns {NodeDB} - */ - getTreeNodeDb: function() { - return this.getTree().getNodeDb(); - }, - - /** - * @param {number} nodeId - * @returns {TreeNode} - */ - lookupNode: function( nodeId ) { - return this.getTreeNodeDb().get( nodeId ); - }, - - /** - * @returns {Tree} - */ - Tree: function() { - return TreeStore.get( this.treeId ); - }, - - /** - * @param {number} nodeId - * @returns {TreeNode} - */ - dbGet: function( nodeId ) { - return this.getTreeNodeDb().get( nodeId ); - }, - - /** - * Returns the width of the node - * @returns {float} - */ - size: function() { - var orientation = this.getTreeConfig().rootOrientation; - - if ( this.pseudo ) { - // prevents separating the subtrees - return ( -this.getTreeConfig().subTeeSeparation ); - } - - if ( orientation === 'NORTH' || orientation === 'SOUTH' ) { - return this.width; - } - else if ( orientation === 'WEST' || orientation === 'EAST' ) { - return this.height; - } - }, - - /** - * @returns {number} - */ - childrenCount: function () { - return ( ( this.collapsed || !this.children)? 0: this.children.length ); - }, - - /** - * @param {number} index - * @returns {TreeNode} - */ - childAt: function( index ) { - return this.dbGet( this.children[index] ); - }, - - /** - * @returns {TreeNode} - */ - firstChild: function() { - return this.childAt( 0 ); - }, - - /** - * @returns {TreeNode} - */ - lastChild: function() { - return this.childAt( this.children.length - 1 ); - }, - - /** - * @returns {TreeNode} - */ - parent: function() { - return this.lookupNode( this.parentId ); - }, - - /** - * @returns {TreeNode} - */ - leftNeighbor: function() { - if ( this.leftNeighborId ) { - return this.lookupNode( this.leftNeighborId ); - } - }, - - /** - * @returns {TreeNode} - */ - rightNeighbor: function() { - if ( this.rightNeighborId ) { - return this.lookupNode( this.rightNeighborId ); - } - }, - - /** - * @returns {TreeNode} - */ - leftSibling: function () { - var leftNeighbor = this.leftNeighbor(); - - if ( leftNeighbor && leftNeighbor.parentId === this.parentId ){ - return leftNeighbor; - } - }, - - /** - * @returns {TreeNode} - */ - rightSibling: function () { - var rightNeighbor = this.rightNeighbor(); - - if ( rightNeighbor && rightNeighbor.parentId === this.parentId ) { - return rightNeighbor; - } - }, - - /** - * @returns {number} - */ - childrenCenter: function () { - var first = this.firstChild(), - last = this.lastChild(); - - return ( first.prelim + ((last.prelim - first.prelim) + last.size()) / 2 ); - }, - - /** - * Find out if one of the node ancestors is collapsed - * @returns {*} - */ - collapsedParent: function() { - var parent = this.parent(); - if ( !parent ) { - return false; - } - if ( parent.collapsed ) { - return parent; - } - return parent.collapsedParent(); - }, - - /** - * Returns the leftmost child at specific level, (initial level = 0) - * @param level - * @param depth - * @returns {*} - */ - leftMost: function ( level, depth ) { - if ( level >= depth ) { - return this; - } - if ( this.childrenCount() === 0 ) { - return; - } - - for ( var i = 0, n = this.childrenCount(); i < n; i++ ) { - var leftmostDescendant = this.childAt( i ).leftMost( level + 1, depth ); - if ( leftmostDescendant ) { - return leftmostDescendant; - } - } - }, - - // returns start or the end point of the connector line, origin is upper-left - connectorPoint: function(startPoint) { - var orient = this.Tree().CONFIG.rootOrientation, point = {}; - - if ( this.stackParentId ) { // return different end point if node is a stacked child - if ( orient === 'NORTH' || orient === 'SOUTH' ) { - orient = 'WEST'; - } - else if ( orient === 'EAST' || orient === 'WEST' ) { - orient = 'NORTH'; - } - } - - // if pseudo, a virtual center is used - if ( orient === 'NORTH' ) { - point.x = (this.pseudo) ? this.X - this.Tree().CONFIG.subTeeSeparation/2 : this.X + this.width/2; - point.y = (startPoint) ? this.Y + this.height : this.Y; - } - else if (orient === 'SOUTH') { - point.x = (this.pseudo) ? this.X - this.Tree().CONFIG.subTeeSeparation/2 : this.X + this.width/2; - point.y = (startPoint) ? this.Y : this.Y + this.height; - } - else if (orient === 'EAST') { - point.x = (startPoint) ? this.X : this.X + this.width; - point.y = (this.pseudo) ? this.Y - this.Tree().CONFIG.subTeeSeparation/2 : this.Y + this.height/2; - } - else if (orient === 'WEST') { - point.x = (startPoint) ? this.X + this.width : this.X; - point.y = (this.pseudo) ? this.Y - this.Tree().CONFIG.subTeeSeparation/2 : this.Y + this.height/2; - } - return point; - }, - - /** - * @returns {string} - */ - pathStringThrough: function() { // get the geometry of a path going through the node - var startPoint = this.connectorPoint( true ), - endPoint = this.connectorPoint( false ); - - return ["M", startPoint.x+","+startPoint.y, 'L', endPoint.x+","+endPoint.y].join(" "); - }, - - /** - * @param {object} hidePoint - */ - drawLineThroughMe: function( hidePoint ) { // hidepoint se proslijedjuje ako je node sakriven zbog collapsed - var pathString = hidePoint? - this.Tree().getPointPathString( hidePoint ): - this.pathStringThrough(); - - this.lineThroughMe = this.lineThroughMe || this.Tree()._R.path(pathString); - - var line_style = UTIL.cloneObj( this.connStyle.style ); - - delete line_style['arrow-start']; - delete line_style['arrow-end']; - - this.lineThroughMe.attr( line_style ); - - if ( hidePoint ) { - this.lineThroughMe.hide(); - this.lineThroughMe.hidden = true; - } - }, - - addSwitchEvent: function( nodeSwitch ) { - var self = this; - UTIL.addEvent( nodeSwitch, 'click', - function( e ) { - e.preventDefault(); - if ( self.getTreeConfig().callback.onBeforeClickCollapseSwitch.apply( self, [ nodeSwitch, e ] ) === false ) { - return false; - } - - self.toggleCollapse(); - - self.getTreeConfig().callback.onAfterClickCollapseSwitch.apply( self, [ nodeSwitch, e ] ); - } - ); - }, - - /** - * @returns {TreeNode} - */ - collapse: function() { - if ( !this.collapsed ) { - this.toggleCollapse(); - } - return this; - }, - - /** - * @returns {TreeNode} - */ - expand: function() { - if ( this.collapsed ) { - this.toggleCollapse(); - } - return this; - }, - - /** - * @returns {TreeNode} - */ - toggleCollapse: function() { - var oTree = this.getTree(); - - if ( !oTree.inAnimation ) { - oTree.inAnimation = true; - - this.collapsed = !this.collapsed; // toggle the collapse at each click - UTIL.toggleClass( this.nodeDOM, 'collapsed', this.collapsed ); - - oTree.positionTree(); - - var self = this; - - setTimeout( - function() { // set the flag after the animation - oTree.inAnimation = false; - oTree.CONFIG.callback.onToggleCollapseFinished.apply( oTree, [ self, self.collapsed ] ); - }, - ( oTree.CONFIG.animation.nodeSpeed > oTree.CONFIG.animation.connectorsSpeed )? - oTree.CONFIG.animation.nodeSpeed: - oTree.CONFIG.animation.connectorsSpeed - ); - } - return this; - }, - - hide: function( collapse_to_point ) { - collapse_to_point = collapse_to_point || false; - - var bCurrentState = this.hidden; - this.hidden = true; - - this.nodeDOM.style.overflow = 'hidden'; - - var tree = this.getTree(), - config = this.getTreeConfig(), - oNewState = { - opacity: 0 - }; - - if ( collapse_to_point ) { - oNewState.left = collapse_to_point.x; - oNewState.top = collapse_to_point.y; - } - - // if parent was hidden in initial configuration, position the node behind the parent without animations - if ( !this.positioned || bCurrentState ) { - this.nodeDOM.style.visibility = 'hidden'; - if ( $ ) { - $( this.nodeDOM ).css( oNewState ); - } - else { - this.nodeDOM.style.left = oNewState.left + 'px'; - this.nodeDOM.style.top = oNewState.top + 'px'; - } - this.positioned = true; - } - else { - // todo: fix flashy bug when a node is manually hidden and tree.redraw is called. - if ( $ ) { - $( this.nodeDOM ).animate( - oNewState, config.animation.nodeSpeed, config.animation.nodeAnimation, - function () { - this.style.visibility = 'hidden'; - } - ); - } - else { - this.nodeDOM.style.transition = 'all '+config.animation.nodeSpeed+'ms ease'; - this.nodeDOM.style.transitionProperty = 'opacity, left, top'; - this.nodeDOM.style.opacity = oNewState.opacity; - this.nodeDOM.style.left = oNewState.left + 'px'; - this.nodeDOM.style.top = oNewState.top + 'px'; - this.nodeDOM.style.visibility = 'hidden'; - } - } - - // animate the line through node if the line exists - if ( this.lineThroughMe ) { - var new_path = tree.getPointPathString( collapse_to_point ); - if ( bCurrentState ) { - // update without animations - this.lineThroughMe.attr( { path: new_path } ); - } - else { - // update with animations - tree.animatePath( this.lineThroughMe, tree.getPointPathString( collapse_to_point ) ); - } - } - - return this; - }, - - /** - * @returns {TreeNode} - */ - hideConnector: function() { - var oTree = this.Tree(); - var oPath = oTree.connectionStore[this.id]; - if ( oPath ) { - oPath.animate( - { 'opacity': 0 }, - oTree.CONFIG.animation.connectorsSpeed, - oTree.CONFIG.animation.connectorsAnimation - ); - } - return this; - }, - - show: function() { - var bCurrentState = this.hidden; - this.hidden = false; - - this.nodeDOM.style.visibility = 'visible'; - - var oTree = this.Tree(); - - var oNewState = { - left: this.X, - top: this.Y, - opacity: 1 - }, - config = this.getTreeConfig(); - - // if the node was hidden, update opacity and position - if ( $ ) { - $( this.nodeDOM ).animate( - oNewState, - config.animation.nodeSpeed, config.animation.nodeAnimation, - function () { - // $.animate applies "overflow:hidden" to the node, remove it to avoid visual problems - this.style.overflow = ""; - } - ); - } - else { - this.nodeDOM.style.transition = 'all '+config.animation.nodeSpeed+'ms ease'; - this.nodeDOM.style.transitionProperty = 'opacity, left, top'; - this.nodeDOM.style.left = oNewState.left + 'px'; - this.nodeDOM.style.top = oNewState.top + 'px'; - this.nodeDOM.style.opacity = oNewState.opacity; - this.nodeDOM.style.overflow = ''; - } - - if ( this.lineThroughMe ) { - this.getTree().animatePath( this.lineThroughMe, this.pathStringThrough() ); - } - - return this; - }, - - /** - * @returns {TreeNode} - */ - showConnector: function() { - var oTree = this.Tree(); - var oPath = oTree.connectionStore[this.id]; - if ( oPath ) { - oPath.animate( - { 'opacity': 1 }, - oTree.CONFIG.animation.connectorsSpeed, - oTree.CONFIG.animation.connectorsAnimation - ); - } - return this; - } - }; - - - /** - * Build a node from the 'text' and 'img' property and return with it. - * - * The node will contain all the fields that present under the 'text' property - * Each field will refer to a css class with name defined as node-{$property_name} - * - * Example: - * The definition: - * - * text: { - * desc: "some description", - * paragraph: "some text" - * } - * - * will generate the following elements: - * - *

some description

- *

some text

- * - * @Returns the configured node - */ - TreeNode.prototype.buildNodeFromText = function (node) { - // IMAGE - if (this.image) { - image = document.createElement('img'); - image.src = this.image; - node.appendChild(image); - } - - // TEXT - if (this.text) { - for (var key in this.text) { - // adding DATA Attributes to the node - if (key.startsWith("data-")) { - node.setAttribute(key, this.text[key]); - } else { - - var textElement = document.createElement(this.text[key].href ? 'a' : 'p'); - - // make an
element if required - if (this.text[key].href) { - textElement.href = this.text[key].href; - if (this.text[key].target) { - textElement.target = this.text[key].target; - } - } - - textElement.className = "node-"+key; - textElement.appendChild(document.createTextNode( - this.text[key].val ? this.text[key].val : - this.text[key] instanceof Object ? "'val' param missing!" : this.text[key] - ) - ); - - node.appendChild(textElement); - } - } - } - return node; - }; - - /** - * Build a node from 'nodeInnerHTML' property that defines an existing HTML element, referenced by it's id, e.g: #someElement - * Change the text in the passed node to 'Wrong ID selector' if the referenced element does ot exist, - * return with a cloned and configured node otherwise - * - * @Returns node the configured node - */ - TreeNode.prototype.buildNodeFromHtml = function(node) { - // get some element by ID and clone its structure into a node - if (this.nodeInnerHTML.charAt(0) === "#") { - var elem = document.getElementById(this.nodeInnerHTML.substring(1)); - if (elem) { - node = elem.cloneNode(true); - node.id += "-clone"; - node.className += " node"; - } - else { - node.innerHTML = " Wrong ID selector "; - } - } - else { - // insert your custom HTML into a node - node.innerHTML = this.nodeInnerHTML; - } - return node; - }; - - /** - * @param {Tree} tree - */ - TreeNode.prototype.createGeometry = function( tree ) { - if ( this.id === 0 && tree.CONFIG.hideRootNode ) { - this.width = 0; - this.height = 0; - return; - } - - var drawArea = tree.drawArea, - image, - - /////////// CREATE NODE ////////////// - node = document.createElement( this.link.href? 'a': 'div' ); - - node.className = ( !this.pseudo )? TreeNode.CONFIG.nodeHTMLclass: 'pseudo'; - if ( this.nodeHTMLclass && !this.pseudo ) { - node.className += ' ' + this.nodeHTMLclass; - } - - if ( this.nodeHTMLid ) { - node.id = this.nodeHTMLid; - } - - if ( this.link.href ) { - node.href = this.link.href; - node.target = this.link.target; - } - - if ( $ ) { - $( node ).data( 'treenode', this ); - } - else { - node.data = { - 'treenode': this - }; - } - - /////////// BUILD NODE CONTENT ////////////// - if ( !this.pseudo ) { - node = this.nodeInnerHTML? this.buildNodeFromHtml(node) : this.buildNodeFromText(node) - - // handle collapse switch - if ( this.collapsed || (this.collapsable && this.childrenCount() && !this.stackParentId) ) { - this.createSwitchGeometry( tree, node ); - } - } - - tree.CONFIG.callback.onCreateNode.apply( tree, [this, node] ); - - /////////// APPEND all ////////////// - drawArea.appendChild(node); - - this.width = node.offsetWidth; - this.height = node.offsetHeight; - - this.nodeDOM = node; - - tree.imageLoader.processNode(this); - }; - - /** - * @param {Tree} tree - * @param {Element} nodeEl - */ - TreeNode.prototype.createSwitchGeometry = function( tree, nodeEl ) { - nodeEl = nodeEl || this.nodeDOM; - - // safe guard and check to see if it has a collapse switch - var nodeSwitchEl = UTIL.findEl( '.collapse-switch', true, nodeEl ); - if ( !nodeSwitchEl ) { - nodeSwitchEl = document.createElement( 'a' ); - nodeSwitchEl.className = "collapse-switch"; - - nodeEl.appendChild( nodeSwitchEl ); - this.addSwitchEvent( nodeSwitchEl ); - if ( this.collapsed ) { - nodeEl.className += " collapsed"; - } - - tree.CONFIG.callback.onCreateNodeCollapseSwitch.apply( tree, [this, nodeEl, nodeSwitchEl] ); - } - return nodeSwitchEl; - }; - - - // ########################################### - // Expose global + default CONFIG params - // ########################################### - - - Tree.CONFIG = { - maxDepth: 100, - rootOrientation: 'NORTH', // NORTH || EAST || WEST || SOUTH - nodeAlign: 'CENTER', // CENTER || TOP || BOTTOM - levelSeparation: 30, - siblingSeparation: 30, - subTeeSeparation: 30, - - hideRootNode: false, - - animateOnInit: false, - animateOnInitDelay: 500, - - padding: 15, // the difference is seen only when the scrollbar is shown - scrollbar: 'native', // "native" || "fancy" || "None" (PS: "fancy" requires jquery and perfect-scrollbar) - - connectors: { - type: 'curve', // 'curve' || 'step' || 'straight' || 'bCurve' - style: { - stroke: 'black' - }, - stackIndent: 15 - }, - - node: { // each node inherits this, it can all be overridden in node config - - // HTMLclass: 'node', - // drawLineThrough: false, - // collapsable: false, - link: { - target: '_self' - } - }, - - animation: { // each node inherits this, it can all be overridden in node config - nodeSpeed: 450, - nodeAnimation: 'linear', - connectorsSpeed: 450, - connectorsAnimation: 'linear' - }, - - callback: { - onCreateNode: function( treeNode, treeNodeDom ) {}, // this = Tree - onCreateNodeCollapseSwitch: function( treeNode, treeNodeDom, switchDom ) {}, // this = Tree - onAfterAddNode: function( newTreeNode, parentTreeNode, nodeStructure ) {}, // this = Tree - onBeforeAddNode: function( parentTreeNode, nodeStructure ) {}, // this = Tree - onAfterPositionNode: function( treeNode, nodeDbIndex, containerCenter, treeCenter) {}, // this = Tree - onBeforePositionNode: function( treeNode, nodeDbIndex, containerCenter, treeCenter) {}, // this = Tree - onToggleCollapseFinished: function ( treeNode, bIsCollapsed ) {}, // this = Tree - onAfterClickCollapseSwitch: function( nodeSwitch, event ) {}, // this = TreeNode - onBeforeClickCollapseSwitch: function( nodeSwitch, event ) {}, // this = TreeNode - onTreeLoaded: function( rootTreeNode ) {} // this = Tree - } - }; - - TreeNode.CONFIG = { - nodeHTMLclass: 'node' - }; - - // ############################################# - // Makes a JSON chart config out of Array config - // ############################################# - - var JSONconfig = { - make: function( configArray ) { - - var i = configArray.length, node; - - this.jsonStructure = { - chart: null, - nodeStructure: null - }; - //fist loop: find config, find root; - while(i--) { - node = configArray[i]; - if (node.hasOwnProperty('container')) { - this.jsonStructure.chart = node; - continue; - } - - if (!node.hasOwnProperty('parent') && ! node.hasOwnProperty('container')) { - this.jsonStructure.nodeStructure = node; - node._json_id = 0; - } - } - - this.findChildren(configArray); - - return this.jsonStructure; - }, - - findChildren: function(nodes) { - var parents = [0]; // start with a a root node - - while(parents.length) { - var parentId = parents.pop(), - parent = this.findNode(this.jsonStructure.nodeStructure, parentId), - i = 0, len = nodes.length, - children = []; - - for(;i Date: Thu, 26 Jun 2025 15:32:59 +0200 Subject: [PATCH 54/55] Updated gitignore and README --- .gitignore | 1 - README.md | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index b15e372d..762a39c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ __pycache__ build/ dist/ -src/qlever/evaluation/output/ *.egg-info *.swp diff --git a/README.md b/README.md index bb0765b5..20653950 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,12 @@ activate autocompletion for all its commands and options (it's very easy, but `pip` cannot do that automatically). ``` -qlever setup-config olympics # Get Qleverfile (config file) for this dataset -qlever get-data # Download the dataset -qlever index # Build index data structures for this dataset -qlever start # Start a QLever server using that index -qlever example-queries # Launch some example queries -qlever ui # Launch the QLever UI +qlever setup-config olympics # Get Qleverfile (config file) for this dataset +qlever get-data # Download the dataset +qlever index # Build index data structures for this dataset +qlever start # Start a QLever server using that index +qlever benchmark-queries --example-queries # Launch some example queries +qlever ui # Launch the QLever UI ``` This will create a SPARQL endpoint for the [120 Years of From 9f35c362c2b3f02644625470af1f48d6d655e12c Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Wed, 9 Jul 2025 18:06:24 +0200 Subject: [PATCH 55/55] Use `qlever query` in README --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 20653950..462d49c1 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,12 @@ activate autocompletion for all its commands and options (it's very easy, but `pip` cannot do that automatically). ``` -qlever setup-config olympics # Get Qleverfile (config file) for this dataset -qlever get-data # Download the dataset -qlever index # Build index data structures for this dataset -qlever start # Start a QLever server using that index -qlever benchmark-queries --example-queries # Launch some example queries -qlever ui # Launch the QLever UI +qlever setup-config olympics # Get Qleverfile (config file) for this dataset +qlever get-data # Download the dataset +qlever index # Build index data structures for this dataset +qlever start # Start a QLever server using that index +qlever query # Launch an example query +qlever ui # Launch the QLever UI ``` This will create a SPARQL endpoint for the [120 Years of