From 2292c8f144bf52dd2a68b8782eca889841899bdb Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Thu, 5 Jun 2025 14:26:42 +0200 Subject: [PATCH 01/58] `example-queries` from add-evaluation-web-app branch --- src/qlever/commands/example_queries.py | 671 ++++++++++++++++++++----- 1 file changed, 535 insertions(+), 136 deletions(-) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/example_queries.py index 01e5fc98..ae8c44b3 100644 --- a/src/qlever/commands/example_queries.py +++ b/src/qlever/commands/example_queries.py @@ -1,31 +1,41 @@ 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 +import rdflib +import yaml 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 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 @@ -50,10 +60,25 @@ 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, + default=None, + help=( + "Path to a TSV file containing queries " + "(query_description, full_sparql_query)" + ), + ) + subparser.add_argument( + "--queries-yml", type=str, - help="Command to get example queries as TSV " - "(description, query)", + 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 " + "description and 'sparql' for the full SPARQL query." + ), ) subparser.add_argument( "--query-ids", @@ -116,8 +141,8 @@ def additional_arguments(self, subparser) -> None: subparser.add_argument( "--width-error-message", type=int, - default=80, - help="Width for printing the error message " "(0 = no limit)", + default=50, + help="Width for printing the error message (0 = no limit)", ) subparser.add_argument( "--width-result-size", @@ -144,6 +169,34 @@ def additional_arguments(self, subparser) -> None: default=False, help="When showing the query, also show the prefixes", ) + subparser.add_argument( + "--results-dir", + type=str, + default=".", + help=( + "The directory where the YML result file would be saved " + "for the evaluation web app (Default = current working directory)" + ), + ) + subparser.add_argument( + "--result-file", + 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" + ), + ) + 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)" + ), + ) def pretty_printed_query(self, query: str, show_prefixes: bool) -> str: remove_prefixes_cmd = ( @@ -159,10 +212,9 @@ 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( - "Failed to pretty-print query, " - "returning original query: {e}" + f"Failed to pretty-print query, returning original query: {e}" ) return query.rstrip() @@ -175,18 +227,268 @@ def sparql_query_type(self, query: str) -> str: else: return "UNKNOWN" + @staticmethod + 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) + """ + 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 [] + + # Validate the structure + if not isinstance(data, dict) or "queries" not in data: + log.error( + "Error: YAML file must contain a top-level 'queries' key" + ) + return [] + + if not isinstance(data["queries"], list): + log.error("Error: 'queries' key in YML file must hold a list.") + return [] + + for item in data["queries"]: + if ( + not isinstance(item, dict) + or "query" not in item + or "sparql" not in item + ): + log.error( + "Error: Each item in 'queries' must contain " + "'query' and 'sparql' keys." + ) + 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 [] + + def get_result_size( + self, + count_only: bool, + query_type: str, + accept_header: str, + result_file: str, + ) -> tuple[int, int | None, 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 + """ + + 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 + ) + 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`. if args.remove_offset_and_limit and args.limit: 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" + ) + 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`. - 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( @@ -200,7 +502,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 @@ -214,6 +516,8 @@ def execute(self, args) -> bool: not args.sparql_endpoint or args.sparql_endpoint.startswith("https://qlever") ) + if engine is not None: + is_qlever = is_qlever or "qlever" in engine.lower() if args.clear_cache == "yes": if is_qlever: log.warning( @@ -229,22 +533,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:" @@ -255,17 +557,21 @@ 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 + tsv_queries = ( + self.parse_queries_yml( + args.queries_yml, args.query_ids, args.query_regex ) - 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}") + if args.queries_yml + else self.parse_queries_tsv( + args.queries_tsv, + args.query_ids, + args.query_regex, + args.ui_config, + ) + ) + + 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 @@ -279,14 +585,15 @@ def execute(self, args) -> bool: # processing time (seconds). query_times = [] result_sizes = [] + 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": @@ -372,19 +679,18 @@ def execute(self, args) -> bool: accept_header = "application/sparql-results+json" # 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." - f"{abs(hash(curl_cmd))}.tmp" - ) - start_time = time.time() http_code = run_curl_command( sparql_endpoint, headers={"Accept": accept_header}, @@ -395,6 +701,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( @@ -402,6 +709,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 = { @@ -412,91 +720,39 @@ 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: - 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 - ) - 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 = { - "short": "Malformed JSON", - "long": "curl returned with code 200, " - "but the JSON is malformed: " - + re.sub(r"\s+", " ", str(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' " - 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 = { - "short": "Malformed JSON", - "long": re.sub(r"\s+", " ", str(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 + result_size, single_int_result, error_msg = ( + self.get_result_size( + args.download_or_count == "count", + query_type, + accept_header, + result_file, + ) + ) - # Remove the result file (unless in debug mode). - if args.log_level != "DEBUG": - Path(result_file).unlink(missing_ok=True) + # 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: @@ -553,9 +809,26 @@ def execute(self, args) -> bool: ) log.info("") + # 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) + 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: @@ -592,7 +865,7 @@ def execute(self, args) -> bool: 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( @@ -604,3 +877,129 @@ def execute(self, args) -> bool: # Return success (has nothing to do with how many queries failed). return True + + 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]: + """ + Construct a dictionary with query information for output result yaml file + """ + record = { + "query": query, + "sparql": sparql, + "runtime_info": {}, + } + if result_size is None: + results = f"{result['short']}: {result['long']}" + headers = [] + else: + record["result_size"] = result_size + result_size = ( + max_result_size + if result_size > max_result_size + else result_size + ) + headers, results = self.get_query_results( + result, result_size, accept_header + ) + if accept_header == "application/qlever-results+json": + 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, accept_header: str + ) -> tuple[list[str], list[list[str]]]: + """ + 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" + 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() + 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}]}}' " + 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 = [] + 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": + 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 = rdflib.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_records_to_result_file( + query_data: dict[str, list[dict[str, Any]]], out_file: Path + ) -> None: + """ + Write yaml record for all queries to output 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} " + f"in the directory {out_file.parent.resolve()}" + ) From 2f929c924f11788a6e6faa34ef44d91372eeb234 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Thu, 5 Jun 2025 14:27:13 +0200 Subject: [PATCH 02/58] Half-done main page with ag-grid and `serve-evaluation-app` command --- src/qlever/commands/serve_evaluation_app.py | 272 ++++++++++++++++++++ src/qlever/evaluation/www/index.html | 26 ++ src/qlever/evaluation/www/main.js | 248 ++++++++++++++++++ src/qlever/evaluation/www/styles.css | 30 +++ 4 files changed, 576 insertions(+) create mode 100644 src/qlever/commands/serve_evaluation_app.py 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/styles.css diff --git a/src/qlever/commands/serve_evaluation_app.py b/src/qlever/commands/serve_evaluation_app.py new file mode 100644 index 00000000..d4af8b5a --- /dev/null +++ b/src/qlever/commands/serve_evaluation_app.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import json +import math +import re +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 = {stat: val for stat, val in QUERY_STATS_DICT.items()} + 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 + + +QUERY_STATS_DICT = { + "ameanTime": None, + "gmeanTime": None, + "medianTime": None, + "under1s": 0.0, + "between1to5s": 0.0, + "over5s": 0.0, + "failed": 0.0, +} + + +def get_all_query_stats_by_kb( + performance_data: dict[str, dict[str, float | list]], kb: str +) -> dict[str, list]: + """ + Given a knowledge base (kb), get all query stats for each engine to display + on the main page of eval web app as a table + """ + engines_dict = performance_data[kb] + engines_dict_for_table = {col: [] for col in ["engine_name"] + QUERY_STATS_DICT.keys()} + for engine, engine_stats in engines_dict.items(): + engines_dict_for_table["engine_name"].append(engine.capitalize()) + for metric_key in QUERY_STATS_DICT.keys(): + metric = engine_stats[metric_key] + engines_dict_for_table[metric_key].append(metric) + return engines_dict_for_table + + +def extract_core_value(sparql_value: list[str] | str) -> str: + if isinstance(sparql_value, list): + if not sparql_value: + return "" + sparql_value = sparql_value[0] + + if not isinstance(sparql_value, str) or not sparql_value.strip(): + return "" + + # URI enclosed in angle brackets + if sparql_value.startswith("<") and sparql_value.endswith(">"): + return sparql_value[1:-1] + + # Literal string (e.g., "\"Some value\"") + literal_match = re.match(r'^"((?:[^"\\]|\\.)*)"', sparql_value) + if literal_match: + raw = literal_match.group(1) + return re.sub(r"\\(.)", r"\1", raw) + + # Fallback + return sparql_value + + +def get_single_result(query_data) -> str | None: + result_size = query_data.get("result_size") + result_size = 0 if result_size is None else result_size + single_result = None + if ( + result_size == 1 + and len(query_data["headers"]) == 1 + and len(query_data["results"]) == 1 + ): + single_result = query_data["results"][0] + single_result = extract_core_value(single_result) + try: + single_result = f"{int(single_result):,}" + except ValueError: + pass + return single_result + + +def get_query_runtimes( + performance_data: dict[str, dict[str, float | list]], kb: str, engine: str +) -> dict[str, list]: + all_queries_data = performance_data[kb][engine]["queries"] + query_runtimes = { + "query": [], + "runtime": [], + "failed": [], + "result_size": [], + } + for query_data in all_queries_data: + query_runtimes["query"].append(query_data["query"]) + runtime = round(query_data["runtime_info"]["client_time"], 2) + query_runtimes["runtime"].append(runtime) + failed = ( + isinstance(query_data["results"], str) + or len(query_data["headers"]) == 0 + ) + query_runtimes["failed"].append(failed) + result_size = query_data.get("result_size") + result_size = 0 if result_size is None else result_size + single_result = get_single_result(query_data) + result_size_to_display = ( + f"{result_size:,}" + if single_result is None + else f"1 [{single_result}]" + ) + query_runtimes["result_size"].append(result_size_to_display) + return query_runtimes + + +def get_query_results_df( + headers: list[str], query_results: list[list[str]] +) -> dict[str, list[str]]: + query_results_lists = [[] for _ in headers] + for result in query_results: + for i in range(len(headers)): + query_results_lists[i].append(result[i]) + query_results_dict = { + header: query_results_lists[i] for i, header in enumerate(headers) + } + return query_results_dict + + +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/index.html b/src/qlever/evaluation/www/index.html new file mode 100644 index 00000000..641cac41 --- /dev/null +++ b/src/qlever/evaluation/www/index.html @@ -0,0 +1,26 @@ + + + + + + SPARQL Engine Comparison + + + + + + + + + + + + +
+
SPARQL Engine Comparison
+
+
+ + diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js new file mode 100644 index 00000000..7918a17f --- /dev/null +++ b/src/qlever/evaluation/www/main.js @@ -0,0 +1,248 @@ +/** + * List of query statistics keys (values unused, so just keys kept) + */ +const QUERY_STATS_KEYS = ["ameanTime", "gmeanTime", "medianTime", "under1s", "between1to5s", "over5s", "failed"]; + +/** + * Given a knowledge base (kb), get all query stats for each engine to display + * on the main page of evaluation web app as a table. + * + * @param {Object>} performanceData - The performance data for all KBs and engines + * @param {string} kb - The knowledge base key to extract data for + * @returns {Object} Object mapping metric keys and engine names to arrays of values + */ +function getAllQueryStatsByKb(performanceData, kb) { + const enginesDict = performanceData[kb]; + const enginesDictForTable = { engine_name: [] }; + + // Initialize arrays for all metric keys + QUERY_STATS_KEYS.forEach((key) => { + enginesDictForTable[key] = []; + }); + + for (const [engine, engineStats] of Object.entries(enginesDict)) { + enginesDictForTable.engine_name.push(capitalize(engine)); + for (const metricKey of QUERY_STATS_KEYS) { + enginesDictForTable[metricKey].push(engineStats[metricKey]); + } + } + return enginesDictForTable; +} + +/** + * Capitalizes the first letter of a string. + * + * @param {string} str - Input string + * @returns {string} Capitalized string + */ +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Extract the core value from a SPARQL result value. + * + * @param {string | string[]} sparqlValue - The raw SPARQL value or list of values + * @returns {string} The extracted core value or empty string if none + */ +function extractCoreValue(sparqlValue) { + if (Array.isArray(sparqlValue)) { + if (sparqlValue.length === 0) return ""; + sparqlValue = sparqlValue[0]; + } + + if (typeof sparqlValue !== "string" || !sparqlValue.trim()) return ""; + + // URI enclosed in angle brackets + if (sparqlValue.startsWith("<") && sparqlValue.endsWith(">")) { + return sparqlValue.slice(1, -1); + } + + // Literal string like "\"Some value\"" + const literalMatch = sparqlValue.match(/^"((?:[^"\\]|\\.)*)"/); + if (literalMatch) { + const raw = literalMatch[1]; + return raw.replace(/\\(.)/g, "$1"); + } + + // Fallback - return as is + return sparqlValue; +} + +/** + * Extracts a single result string from query data if exactly one result exists. + * + * @param {Object} queryData - Single query data object + * @returns {string | null} Formatted single result or null if not applicable + */ +function getSingleResult(queryData) { + let resultSize = queryData.result_size ?? 0; + let singleResult = null; + + if ( + resultSize === 1 && + Array.isArray(queryData.headers) && + queryData.headers.length === 1 && + Array.isArray(queryData.results) && + queryData.results.length === 1 + ) { + singleResult = extractCoreValue(queryData.results[0]); + // Try formatting as int with commas + const intVal = parseInt(singleResult, 10); + if (!isNaN(intVal)) { + singleResult = intVal.toLocaleString(); + } + } + return singleResult; +} + +/** + * Extracts runtime and related query info for a given knowledge base and engine. + * + * @param {Object>} performanceData - Performance data object + * @param {string} kb - Knowledge base name + * @param {string} engine - Engine name + * @returns {Object} Object containing arrays for query, runtime, failed, and result_size + */ +function getQueryRuntimes(performanceData, kb, engine) { + const allQueriesData = performanceData[kb][engine].queries; + const queryRuntimes = { + query: [], + runtime: [], + failed: [], + result_size: [], + }; + + for (const queryData of allQueriesData) { + queryRuntimes.query.push(queryData.query); + const runtime = Number(queryData.runtime_info.client_time.toFixed(2)); + queryRuntimes.runtime.push(runtime); + + const failed = typeof queryData.results === "string" || (queryData.headers?.length ?? 0) === 0; + queryRuntimes.failed.push(failed); + + const resultSize = queryData.result_size ?? 0; + const singleResult = getSingleResult(queryData); + + const resultSizeToDisplay = singleResult === null ? resultSize.toLocaleString() : `1 [${singleResult}]`; + + queryRuntimes.result_size.push(resultSizeToDisplay); + } + return queryRuntimes; +} + +/** + * Converts query results represented as a list of lists into a dictionary + * mapping headers to their respective columns (lists). + * + * @param {string[]} headers - List of header strings + * @param {string[][]} queryResults - List of query results (each result is a list of strings) + * @returns {Object} Object mapping header names to lists of column values + */ +function getQueryResultsDict(headers, queryResults) { + const queryResultsLists = headers.map(() => []); + + for (const result of queryResults) { + for (let i = 0; i < headers.length; i++) { + queryResultsLists[i].push(result[i]); + } + } + + const queryResultsDict = {}; + headers.forEach((header, i) => { + queryResultsDict[header] = queryResultsLists[i]; + }); + + return queryResultsDict; +} + + +document.addEventListener('DOMContentLoaded', async () => { + const container = document.getElementById('main-table-container'); + + try { + const response = await fetch('/yaml_data'); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const performanceData = await response.json(); + + // Clear container if any existing content + container.innerHTML = ''; + + // For each knowledge base (kb) key in performanceData + for (const kb of Object.keys(performanceData)) { + // Create section wrapper + const section = document.createElement('div'); + section.className = 'kg-section'; + + // Header with KB name and a dummy compare button + const header = document.createElement('div'); + header.className = 'kg-header'; + + const title = document.createElement('h5'); + title.textContent = capitalize(kb); + title.style.fontWeight = "bold"; + + const compareBtn = document.createElement('button'); + compareBtn.className = 'btn btn-outline-primary btn-sm'; + compareBtn.textContent = 'Compare Results'; + compareBtn.onclick = () => alert(`Compare results for ${kb}`); + + header.appendChild(title); + header.appendChild(compareBtn); + + // Grid div with ag-theme-alpine styling + const gridDiv = document.createElement('div'); + gridDiv.className = 'ag-theme-balham'; + gridDiv.style.height = 'auto'; // adjust height as needed + gridDiv.style.width = '100%'; + + // Append header and grid div to section + section.appendChild(header); + section.appendChild(gridDiv); + container.appendChild(section); + + // Get table data from function you provided + const tableData = getAllQueryStatsByKb(performanceData, kb); + + // Build column definitions for ag-grid dynamically + const columnDefs = Object.keys(tableData).map((colKey) => ({ + headerName: colKey === 'engine_name' ? 'Engine Name' : colKey, + field: colKey, + sortable: true, + filter: true, + resizable: true, + flex: 1, + })); + + // Prepare row data as array of objects for ag-grid + // tableData is {colName: [val, val, ...], ...} + // We convert to [{engine_name: ..., ameanTime: ..., ...}, ...] + const rowCount = tableData.engine_name.length; + const rowData = Array.from({ length: rowCount }, (_, i) => { + const row = {}; + for (const col of Object.keys(tableData)) { + row[col] = tableData[col][i]; + } + return row; + }); + + // Initialize ag-Grid instance + agGrid.createGrid(gridDiv, { + columnDefs, + rowData, + defaultColDef: { + sortable: true, + filter: true, + resizable: true, + flex: 1, + minWidth: 100, + }, + domLayout: "autoHeight", + }); + } + } catch (err) { + console.error('Error loading /yaml_data:', err); + container.innerHTML = `
Failed to load data.
`; + } +}); + diff --git a/src/qlever/evaluation/www/styles.css b/src/qlever/evaluation/www/styles.css new file mode 100644 index 00000000..c887641e --- /dev/null +++ b/src/qlever/evaluation/www/styles.css @@ -0,0 +1,30 @@ +body { + background-color: #f9f9f9; + font-family: "Helvetica Neue", sans-serif; +} + +.main-title { + font-weight: bold; + font-size: 2rem; + margin-bottom: 1rem; + color: #262730; +} + +.kg-section { + background-color: white; + border-radius: 10px; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.05); + padding: 1rem; + margin-bottom: 1.5rem; +} + +.kg-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.ag-center-cols-viewport { + min-height: unset !important; +} From 3379a88afff2488cc565643824b47291a919bdcc Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Fri, 6 Jun 2025 16:14:38 +0200 Subject: [PATCH 03/58] Pull updated `benchmark-queries` code from add-evaluation-web-app pr --- ...xample_queries.py => benchmark_queries.py} | 211 +++++++++--------- 1 file changed, 108 insertions(+), 103 deletions(-) rename src/qlever/commands/{example_queries.py => benchmark_queries.py} (90%) diff --git a/src/qlever/commands/example_queries.py b/src/qlever/commands/benchmark_queries.py similarity index 90% rename from src/qlever/commands/example_queries.py rename to src/qlever/commands/benchmark_queries.py index ae8c44b3..d8eacd89 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"], @@ -183,9 +192,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( @@ -228,41 +236,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 [] @@ -272,9 +289,19 @@ def parse_queries_tsv( return [] @staticmethod - def parse_queries_yml( - queries_file: str, query_ids: str, query_regex: str - ) -> list[str]: + 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]: """ Parse a YML file, validate its structure and return a list of tab-separated queries (query_description, full_sparql_query) @@ -309,46 +336,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, @@ -385,7 +375,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 ) @@ -412,10 +402,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 ) @@ -465,15 +452,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: @@ -502,6 +489,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 @@ -532,12 +540,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 @@ -546,7 +551,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:" @@ -557,17 +562,17 @@ 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 42762b62698d11f2be596f86e6a026628be43316 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Sun, 8 Jun 2025 02:14:00 +0200 Subject: [PATCH 04/58] First completed version of new eval web app --- src/qlever/evaluation/www/comparison.js | 404 +++++ src/qlever/evaluation/www/details.js | 412 +++++ src/qlever/evaluation/www/index.html | 218 ++- src/qlever/evaluation/www/main.js | 397 ++--- src/qlever/evaluation/www/styles.css | 272 ++- src/qlever/evaluation/www/treant.js | 2171 +++++++++++++++++++++++ src/qlever/evaluation/www/util.js | 183 ++ 7 files changed, 3797 insertions(+), 260 deletions(-) create mode 100644 src/qlever/evaluation/www/comparison.js create mode 100644 src/qlever/evaluation/www/details.js create mode 100644 src/qlever/evaluation/www/treant.js create mode 100644 src/qlever/evaluation/www/util.js diff --git a/src/qlever/evaluation/www/comparison.js b/src/qlever/evaluation/www/comparison.js new file mode 100644 index 00000000..b280392f --- /dev/null +++ b/src/qlever/evaluation/www/comparison.js @@ -0,0 +1,404 @@ +let gridApi; +/** + * Populate the checkbox container inside the accordion with column names. + * @param {string[]} columnNames - List of Ag Grid column field names + */ +function populateColumnCheckboxes(columnNames) { + const container = document.querySelector("#columnCheckboxContainer"); + container.innerHTML = ""; + + columnNames.forEach((col) => { + const div = document.createElement("div"); + div.classList.add("form-check"); + + const checkbox = document.createElement("input"); + checkbox.className = "form-check-input"; + checkbox.style.cursor = "pointer"; + checkbox.type = "checkbox"; + checkbox.id = `col-${col}`; + checkbox.value = col; + + const label = document.createElement("label"); + label.className = "form-check-label"; + label.style.cursor = "pointer"; + label.setAttribute("for", `col-${col}`); + label.textContent = capitalize(col); + + div.appendChild(checkbox); + div.appendChild(label); + container.appendChild(div); + }); +} + +/** + * Attach a single change event listener to the container. + * Whenever any checkbox changes, it logs the currently checked values. + */ +function addEventListenerToCheckBoxContainer() { + document.getElementById("columnCheckboxContainer").addEventListener("change", (event) => { + if (event.target && event.target.matches('input[type="checkbox"]')) { + const enginesToDisplay = Array.from( + document.querySelectorAll('#columnCheckboxContainer input[type="checkbox"]:not(:checked)') + ).map((cb) => cb.value); + // console.log("Currently checked:", selectedValues); + updateHiddenColumns(enginesToDisplay); + // You can now use selectedValues to hide/show columns, etc. + } + }); +} + +function addEventListenerToResultSizeCheckbox() { + document.querySelector("#showResultSize").addEventListener("change", (event) => { + if (!gridApi) return; + const showResultSize = event.target.checked; + const enginesToDisplay = gridApi + .getColumns() + .filter((col) => { + return col.colId !== "query"; + }) + .map((col) => { + return col.colId; + }); + const visibleColumnDefs = getComparisonColumnDefs(enginesToDisplay, showResultSize); + gridApi.setGridOption("columnDefs", visibleColumnDefs); + }); +} + +/** + * Constructs a mapping from query string to engine-specific stats. + * @param {Object} performanceData - The top-level engine performance data. + * @returns {Object} queryToEngineStatsDict - Mapping: query => { engine => stats } + */ +function getQueryToEngineStatsDict(performanceData) { + const queryToEngineStatsDict = {}; + + for (const [engine, data] of Object.entries(performanceData)) { + const queriesData = data.queries; + + for (const queryData of queriesData) { + const { query, ...restOfStats } = queryData; + + if (!queryToEngineStatsDict[query]) { + queryToEngineStatsDict[query] = {}; + } + + queryToEngineStatsDict[query][engine] = restOfStats; + } + } + + return queryToEngineStatsDict; +} + +/** + * Finds the best (lowest) runtime among all engines for a single query. + * @param {Object} engineStats - Stats per engine for a query. + * @returns {number|null} - Minimum runtime or null if no valid runtimes. + */ +function getBestRuntimeForQuery(engineStats) { + const runtimes = Object.values(engineStats) + .filter((stat) => typeof stat.results !== "string") + .map((stat) => Number(stat.runtime_info.client_time.toFixed(2))); + + return runtimes.length > 0 ? Math.min(...runtimes) : null; +} + +/** + * Determines the majority result size or single result value for a query across engines. + * @param {Object} engineStats - Stats per engine. + * @returns {string|null} - The majority size string, or "no_consensus", or null. + */ +function getMajorityResultSizeForQuery(engineStats) { + const sizeCounts = {}; + + for (const stat of Object.values(engineStats)) { + if (typeof stat.results === "string") continue; + + const singleResult = getSingleResult(stat); + const resultSize = stat.result_size ?? 0; + const key = singleResult === null ? resultSize.toLocaleString() : singleResult; + + sizeCounts[key] = (sizeCounts[key] || 0) + 1; + } + + const entries = Object.entries(sizeCounts); + if (entries.length === 0) return null; + + let [majorityResultSize, maxCount, tie] = [null, 0, false]; + + for (const [size, count] of entries) { + if (count > maxCount) { + majorityResultSize = size; + maxCount = count; + tie = false; + } else if (count === maxCount) { + tie = true; + } + } + + return tie ? "no_consensus" : majorityResultSize; +} + +/** + * Creates a summary of performance per query per engine for display. + * @param {Object} performanceData - Raw engine performance data. + * @returns {Object} A structured object ready for use with AG Grid or tables. + */ +function getPerformanceComparisonPerKbDict(allEngineStats, enginesToDisplay = null) { + enginesToDisplay = enginesToDisplay === null ? Object.keys(allEngineStats) : enginesToDisplay; + const performanceData = Object.fromEntries( + Object.entries(allEngineStats).filter(([key]) => enginesToDisplay.includes(key)) + ); + const engineNames = Object.keys(performanceData); + const columns = ["query", "row_warning", ...engineNames.flatMap((e) => [e, `${e}_stats`])]; + + const result = {}; + for (const col of columns) result[col] = []; + + const queryToEngineStats = getQueryToEngineStatsDict(performanceData); + + for (const [query, engineStats] of Object.entries(queryToEngineStats)) { + result["query"].push(query); + + const bestRuntime = getBestRuntimeForQuery(engineStats); + const majoritySize = getMajorityResultSizeForQuery(engineStats); + result["row_warning"].push(majoritySize === "no_consensus"); + + for (const engine of engineNames) { + const stat = engineStats[engine]; + if (!stat) { + result[engine].push(null); + result[`${engine}_stats`].push(null); + continue; + } + + const runtime = Number(stat.runtime_info.client_time.toFixed(2)); + const singleResult = getSingleResult(stat); + const resultSize = stat.result_size ?? 0; + const resultSizeFinal = singleResult === null ? resultSize.toLocaleString() : singleResult; + + const sizeWarning = + majoritySize !== "no_consensus" && + majoritySize !== null && + typeof stat.results !== "string" && + resultSizeFinal !== majoritySize; + + Object.assign(stat, { + has_best_runtime: runtime === bestRuntime, + majority_result_size: majoritySize, + size_warning: sizeWarning, + result_size_to_display: singleResult === null ? resultSize.toLocaleString() : `1 [${singleResult}]`, + }); + + result[engine].push(runtime); + result[`${engine}_stats`].push(stat); + } + } + + return result; // or convert to tabular UI (e.g., AG Grid rows) +} + +// Function to update column visibility +function updateHiddenColumns(enginesToDisplay) { + if (!gridApi) return; + + const kb = new URLSearchParams(window.location.hash.split("?")[1]).get("kb"); + const visibleTableData = getPerformanceComparisonPerKbDict(performanceData[kb], enginesToDisplay); + const visibleRowData = getGridRowData(visibleTableData.query.length, visibleTableData); + gridApi.setGridOption("rowData", visibleRowData); + const showResultSize = document.querySelector("#showResultSize").checked; + const visibleColumnDefs = getComparisonColumnDefs(enginesToDisplay, showResultSize); + gridApi.setGridOption("columnDefs", visibleColumnDefs); +} + +class WarningCellRenderer { + init(params) { + const value = params.value; + const container = document.createElement("div"); + container.style.whiteSpace = "normal"; + + const warning = document.createElement("span"); + warning.textContent = "⚠️"; + warning.style.marginRight = "4px"; + + if (params.column.getColId() === "query") { + container.appendChild(document.createTextNode(`${value} `)); + if (params.data.row_warning) { + warning.title = "The result sizes for the engines do not match!"; + container.appendChild(warning); + } + } else { + const engineStatsColumn = params.column.getColId() + "_stats"; + const engineStats = params.data[engineStatsColumn]; + if (engineStats && typeof engineStats === "object" && engineStats.size_warning) { + warning.title = `Result size ${engineStats.result_size_to_display} doesn't match the majority ${engineStats.majority_result_size}!`; + container.appendChild(warning); + } + container.appendChild(document.createTextNode(`${value} s`)); + if (params.showResultSize) { + //container.appendChild(document.createElement("br")); + const resultSizeLine = document.createElement("div"); + resultSizeLine.textContent = engineStats.result_size_to_display; + resultSizeLine.style.color = "#888"; + resultSizeLine.style.fontSize = "90%"; + resultSizeLine.style.marginTop = "-8px"; + // container.appendChild(document.createTextNode(engineStats.result_size_to_display)); + container.appendChild(resultSizeLine); + } + } + this.eGui = container; + } + + getGui() { + return this.eGui; + } +} + +function comparisonGridCellStyle(params) { + const engineStatsColumn = params.column.getColId() + "_stats"; + const engineStats = params.data[engineStatsColumn]; + + if (engineStats && typeof engineStats === "object") { + if (typeof engineStats.results === "string") { + return { backgroundColor: "#f6ccd0" }; + } else if (engineStats.has_best_runtime) { + return { backgroundColor: "#c5e1d4" }; + } + } + return {}; +} + +function getTooltipValue(params) { + if (params.column.getColId() === "query") { + for (const key in params.data) { + const value = params.data[key]; + if (value && typeof value === "object" && typeof value.sparql === "string") { + return value.sparql; + } + } + return null; + } + const engineStatsColumn = params.column.getColId() + "_stats"; + const engineStats = params.data[engineStatsColumn]; + + if (engineStats && typeof engineStats === "object") { + if (typeof engineStats.results === "string") { + return engineStats.results; + } else { + return `Result size: ${engineStats.result_size_to_display}`; + } + } + return null; +} + +class CustomTooltip { + init(params) { + const tooltipText = params.value || ""; + + const container = document.createElement("div"); + container.className = "custom-tooltip"; + + const textDiv = document.createElement("div"); + textDiv.className = "tooltip-text"; + textDiv.textContent = tooltipText; + + // Copy button + const copyButton = document.createElement("button"); + copyButton.innerHTML = "📄"; + copyButton.className = "copy-btn"; + copyButton.title = "Copy"; + + copyButton.onclick = () => { + navigator.clipboard.writeText(textDiv.textContent).then(() => { + copyButton.innerHTML = "✅"; + setTimeout(() => (copyButton.innerHTML = "📋"), 1000); + }); + }; + + container.appendChild(textDiv); + container.appendChild(copyButton); + this.eGui = container; + } + + getGui() { + return this.eGui; + } +} + +/** + * Returns column definitions for ag-Grid to display engine comparison results. + * + * @returns {Array} columnDefs for ag-Grid + */ +function getComparisonColumnDefs(engines, showResultSize) { + columnDefs = [ + { + headerName: "Query", + field: "query", + filter: "agTextColumnFilter", + flex: 4, + cellRenderer: WarningCellRenderer, + autoHeight: showResultSize, + tooltipValueGetter: getTooltipValue, + tooltipComponent: CustomTooltip, + }, + ]; + for (const engine of engines) { + columnDefs.push({ + field: engine, + type: "numericColumn", + filter: "agNumberColumnFilter", + flex: 1, + cellRenderer: WarningCellRenderer, + cellRendererParams: { showResultSize: showResultSize }, + cellStyle: comparisonGridCellStyle, + autoHeight: true, + tooltipValueGetter: getTooltipValue, + tooltipComponent: CustomTooltip, + }); + } + return columnDefs; +} + +function updateComparisonPage(performanceData, kb) { + const titleNode = document.querySelector("#comparison-title"); + const title = `Performance comparison for ${capitalize(kb)}`; + if (titleNode.innerHTML === title) return; + titleNode.innerHTML = title; + + populateColumnCheckboxes(Object.keys(performanceData[kb])); + document.querySelector("#showResultSize").checked = false; + addEventListenerToCheckBoxContainer(); + addEventListenerToResultSizeCheckbox(); + + const tableData = getPerformanceComparisonPerKbDict(performanceData[kb]); + const gridDiv = document.querySelector("#comparison-grid"); + + const rowCount = tableData.query.length; + const rowData = getGridRowData(rowCount, tableData); + gridDiv.innerHTML = ""; + let domLayout = "normal"; + if (rowCount < 25) domLayout = "autoHeight"; + + if (domLayout === "normal") { + gridDiv.style.height = `${document.documentElement.clientHeight - 200}px`; + } + const detailsGridOptions = { + columnDefs: getComparisonColumnDefs(Object.keys(performanceData[kb])), + rowData: rowData, + defaultColDef: { + sortable: true, + filter: true, + resizable: true, + flex: 1, + minWidth: 100, + }, + domLayout: domLayout, + rowStyle: { fontSize: "14px", cursor: "pointer" }, + onGridReady: params => {gridApi = params.api}, + tooltipShowDelay: 0, + tooltipTrigger: "focus", + tooltipInteraction: true, + }; + // Initialize ag-Grid instance + agGrid.createGrid(gridDiv, detailsGridOptions); +} diff --git a/src/qlever/evaluation/www/details.js b/src/qlever/evaluation/www/details.js new file mode 100644 index 00000000..76004004 --- /dev/null +++ b/src/qlever/evaluation/www/details.js @@ -0,0 +1,412 @@ +/** + * Extracts runtime and related query info for a given knowledge base and engine. + * + * @param {Object>} performanceData - Performance data object + * @param {string} kb - Knowledge base name + * @param {string} engine - Engine name + * @returns {Object} Object containing arrays for query, runtime, failed, and result_size + */ +function getQueryRuntimes(performanceData, kb, engine) { + const allQueriesData = performanceData[kb][engine].queries; + const queryRuntimes = { + query: [], + sparql: [], + runtime: [], + failed: [], + result_size: [], + }; + + for (const queryData of allQueriesData) { + queryRuntimes.query.push(queryData.query); + queryRuntimes.sparql.push(queryData.sparql); + const runtime = Number(queryData.runtime_info.client_time.toFixed(2)); + queryRuntimes.runtime.push(runtime); + + const failed = typeof queryData.results === "string" || (queryData.headers?.length ?? 0) === 0; + queryRuntimes.failed.push(failed); + + const resultSize = queryData.result_size ?? 0; + const singleResult = getSingleResult(queryData); + + const resultSizeToDisplay = singleResult === null ? resultSize.toLocaleString() : `1 [${singleResult}]`; + + queryRuntimes.result_size.push(resultSizeToDisplay); + } + return queryRuntimes; +} + +/** + * Converts query results represented as a list of lists into a dictionary + * mapping headers to their respective columns (lists). + * + * @param {string[]} headers - List of header strings + * @param {string[][]} queryResults - List of query results (each result is a list of strings) + * @returns {Object} Object mapping header names to lists of column values + */ +function getQueryResultsDict(headers, queryResults) { + const queryResultsLists = headers.map(() => []); + + for (const result of queryResults) { + for (let i = 0; i < headers.length; i++) { + queryResultsLists[i].push(result[i]); + } + } + + const queryResultsDict = {}; + headers.forEach((header, i) => { + queryResultsDict[header] = queryResultsLists[i]; + }); + + return queryResultsDict; +} + +class CustomDetailsTooltip { + eGui; + init(params) { + const tooltipText = params.value || ""; + + const container = document.createElement("div"); + container.className = "custom-tooltip"; + + const textDiv = document.createElement("div"); + textDiv.className = "tooltip-text"; + textDiv.textContent = tooltipText; + container.appendChild(textDiv); + this.eGui = container; + } + + getGui() { + return this.eGui; + } +} + +/** + * Returns column definitions for ag-Grid to display query runtime results. + * Expected input data keys: query, runtime, failed, result_size. + * + * @returns {Array} columnDefs for ag-Grid + */ +function getQueryRuntimesColumnDefs() { + return [ + { + headerName: "SPARQL Query", + field: "query", + filter: "agTextColumnFilter", + flex: 3, + tooltipValueGetter: params => { + return params.data.sparql; + }, + tooltipComponent: CustomDetailsTooltip, + }, + { + headerName: "Runtime (s)", + field: "runtime", + type: "numericColumn", + filter: "agNumberColumnFilter", + flex: 1, + valueFormatter: (params) => (params.value != null ? `${params.value.toFixed(2)}s` : ""), + }, + { + headerName: "Result Size", + field: "result_size", + type: "numericColumn", + filter: "agTextColumnFilter", + flex: 1.5, + }, + ]; +} + +function setTabsToDefault() { + document.querySelectorAll("#page-details .tab-pane").forEach((node) => { + if (node.id === "runtimes-tab-pane") return; + for (const div of node.querySelectorAll("div")) { + if (div.classList.contains("alert-info")) div.classList.remove("d-none"); + else div.classList.add("d-none"); + } + }); +} + +let currentTree = null; + +function renderExecTree(runtime_info) { + // Show meta information (if it exists). + const meta_info = runtime_info["meta"]; + + const time_query_planning = + "time_query_planning" in meta_info + ? formatInteger(meta_info["time_query_planning"]) + " ms" + : "[not available]"; + + const time_index_scans_query_planning = + "time_index_scans_query_planning" in meta_info + ? formatInteger(meta_info["time_index_scans_query_planning"]) + " ms" + : "[not available]"; + + const total_time_computing = + "total_time_computing" in meta_info ? formatInteger(meta_info["total_time_computing"]) + " ms" : "N/A"; + + // Inject meta info into the DOM + document.getElementById("meta-info").innerHTML = `

Time for query planning: ${time_query_planning}
+ Time for index scans during query planning: ${time_index_scans_query_planning}
+ Total time for computing the result: ${total_time_computing}

`; + + // Show the query execution tree (using Treant.js) + addTextElementsToExecTreeForTreant(runtime_info["query_execution_tree"]); + console.log(runtime_info.query_execution_tree); + + const treant_tree = { + chart: { + container: "#result-tree", + rootOrientation: "NORTH", + connectors: { type: "step" }, + }, + nodeStructure: runtime_info["query_execution_tree"], + }; + + + // Destroy previous tree if it exists + if (typeof currentTree !== "undefined" && currentTree !== null) { + currentTree.destroy(); + } + + // Create new Treant tree + currentTree = new Treant(treant_tree); + + // Add tooltips with parsed .node-details info + document.querySelectorAll("div.node").forEach(function (node) { + const detailsChild = node.querySelector(".node-details"); + if (detailsChild) { + const topPos = parseFloat(window.getComputedStyle(node).top); + node.setAttribute("data-bs-toggle", "tooltip"); + node.setAttribute("data-bs-html", "true"); + node.setAttribute("data-bs-placement", topPos > 100 ? "top" : "bottom"); + + let detailHTML = ""; + const details = JSON.parse(detailsChild.textContent); + for (const key in details) { + detailHTML += `${key}: ${details[key]}
`; + } + + node.setAttribute( + "data-bs-title", + `
+
Details
+
+ ${detailHTML} +
+
` + ); + + // Manually initialize Bootstrap tooltip + new bootstrap.Tooltip(node); + } + }); + + // Highlight high/very high node-time values + document.querySelectorAll("p.node-time").forEach(function (p) { + const time = parseInt(p.textContent.replace(/,/g, "")); + if (time >= window.high_query_time_ms) { + p.parentElement.classList.add("high"); + } + if (time >= window.very_high_query_time_ms) { + p.parentElement.classList.add("veryhigh"); + } + }); + + // Add cache status classes + document.querySelectorAll("p.node-cache-status").forEach(function (p) { + const status = p.textContent; + const parent = p.parentElement; + + if (status === "cached_not_pinned") { + parent.classList.add("cached-not-pinned", "cached"); + } else if (status === "cached_pinned") { + parent.classList.add("cached-pinned", "cached"); + } else if (status === "ancestor_cached") { + parent.classList.add("ancestor-cached", "cached"); + } + }); + + // Add status classes + document.querySelectorAll("p.node-status").forEach(function (p) { + const status = p.textContent; + const parent = p.parentElement; + + switch (status) { + case "fully materialized": + p.classList.add("fully-materialized"); + break; + case "lazily materialized": + p.classList.add("lazily-materialized"); + break; + case "failed": + p.classList.add("failed"); + break; + case "failed because child failed": + p.classList.add("child-failed"); + break; + case "not yet started": + parent.classList.add("not-started"); + break; + case "optimized out": + p.classList.add("optimized-out"); + break; + } + }); + + // Add title for truncated node names and cols + document.querySelectorAll("#result-tree p.node-name, #result-tree p.node-cols").forEach(function (p) { + p.setAttribute("title", p.textContent); + }); +} + +function updateTabsWithSelectedRow(rowData) { + console.log(rowData); + const sparqlQuery = rowData?.sparql; + if (sparqlQuery) { + for (const div of document.querySelectorAll("#query-tab-pane div")) { + if (div.classList.contains("alert-info")) div.classList.add("d-none"); + else div.classList.remove("d-none"); + } + document.querySelector("#full-query").textContent = sparqlQuery; + } + + const runtime_info = rowData?.runtime_info; + if (runtime_info?.query_execution_tree) { + for (const div of document.querySelectorAll("#exec-tree-tab-pane div")) { + if (div.classList.contains("alert-info")) div.classList.add("d-none"); + else div.classList.remove("d-none"); + } + document.querySelector("#result-tree").innerHTML = ""; + const exec_tree_tab = document.querySelector("#exec-tree-tab"); + exec_tree_tab.addEventListener( + "shown.bs.tab", + function () { + renderExecTree(runtime_info); + }, + { once: true } + ); + } else { + document.querySelector("#exec-tree-tab-pane div.alert-info").classList.add("d-none") + document.querySelector("#result-tree-div").classList.remove("d-none"); + document.querySelector("#result-tree-div div.alert-info").classList.remove("d-none") + } + + const headers = rowData?.headers; + const queryResults = rowData?.results; + for (const div of document.querySelectorAll("#results-tab-pane div")) { + if (div.classList.contains("alert-info")) div.classList.add("d-none"); + else div.classList.remove("d-none"); + } + const gridDiv = document.querySelector("#results-grid"); + gridDiv.innerHTML = ""; + if (Array.isArray(queryResults) && Array.isArray(headers)) { + const textDiv = document.querySelector("#results-container div.alert"); + textDiv.classList.remove("alert-danger"); + textDiv.classList.add("alert-secondary"); + textDiv.innerHTML = `Showing ${rowData.results.length} results out of ${ + rowData?.result_size ?? 0 + } total results`; + const rowCount = queryResults.length; + const tableData = getQueryResultsDict(headers, queryResults); + let domLayout = "normal"; + if (rowCount < 25) domLayout = "autoHeight"; + + if (domLayout === "normal") { + gridDiv.style.height = `${document.documentElement.clientHeight - 275}px`; + } + + const gridData = getGridRowData(rowCount, tableData); + const columnDefs = headers.map((key) => ({ + field: key, + headerName: key, + })); + + agGrid.createGrid(gridDiv, { + columnDefs: columnDefs, + rowData: gridData, + defaultColDef: { + sortable: true, + filter: true, + resizable: true, + flex: 1, + minWidth: 100, + }, + domLayout: domLayout, + rowStyle: { fontSize: "14px", cursor: "pointer" }, + }); + } else { + const textDiv = document.querySelector("#results-container div.alert"); + textDiv.classList.add("alert-danger"); + textDiv.classList.remove("alert-secondary"); + textDiv.innerHTML = `Query failed in ${rowData.runtime_info.client_time.toFixed(2)} s with error:

${ + rowData.results + }`; + } +} + +/** + * Called when a row is selected in the runtime table + */ +function onRuntimeRowSelected(event, performanceData, kb, engine) { + const selectedNode = event.api.getSelectedNodes(); + if (selectedNode.length === 1) { + let selectedRowIdx = selectedNode[0].rowIndex; + updateTabsWithSelectedRow(performanceData[kb][engine]["queries"][selectedRowIdx]); + // router.navigate(`/details?kb=${encodeURIComponent(kb)}&engine=${encodeURIComponent(engine)}&q=${selectedRowIdx}`) + } else { + setTabsToDefault(); + // router.navigate(`/details?kb=${encodeURIComponent(kb)}&engine=${encodeURIComponent(engine)}&q=${selectedRowIdx}`) + } +} + +function updateDetailsPage(performanceData, kb, engine) { + let engine_header = capitalize(engine); + if (engine_header === "Qlever") engine_header = "QLever"; + const titleNode = document.querySelector("#details-title"); + if (titleNode.innerHTML === `Details - ${engine_header} (${capitalize(kb)})`) return; + else titleNode.innerHTML = `Details - ${engine_header} (${capitalize(kb)})`; + + setTabsToDefault(); + const tab = new bootstrap.Tab(document.querySelector("#runtimes-tab")); + tab.show(); + + const tableData = getQueryRuntimes(performanceData, kb, engine); + const gridDiv = document.querySelector("#details-grid"); + + const rowCount = tableData.query.length; + const rowData = getGridRowData(rowCount, tableData); + gridDiv.innerHTML = ""; + let domLayout = "normal"; + if (rowCount < 25) domLayout = "autoHeight"; + + if (domLayout === "normal") { + gridDiv.style.height = `${document.documentElement.clientHeight - 200}px`; + } + let selectedRow = null; + const detailsGridOptions = { + columnDefs: getQueryRuntimesColumnDefs(), + rowData: rowData, + defaultColDef: { + sortable: true, + filter: true, + resizable: true, + }, + domLayout: domLayout, + getRowStyle: (params) => { + let rowStyle = { fontSize: "14px", cursor: "pointer" }; + if (params.data.failed === true) { + rowStyle.backgroundColor = "#f6ccd0"; + } + return rowStyle; + }, + rowSelection: { mode: "singleRow", headerCheckbox: false, enableClickSelection: true }, + onRowSelected: (event) => { + if (event.api.getSelectedRows() === selectedRow) return; + onRuntimeRowSelected(event, performanceData, kb, engine); + }, + tooltipShowDelay: 500, + }; + // Initialize ag-Grid instance + agGrid.createGrid(gridDiv, detailsGridOptions); +} diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html index 641cac41..116e0511 100644 --- a/src/qlever/evaluation/www/index.html +++ b/src/qlever/evaluation/www/index.html @@ -1,26 +1,200 @@ - - - - SPARQL Engine Comparison + + + + SPARQL Engine Comparison - - - - - - - - - - - -
-
SPARQL Engine Comparison
-
-
- + + + + + + + + + + + + + + + + + + + + + + + +
+
SPARQL Engine Comparison
+
+
+ + + + + diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index 7918a17f..7762e17e 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -12,237 +12,188 @@ const QUERY_STATS_KEYS = ["ameanTime", "gmeanTime", "medianTime", "under1s", "be * @returns {Object} Object mapping metric keys and engine names to arrays of values */ function getAllQueryStatsByKb(performanceData, kb) { - const enginesDict = performanceData[kb]; - const enginesDictForTable = { engine_name: [] }; - - // Initialize arrays for all metric keys - QUERY_STATS_KEYS.forEach((key) => { - enginesDictForTable[key] = []; - }); - - for (const [engine, engineStats] of Object.entries(enginesDict)) { - enginesDictForTable.engine_name.push(capitalize(engine)); - for (const metricKey of QUERY_STATS_KEYS) { - enginesDictForTable[metricKey].push(engineStats[metricKey]); - } - } - return enginesDictForTable; -} - -/** - * Capitalizes the first letter of a string. - * - * @param {string} str - Input string - * @returns {string} Capitalized string - */ -function capitalize(str) { - return str.charAt(0).toUpperCase() + str.slice(1); -} - -/** - * Extract the core value from a SPARQL result value. - * - * @param {string | string[]} sparqlValue - The raw SPARQL value or list of values - * @returns {string} The extracted core value or empty string if none - */ -function extractCoreValue(sparqlValue) { - if (Array.isArray(sparqlValue)) { - if (sparqlValue.length === 0) return ""; - sparqlValue = sparqlValue[0]; - } - - if (typeof sparqlValue !== "string" || !sparqlValue.trim()) return ""; - - // URI enclosed in angle brackets - if (sparqlValue.startsWith("<") && sparqlValue.endsWith(">")) { - return sparqlValue.slice(1, -1); - } - - // Literal string like "\"Some value\"" - const literalMatch = sparqlValue.match(/^"((?:[^"\\]|\\.)*)"/); - if (literalMatch) { - const raw = literalMatch[1]; - return raw.replace(/\\(.)/g, "$1"); - } - - // Fallback - return as is - return sparqlValue; -} - -/** - * Extracts a single result string from query data if exactly one result exists. - * - * @param {Object} queryData - Single query data object - * @returns {string | null} Formatted single result or null if not applicable - */ -function getSingleResult(queryData) { - let resultSize = queryData.result_size ?? 0; - let singleResult = null; - - if ( - resultSize === 1 && - Array.isArray(queryData.headers) && - queryData.headers.length === 1 && - Array.isArray(queryData.results) && - queryData.results.length === 1 - ) { - singleResult = extractCoreValue(queryData.results[0]); - // Try formatting as int with commas - const intVal = parseInt(singleResult, 10); - if (!isNaN(intVal)) { - singleResult = intVal.toLocaleString(); + const enginesDict = performanceData[kb]; + const enginesDictForTable = { engine_name: [] }; + + // Initialize arrays for all metric keys + QUERY_STATS_KEYS.forEach((key) => { + enginesDictForTable[key] = []; + }); + + for (const [engine, engineStats] of Object.entries(enginesDict)) { + enginesDictForTable.engine_name.push(capitalize(engine)); + for (const metricKey of QUERY_STATS_KEYS) { + enginesDictForTable[metricKey].push(engineStats[metricKey]); + } } - } - return singleResult; + return enginesDictForTable; } /** - * Extracts runtime and related query info for a given knowledge base and engine. + * Returns ag-Grid gridOptions for comparing SPARQL engines for a given knowledge base. * - * @param {Object>} performanceData - Performance data object - * @param {string} kb - Knowledge base name - * @param {string} engine - Engine name - * @returns {Object} Object containing arrays for query, runtime, failed, and result_size + * This grid displays various metrics like average time, failure rate, etc. + * It applies proper formatting and filters based on the type of each metric. + * @returns {Array} ag-Grid gridOptions object */ -function getQueryRuntimes(performanceData, kb, engine) { - const allQueriesData = performanceData[kb][engine].queries; - const queryRuntimes = { - query: [], - runtime: [], - failed: [], - result_size: [], - }; - - for (const queryData of allQueriesData) { - queryRuntimes.query.push(queryData.query); - const runtime = Number(queryData.runtime_info.client_time.toFixed(2)); - queryRuntimes.runtime.push(runtime); - - const failed = typeof queryData.results === "string" || (queryData.headers?.length ?? 0) === 0; - queryRuntimes.failed.push(failed); - - const resultSize = queryData.result_size ?? 0; - const singleResult = getSingleResult(queryData); - - const resultSizeToDisplay = singleResult === null ? resultSize.toLocaleString() : `1 [${singleResult}]`; - - queryRuntimes.result_size.push(resultSizeToDisplay); - } - return queryRuntimes; -} - -/** - * Converts query results represented as a list of lists into a dictionary - * mapping headers to their respective columns (lists). - * - * @param {string[]} headers - List of header strings - * @param {string[][]} queryResults - List of query results (each result is a list of strings) - * @returns {Object} Object mapping header names to lists of column values - */ -function getQueryResultsDict(headers, queryResults) { - const queryResultsLists = headers.map(() => []); - - for (const result of queryResults) { - for (let i = 0; i < headers.length; i++) { - queryResultsLists[i].push(result[i]); - } - } - - const queryResultsDict = {}; - headers.forEach((header, i) => { - queryResultsDict[header] = queryResultsLists[i]; - }); - - return queryResultsDict; +function mainTableColumnDefs() { + // Define custom formatting and filters based on column keys + return [ + { + headerName: "SPARQL Engine", + field: "engine_name", + filter: "agTextColumnFilter", + }, + { + headerName: "Failed", + field: "failed", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)}%` : "N/A"), + }, + { + headerName: "Arithmetic Mean", + field: "ameanTime", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)}s` : "N/A"), + }, + { + headerName: "Geometric Mean", + field: "gmeanTime", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)}s` : "N/A"), + }, + { + headerName: "Median", + field: "medianTime", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)}s` : "N/A"), + }, + { + headerName: "<= 1s", + field: "under1s", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)}%` : "N/A"), + }, + { + headerName: "(1s, 5s]", + field: "between1to5s", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)}%` : "N/A"), + }, + { + headerName: "> 5s", + field: "over5s", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)}%` : "N/A"), + }, + ]; } - -document.addEventListener('DOMContentLoaded', async () => { - const container = document.getElementById('main-table-container'); - - try { - const response = await fetch('/yaml_data'); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const performanceData = await response.json(); - - // Clear container if any existing content - container.innerHTML = ''; - - // For each knowledge base (kb) key in performanceData - for (const kb of Object.keys(performanceData)) { - // Create section wrapper - const section = document.createElement('div'); - section.className = 'kg-section'; - - // Header with KB name and a dummy compare button - const header = document.createElement('div'); - header.className = 'kg-header'; - - const title = document.createElement('h5'); - title.textContent = capitalize(kb); - title.style.fontWeight = "bold"; - - const compareBtn = document.createElement('button'); - compareBtn.className = 'btn btn-outline-primary btn-sm'; - compareBtn.textContent = 'Compare Results'; - compareBtn.onclick = () => alert(`Compare results for ${kb}`); - - header.appendChild(title); - header.appendChild(compareBtn); - - // Grid div with ag-theme-alpine styling - const gridDiv = document.createElement('div'); - gridDiv.className = 'ag-theme-balham'; - gridDiv.style.height = 'auto'; // adjust height as needed - gridDiv.style.width = '100%'; - - // Append header and grid div to section - section.appendChild(header); - section.appendChild(gridDiv); - container.appendChild(section); - - // Get table data from function you provided - const tableData = getAllQueryStatsByKb(performanceData, kb); - - // Build column definitions for ag-grid dynamically - const columnDefs = Object.keys(tableData).map((colKey) => ({ - headerName: colKey === 'engine_name' ? 'Engine Name' : colKey, - field: colKey, - sortable: true, - filter: true, - resizable: true, - flex: 1, - })); - - // Prepare row data as array of objects for ag-grid - // tableData is {colName: [val, val, ...], ...} - // We convert to [{engine_name: ..., ameanTime: ..., ...}, ...] - const rowCount = tableData.engine_name.length; - const rowData = Array.from({ length: rowCount }, (_, i) => { - const row = {}; - for (const col of Object.keys(tableData)) { - row[col] = tableData[col][i]; +document.addEventListener("DOMContentLoaded", async () => { + router = new Navigo("/", { hash: true }); + + try { + const response = await fetch("/yaml_data"); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + performanceData = await response.json(); + + // Routes + router + .on({ + "/": () => showPage("main"), + "/details": (params) => { + const kb = params.params.kb; + const engine = params.params.engine; + updateDetailsPage(performanceData, kb, engine); + showPage("details"); + }, + "/comparison": (params) => { + const kb = params.params.kb; + updateComparisonPage(performanceData, kb); + showPage("comparison"); + }, + }) + .notFound(() => showPage("main")); + + router.resolve(); + const container = document.getElementById("main-table-container"); + + // Clear container if any existing content + container.innerHTML = ""; + + // For each knowledge base (kb) key in performanceData + for (const kb of Object.keys(performanceData)) { + // Create section wrapper + const section = document.createElement("div"); + section.className = "kg-section"; + + // Header with KB name and a dummy compare button + const header = document.createElement("div"); + header.className = "kg-header"; + + const title = document.createElement("h5"); + title.textContent = capitalize(kb); + title.style.fontWeight = "bold"; + + const compareBtn = document.createElement("button"); + compareBtn.className = "btn btn-outline-dark btn-sm"; + compareBtn.textContent = "Compare Results"; + compareBtn.onclick = () => { + router.navigate(`/comparison?kb=${encodeURIComponent(kb)}`); + }; + + header.appendChild(title); + header.appendChild(compareBtn); + + // Grid div with ag-theme-alpine styling + const gridDiv = document.createElement("div"); + gridDiv.className = "ag-theme-balham"; + gridDiv.style.width = "100%"; + + // Append header and grid div to section + section.appendChild(header); + section.appendChild(gridDiv); + container.appendChild(section); + + // Get table data from function you provided + const tableData = getAllQueryStatsByKb(performanceData, kb); + + // Prepare row data as array of objects for ag-grid + // tableData is {colName: [val, val, ...], ...} + // We convert to [{engine_name: ..., ameanTime: ..., ...}, ...] + const rowCount = tableData.engine_name.length; + const rowData = getGridRowData(rowCount, tableData); + + const onRowClicked = (event) => { + const engine = event.data.engine_name.toLowerCase(); + router.navigate(`/details?kb=${encodeURIComponent(kb)}&engine=${encodeURIComponent(engine)}`); + }; + + // Initialize ag-Grid instance + agGrid.createGrid(gridDiv, { + columnDefs: mainTableColumnDefs(), + rowData: rowData, + defaultColDef: { + sortable: true, + filter: true, + resizable: true, + flex: 1, + minWidth: 100, + }, + domLayout: "autoHeight", + rowStyle: { fontSize: "14px", cursor: "pointer" }, + onRowClicked: onRowClicked, + }); } - return row; - }); - - // Initialize ag-Grid instance - agGrid.createGrid(gridDiv, { - columnDefs, - rowData, - defaultColDef: { - sortable: true, - filter: true, - resizable: true, - flex: 1, - minWidth: 100, - }, - domLayout: "autoHeight", - }); + } catch (err) { + console.error("Error loading /yaml_data:", err); + container.innerHTML = `
Failed to load data.
`; } - } catch (err) { - console.error('Error loading /yaml_data:', err); - container.innerHTML = `
Failed to load data.
`; - } }); - diff --git a/src/qlever/evaluation/www/styles.css b/src/qlever/evaluation/www/styles.css index c887641e..149cd37d 100644 --- a/src/qlever/evaluation/www/styles.css +++ b/src/qlever/evaluation/www/styles.css @@ -1,30 +1,272 @@ body { - background-color: #f9f9f9; - font-family: "Helvetica Neue", sans-serif; + background-color: #f9f9f9; + font-family: "Helvetica Neue", sans-serif; +} + +.ag-theme-balham { + font-size: 16px; /* Change this to 15px, 16px etc. as needed */ } .main-title { - font-weight: bold; - font-size: 2rem; - margin-bottom: 1rem; - color: #262730; + font-weight: bold; + font-size: 2rem; + margin-bottom: 1rem; + color: #262730; } .kg-section { - background-color: white; - border-radius: 10px; - box-shadow: 0 0 8px rgba(0, 0, 0, 0.05); - padding: 1rem; - margin-bottom: 1.5rem; + background-color: white; + border-radius: 10px; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.05); + padding: 1rem; + margin-bottom: 1.5rem; } .kg-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; } .ag-center-cols-viewport { min-height: unset !important; } + +.page { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.3s ease, transform 0.3s ease; + position: absolute; + left: 0; + right: 0; + padding: 20px; +} + +.page.visible { + opacity: 1; + transform: translateY(0); + z-index: 1; +} + +.page.hidden { + display: none; + z-index: 0; +} + +.floating-label { + position: absolute; + top: -0.7rem; + left: 1rem; + background: white; + padding: 0 0.4rem; + font-size: 0.8rem; + color: #6c757d; +} + +.nav-link.active { + background-color: #212529 !important; + color: white !important; +} + +.custom-tooltip { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px; + background-color: #212529; + color: white; + border-radius: 4px; + font-size: 12px; + max-width: 600px; +} + +.tooltip-text { + flex: 1; + user-select: text; + white-space: pre-wrap; + word-break: break-word; +} + +.copy-btn { + background: none; + border: none; + font-size: 16px; + cursor: pointer; + padding: 2px; +} + +#meta-info p { + font-size: 80%; +} + +.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; +} + +#result-tree .node { + padding: 3px; + border-radius: 3px; + background-color: #fefefe; + border: 1px solid #000; + min-width: 25em; + font-size: 80%; + color: black; +} + +#result-tree .node > p { + margin: 0; + white-space: nowrap; +} +#result-tree .node-name { + font-weight: bold; +} +#result-tree p.node-name { + width: 30em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#result-tree .node-status { + font-size: 90%; + color: #000099; +} +#result-tree .node.not-started { + color: #999; +} +#result-tree p.fully-materialized { + display: none; +} +#result-tree p.lazily-materialized { + display: none; +} +#result-tree .node-status.failed { + font-weight: bold; + color: red; +} +#result-tree .node-status.child-failed { + color: red; +} +#result-tree .node-status.not-started { + color: blue; +} +#result-tree .node-status.optimized-out { + color: blue; +} +#result-tree .node-status.lazily-materialized { + color: blue; +} +#result-tree .node-cols:before { + content: "Cols: "; +} +#result-tree p.node-cols { + width: 30em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#result-tree .node-size:before { + content: "Size: "; +} +#result-tree .node-size { + display: inline; +} +#result-tree .node-size-estimate { + display: inline; + padding-left: 1em; +} +#result-tree .node-time:before { + content: "\ATime: "; + white-space: pre; +} +#result-tree .node-time { + display: inline; +} +#result-tree .node-cost-estimate { + display: inline; + padding-left: 1em; +} +#result-tree .node-status { + display: inline; + padding-left: 1em; +} +#result-tree .node-time:after { + content: "ms"; +} +#result-tree .node-details { + display: none; + color: blue; +} +#result-tree .node-details:before { + content: "Details: "; +} +#result-tree div.cached-not-pinned .node-time:after { + content: "ms [cached]"; +} +#result-tree div.cached-pinned .node-time:after { + content: "ms [cached, pinned]"; +} +#result-tree div.ancestor-cached .node-time:after { + content: "ms [ancestor cached]"; +} +#result-tree p.node-cache-status { + display: none; +} +#result-tree .node.cached-pinned { + color: grey; + border: 1px solid grey; +} +#result-tree .node.cached-not-pinned { + color: grey; + border: 1px solid grey; +} +#result-tree .node-total { + display: none; +} +#result-tree .node.high { + background-color: #ffeeee; +} +#result-tree .node.veryhigh { + background-color: #ffcccc; +} +#result-tree .node.high.cached { + background-color: #ffffee; +} +#result-tree .node.veryhigh.cached { + background-color: #ffffcc; +} 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")) { + return sparqlValue.slice(1, -1); + } + + // Literal string like "\"Some value\"" + const literalMatch = sparqlValue.match(/^"((?:[^"\\]|\\.)*)"/); + if (literalMatch) { + const raw = literalMatch[1]; + return raw.replace(/\\(.)/g, "$1"); + } + + // Fallback - return as is + return sparqlValue; +} + +function showPage(pageId) { + // Hide all pages + document.querySelectorAll(".page").forEach((p) => { + p.classList.remove("visible"); + p.classList.add("hidden"); + }); + + // Show requested page with animation + const page = document.getElementById(`page-${pageId}`); + if (page) { + page.classList.remove("hidden"); + // Force reflow for transition to trigger + void page.offsetWidth; + page.classList.add("visible"); + } +} + +function getGridRowData(rowCount, tableData) { + return Array.from({ length: rowCount }, (_, i) => { + const row = {}; + for (const col of Object.keys(tableData)) { + row[col] = tableData[col][i]; + } + return row; + }); +} + +/** + * Extracts a single result string from query data if exactly one result exists. + * + * @param {Object} queryData - Single query data object + * @returns {string | null} Formatted single result or null if not applicable + */ +function getSingleResult(queryData) { + let resultSize = queryData.result_size ?? 0; + let singleResult = null; + + if ( + resultSize === 1 && + Array.isArray(queryData.headers) && + queryData.headers.length === 1 && + Array.isArray(queryData.results) && + queryData.results.length === 1 + ) { + singleResult = extractCoreValue(queryData.results[0]); + // Try formatting as int with commas + const intVal = parseInt(singleResult, 10); + if (!isNaN(intVal)) { + singleResult = intVal.toLocaleString(); + } + } + return singleResult; +} + +function addTextElementsToExecTreeForTreant(tree_node, is_ancestor_cached = false) { + if (tree_node["text"] == undefined) { + var text = {}; + if (tree_node["column_names"] == undefined) { + tree_node["column_names"] = ["not yet available"]; + } + // Rewrite runtime info from QLever as follows: + // + // 1. Abbreviate IRIs (only keep part after last / or # or dot) + // 2. Remove qlc_ and _qlever_internal_... prefixes from variable names + // 3. Lowercase fully capitalized words (with _) + // 4. Separate CamelCase word parts by hyphen (Camel-Case) + // 5. First word in ALL CAPS (like JOIN or INDEX-SCAN) + // 6. Replace hyphen in all caps by space (INDEX SCAN) + // 7. Abbreviate long QLever-internal variable names + // + text["name"] = tree_node["description"] + .replace(/<[^>]*[#\/\.]([^>]*)>/g, "<$1>") + .replace(/qlc_/g, "") + .replace(/_qlever_internal_variable_query_planner/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["cols"] = tree_node["column_names"] + .join(", ") + .replace(/qlc_/g, "") + .replace(/_qlever_internal_variable_query_planner/g, "") + .replace(/\?[A-Z_]*/g, function (match) { + return match.toLowerCase(); + }); + text["size"] = formatInteger(tree_node["result_rows"]) + " x " + formatInteger(tree_node["result_cols"]); + text["size-estimate"] = "[~ " + formatInteger(tree_node["estimated_size"]) + "]"; + text["cache-status"] = is_ancestor_cached + ? "ancestor_cached" + : tree_node["cache_status"] + ? tree_node["cache_status"] + : tree_node["was_cached"] + ? "cached_not_pinned" + : "computed"; + text["time"] = + tree_node["cache_status"] == "computed" || tree_node["was_cached"] == false + ? formatInteger(tree_node["operation_time"]) + : formatInteger(tree_node["original_operation_time"]); + text["cost-estimate"] = "[~ " + formatInteger(tree_node["estimated_operation_cost"]) + "]"; + text["status"] = tree_node["status"]; + if (text["status"] == "not started") { + text["status"] = "not yet started"; + } + text["total"] = text["time"]; + if (tree_node["details"]) { + text["details"] = JSON.stringify(tree_node["details"]); + } + + // Delete all other keys except "children" (we only needed them here to + // create a proper "text" element) and the "text" element. + for (var key in tree_node) { + if (key != "children") { + delete tree_node[key]; + } + } + tree_node["text"] = text; + + // Check out https://fperucic.github.io/treant-js + // TODO: Do we still need / want this? + tree_node["stackChildren"] = true; + + // Recurse over all children. Propagate "cached" status. + tree_node["children"].map((child) => + addTextElementsToExecTreeForTreant( + child, + is_ancestor_cached || text["cache-status"] != "computed" + ) + ); + } +} + +function formatInteger(number) { + return number.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,"); +} From e07a81afc0c044dab0df8fa87fd953022b6bd00a Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Tue, 10 Jun 2025 02:25:02 +0200 Subject: [PATCH 05/58] Second completed version with comapre-exec-trees page and site-wide error handling --- src/qlever/evaluation/www/compareExecTrees.js | 234 ++++++++++++++++++ src/qlever/evaluation/www/details.js | 79 ++++-- src/qlever/evaluation/www/index.html | 82 +++++- src/qlever/evaluation/www/main.js | 202 +++++++++------ src/qlever/evaluation/www/styles.css | 95 ++++--- src/qlever/evaluation/www/util.js | 47 +++- 6 files changed, 592 insertions(+), 147 deletions(-) create mode 100644 src/qlever/evaluation/www/compareExecTrees.js diff --git a/src/qlever/evaluation/www/compareExecTrees.js b/src/qlever/evaluation/www/compareExecTrees.js new file mode 100644 index 00000000..2d8b6098 --- /dev/null +++ b/src/qlever/evaluation/www/compareExecTrees.js @@ -0,0 +1,234 @@ +// Zoom settings +const baseTreeTextFontSize = 80; +const minimumZoomPercent = 30; +const maximumZoomPercent = 80; +const zoomChange = 10; + +function setCompareExecTreesEvents() { + // Events to handle drag and scroll horizontally on compareExecTrees page + for (const treeDiv of ["#result-tree", "#tree1", "#tree2"]) { + let isDragging = false; + let initialX = 0; + let initialY = 0; + let currentTreeDiv = null; + + const treeDivNode = document.querySelector(treeDiv); + + treeDivNode.addEventListener("mousedown", (e) => { + currentTreeDiv = treeDiv; + document.querySelector(currentTreeDiv).style.cursor = "grabbing"; + isDragging = true; + initialX = e.clientX; + initialY = e.clientY; + e.preventDefault(); + }); + treeDivNode.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 + treeDivNode.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; + }); + } + + // Event to handle zoom in/out of execution trees + document.querySelectorAll('[aria-label="CompareExecTrees zoom"]').forEach((node) => { + node.addEventListener("click", function (event) { + if (event.target.tagName === "BUTTON") { + const engine1 = document.querySelector("#select1").value; + const engine2 = document.querySelector("#select2").value; + if (!engine1 || !engine2) return; + const buttonId = event.target.id; + const purpose = buttonId.slice(0, -1); + const treeId = `#tree${buttonId.slice(-1)}`; + const currentFontSize = document + .querySelector(treeId) + .querySelector(".node[class*=font-size-]") + .className.match(/font-size-(\d+)/)[1]; + // Zoom in and out for both trees when sync option enabled + const kb = new URLSearchParams(window.location.hash.split("?")[1]).get("kb"); + const queryIdx = new URLSearchParams(window.location.hash.split("?")[1]).get("q"); + const runtimeInfo1 = performanceData[kb][engine1].queries[queryIdx].runtime_info; + const runtimeInfo2 = performanceData[kb][engine2].queries[queryIdx].runtime_info; + if (document.querySelector("#syncScrollCheck").checked) { + for (let [runtimeInfo, id] of [ + [runtimeInfo1, "1"], + [runtimeInfo2, "2"], + ]) { + renderExecTree( + runtimeInfo, + `#tree${id}`, + `#meta-info-${id}`, + purpose, + Number.parseInt(currentFontSize) + ); + } + } else { + let runtimeInfo = treeId === "#tree1" ? runtimeInfo1 : runtimeInfo2; + renderExecTree( + runtimeInfo, + `#tree${buttonId.slice(-1)}`, + `#meta-info-${buttonId.slice(-1)}`, + purpose, + Number.parseInt(currentFontSize) + ); + } + } + }); + }); +} + +/** + * 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; + } + } +} + +/** + * 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., "showTree", "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 === "showTree") { + 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; +} + +function addEventListenerToCompareExecTreesBtn(engineStatForQuery) { + document.querySelector("#compareExecTreesBtn").addEventListener("click", () => { + const select1Value = document.querySelector("#select1").value; + const select2Value = document.querySelector("#select2").value; + + if (!select1Value || !select2Value) { + alert("Please select both QLever instances before comparing."); + return; + } + + // Continue with comparison logic + let s1RuntimeTree = null; + let s2RuntimeTree = null; + for (const [ engine, stats ] of Object.entries(engineStatForQuery)) { + const runtimeInfo = stats.runtime_info; + if (engine === select1Value) { + s1RuntimeTree = runtimeInfo; + } + if (engine === select2Value) { + s2RuntimeTree = runtimeInfo; + } + } + + for (let [runtime_info, tree_idx] of [ + [s1RuntimeTree, "1"], + [s2RuntimeTree, "2"], + ]) { + renderExecTree(runtime_info, `#tree${tree_idx}`, `#meta-info-${tree_idx}`); + } + }); +} + +function populateSelect(selectEl, engines) { + // Clear existing options + selectEl.innerHTML = ""; + + // Add placeholder as the first option + const placeholderOption = document.createElement("option"); + placeholderOption.value = ""; + placeholderOption.disabled = true; + placeholderOption.selected = true; + placeholderOption.textContent = "Select a QLever instance"; + selectEl.appendChild(placeholderOption); + + // Add other options + engines.forEach((engine) => { + const optionEl = document.createElement("option"); + optionEl.value = engine; + optionEl.textContent = capitalize(engine); + selectEl.appendChild(optionEl); + }); +} + +function getEnginesWithExecTrees(performanceDataForKb) { + let execTreeEngines = []; + + for (let [engine, engineStat] of Object.entries(performanceDataForKb)) { + const queries = engineStat.queries; + for (const query of queries) { + if (Array.isArray(query.results)) { + if (!Object.hasOwn(query.runtime_info, "query_execution_tree")) break; + else { + // execTreeEngines.push({ engine: engine, stats: engineStat }); + execTreeEngines.push(engine); + break; + } + } + } + } + return execTreeEngines; +} + +function updateCompareExecTreesPage(kb, query, engineStatForQuery) { + const titleNode = document.querySelector("#compareExecTrees-title"); + const queryNode = document.querySelector("#compareExecQuery"); + const title = `Query Execution Tree comparison - ${capitalize(kb)}`; + + const queryTitle = `QUERY: ${query}`; + let sparql = null; + for (const engineStat of Object.values(engineStatForQuery)) { + if (engineStat.sparql) { + sparql = engineStat.sparql; + break; + } + } + + if (titleNode.innerHTML === title && queryNode.innerHTML === queryTitle) return; + titleNode.innerHTML = title; + queryNode.innerHTML = queryTitle; + queryNode.title = sparql; + + const engines = Object.keys(engineStatForQuery); + + for (const selectEl of document.querySelectorAll("#page-compareExecTrees select")) { + populateSelect(selectEl, engines); + } + addEventListenerToCompareExecTreesBtn(engineStatForQuery); +} diff --git a/src/qlever/evaluation/www/details.js b/src/qlever/evaluation/www/details.js index 76004004..f691eada 100644 --- a/src/qlever/evaluation/www/details.js +++ b/src/qlever/evaluation/www/details.js @@ -1,3 +1,27 @@ +let detailsGridApi = null; + +function setDetailsPageEvents() { + // Adds functionality to buttons in the modal footer for zooming in/out the execution tree + document.querySelector('[aria-label="Details zoom controls"]').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]; + const kb = new URLSearchParams(window.location.hash.split("?")[1]).get("kb"); + const engine = new URLSearchParams(window.location.hash.split("?")[1]).get("engine"); + const selectedNodes = detailsGridApi.getSelectedNodes(); + if (selectedNodes.length === 1) { + const queryIdx = detailsGridApi.getSelectedNodes()[0].rowIndex; + const runtime_info = performanceData[kb][engine].queries[queryIdx].runtime_info; + renderExecTree(runtime_info, "#result-tree", "#meta-info", purpose, Number.parseInt(currentFontSize)); + } + } + }); +} + /** * Extracts runtime and related query info for a given knowledge base and engine. * @@ -60,7 +84,7 @@ function getQueryResultsDict(headers, queryResults) { return queryResultsDict; } -class CustomDetailsTooltip { +class CustomDetailsTooltip { eGui; init(params) { const tooltipText = params.value || ""; @@ -93,7 +117,7 @@ function getQueryRuntimesColumnDefs() { field: "query", filter: "agTextColumnFilter", flex: 3, - tooltipValueGetter: params => { + tooltipValueGetter: (params) => { return params.data.sparql; }, tooltipComponent: CustomDetailsTooltip, @@ -126,9 +150,7 @@ function setTabsToDefault() { }); } -let currentTree = null; - -function renderExecTree(runtime_info) { +function renderExecTree(runtime_info, treeNodeId, metaNodeId, purpose = "showTree", currentFontSize) { // Show meta information (if it exists). const meta_info = runtime_info["meta"]; @@ -145,10 +167,10 @@ function renderExecTree(runtime_info) { const total_time_computing = "total_time_computing" in meta_info ? formatInteger(meta_info["total_time_computing"]) + " ms" : "N/A"; - // Inject meta info into the DOM - document.getElementById("meta-info").innerHTML = `

Time for query planning: ${time_query_planning}
- Time for index scans during query planning: ${time_index_scans_query_planning}
- Total time for computing the result: ${total_time_computing}

`; + // Inject meta info into the DOM + document.querySelector(metaNodeId).innerHTML = `

Time for query planning: ${time_query_planning}
+ Time for index scans during query planning: ${time_index_scans_query_planning}
+ Total time for computing the result: ${total_time_computing}

`; // Show the query execution tree (using Treant.js) addTextElementsToExecTreeForTreant(runtime_info["query_execution_tree"]); @@ -156,21 +178,18 @@ function renderExecTree(runtime_info) { const treant_tree = { chart: { - container: "#result-tree", + container: treeNodeId, rootOrientation: "NORTH", connectors: { type: "step" }, + node: { HTMLclass: "font-size-" + maximumZoomPercent }, }, nodeStructure: runtime_info["query_execution_tree"], }; - - - // Destroy previous tree if it exists - if (typeof currentTree !== "undefined" && currentTree !== null) { - currentTree.destroy(); - } + const newFontSize = getNewFontSizeForTree(treant_tree, purpose, currentFontSize); + treant_tree.chart.node.HTMLclass = "font-size-" + newFontSize.toString(); // Create new Treant tree - currentTree = new Treant(treant_tree); + new Treant(treant_tree); // Add tooltips with parsed .node-details info document.querySelectorAll("div.node").forEach(function (node) { @@ -260,6 +279,8 @@ function renderExecTree(runtime_info) { }); } +let exec_tree_listener = null; + function updateTabsWithSelectedRow(rowData) { console.log(rowData); const sparqlQuery = rowData?.sparql; @@ -279,17 +300,16 @@ function updateTabsWithSelectedRow(rowData) { } document.querySelector("#result-tree").innerHTML = ""; const exec_tree_tab = document.querySelector("#exec-tree-tab"); - exec_tree_tab.addEventListener( - "shown.bs.tab", - function () { - renderExecTree(runtime_info); - }, - { once: true } - ); + if (exec_tree_listener) exec_tree_tab.removeEventListener("shown.bs.tab", exec_tree_listener); + exec_tree_listener = () => { + renderExecTree(runtime_info, "#result-tree", "#meta-info"); + exec_tree_listener = null; + }; + exec_tree_tab.addEventListener("shown.bs.tab", exec_tree_listener, { once: true }); } else { - document.querySelector("#exec-tree-tab-pane div.alert-info").classList.add("d-none") + document.querySelector("#exec-tree-tab-pane div.alert-info").classList.add("d-none"); document.querySelector("#result-tree-div").classList.remove("d-none"); - document.querySelector("#result-tree-div div.alert-info").classList.remove("d-none") + document.querySelector("#result-tree-div div.alert-info").classList.remove("d-none"); } const headers = rowData?.headers; @@ -393,6 +413,9 @@ function updateDetailsPage(performanceData, kb, engine) { resizable: true, }, domLayout: domLayout, + onGridReady: (params) => { + detailsGridApi = params.api; + }, getRowStyle: (params) => { let rowStyle = { fontSize: "14px", cursor: "pointer" }; if (params.data.failed === true) { @@ -402,7 +425,9 @@ function updateDetailsPage(performanceData, kb, engine) { }, rowSelection: { mode: "singleRow", headerCheckbox: false, enableClickSelection: true }, onRowSelected: (event) => { - if (event.api.getSelectedRows() === selectedRow) return; + const query = Array.isArray(selectedRow) ? selectedRow[0].query : null; + if (event.api.getSelectedRows()[0].query === query) return; + selectedRow = event.api.getSelectedRows(); onRuntimeRowSelected(event, performanceData, kb, engine); }, tooltipShowDelay: 500, diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html index 116e0511..a22bef6b 100644 --- a/src/qlever/evaluation/www/index.html +++ b/src/qlever/evaluation/www/index.html @@ -19,10 +19,13 @@ + + + @@ -146,12 +149,18 @@
- Execution Tree is only available for QLever with accept header application/qlever-results+json! + Execution Tree is only available for QLever with accept header + application/qlever-results+json!
-
-
-
Please execute your query first.

+
+
+ +
+ + +
+
-
- - +
+ +
+ + + diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js index 7762e17e..75a780d0 100644 --- a/src/qlever/evaluation/www/main.js +++ b/src/qlever/evaluation/www/main.js @@ -96,6 +96,78 @@ function mainTableColumnDefs() { ]; } +function updateMainPage() { + const container = document.getElementById("main-table-container"); + + // Clear container if any existing content + container.innerHTML = ""; + + // For each knowledge base (kb) key in performanceData + for (const kb of Object.keys(performanceData)) { + // Create section wrapper + const section = document.createElement("div"); + section.className = "kg-section"; + + // Header with KB name and a dummy compare button + const header = document.createElement("div"); + header.className = "kg-header"; + + const title = document.createElement("h5"); + title.textContent = capitalize(kb); + title.style.fontWeight = "bold"; + + const compareBtn = document.createElement("button"); + compareBtn.className = "btn btn-outline-dark btn-sm"; + compareBtn.textContent = "Compare Results"; + compareBtn.onclick = () => { + router.navigate(`/comparison?kb=${encodeURIComponent(kb)}`); + }; + + header.appendChild(title); + header.appendChild(compareBtn); + + // Grid div with ag-theme-alpine styling + const gridDiv = document.createElement("div"); + gridDiv.className = "ag-theme-balham"; + gridDiv.style.width = "100%"; + + // Append header and grid div to section + section.appendChild(header); + section.appendChild(gridDiv); + container.appendChild(section); + + // Get table data from function you provided + const tableData = getAllQueryStatsByKb(performanceData, kb); + + // Prepare row data as array of objects for ag-grid + // tableData is {colName: [val, val, ...], ...} + // We convert to [{engine_name: ..., ameanTime: ..., ...}, ...] + const rowCount = tableData.engine_name.length; + const rowData = getGridRowData(rowCount, tableData); + + const onRowClicked = (event) => { + const engine = event.data.engine_name.toLowerCase(); + router.navigate(`/details?kb=${encodeURIComponent(kb)}&engine=${encodeURIComponent(engine)}`); + }; + + // Initialize ag-Grid instance + agGrid.createGrid(gridDiv, { + columnDefs: mainTableColumnDefs(), + rowData: rowData, + defaultColDef: { + sortable: true, + filter: true, + resizable: true, + flex: 1, + minWidth: 100, + }, + domLayout: "autoHeight", + rowStyle: { fontSize: "14px", cursor: "pointer" }, + onRowClicked: onRowClicked, + }); + } +} + document.addEventListener("DOMContentLoaded", async () => { router = new Navigo("/", { hash: true }); @@ -107,93 +179,81 @@ document.addEventListener("DOMContentLoaded", async () => { // Routes router .on({ - "/": () => showPage("main"), + "/": () => { + showPage("main"); + updateMainPage(performanceData); + }, "/details": (params) => { const kb = params.params.kb; const engine = params.params.engine; + if ( + !Object.keys(performanceData).includes(kb) || + !Object.keys(performanceData[kb]).includes(engine) + ) { + showPage( + "error", + `Query Details Page not found for ${engine} (${kb}) -> Make sure the url is correct!` + ); + return; + } updateDetailsPage(performanceData, kb, engine); showPage("details"); }, "/comparison": (params) => { const kb = params.params.kb; + if (!Object.keys(performanceData).includes(kb)) { + showPage( + "error", + `Performance Comparison Page not found for ${capitalize( + kb + )} -> Make sure the url is correct!` + ); + return; + } updateComparisonPage(performanceData, kb); showPage("comparison"); }, + "/compareExecTrees": (params) => { + const kb = params.params.kb; + const queryIdx = params.params.q; + if (!Object.keys(performanceData).includes(kb)) { + showPage( + "error", + `Query Execution Tree Page not found for ${capitalize(kb)} -> Make sure the url is correct!` + ); + return; + } + const queryToEngineStats = getQueryToEngineStatsDict(performanceData[kb]); + if ( + !parseInt(queryIdx) || + parseInt(queryIdx) < 0 || + parseInt(queryIdx) >= Object.keys(queryToEngineStats).length + ) { + showPage( + "error", + `Query Execution Tree Page not found as the requested query is not available for ${capitalize( + kb + )} -> Make sure the parameter q in the url is correct!` + ); + return; + } + const execTreeEngines = getEnginesWithExecTrees(performanceData[kb]); + const query = Object.keys(queryToEngineStats)[queryIdx]; + + const engineStatForQuery = Object.fromEntries( + Object.entries(queryToEngineStats[query]).filter(([engine]) => execTreeEngines.includes(engine)) + ); + updateCompareExecTreesPage(kb, query, engineStatForQuery); + showPage("compareExecTrees"); + }, }) .notFound(() => showPage("main")); router.resolve(); - const container = document.getElementById("main-table-container"); - - // Clear container if any existing content - container.innerHTML = ""; - - // For each knowledge base (kb) key in performanceData - for (const kb of Object.keys(performanceData)) { - // Create section wrapper - const section = document.createElement("div"); - section.className = "kg-section"; - - // Header with KB name and a dummy compare button - const header = document.createElement("div"); - header.className = "kg-header"; - - const title = document.createElement("h5"); - title.textContent = capitalize(kb); - title.style.fontWeight = "bold"; - - const compareBtn = document.createElement("button"); - compareBtn.className = "btn btn-outline-dark btn-sm"; - compareBtn.textContent = "Compare Results"; - compareBtn.onclick = () => { - router.navigate(`/comparison?kb=${encodeURIComponent(kb)}`); - }; - - header.appendChild(title); - header.appendChild(compareBtn); - - // Grid div with ag-theme-alpine styling - const gridDiv = document.createElement("div"); - gridDiv.className = "ag-theme-balham"; - gridDiv.style.width = "100%"; - - // Append header and grid div to section - section.appendChild(header); - section.appendChild(gridDiv); - container.appendChild(section); - - // Get table data from function you provided - const tableData = getAllQueryStatsByKb(performanceData, kb); - - // Prepare row data as array of objects for ag-grid - // tableData is {colName: [val, val, ...], ...} - // We convert to [{engine_name: ..., ameanTime: ..., ...}, ...] - const rowCount = tableData.engine_name.length; - const rowData = getGridRowData(rowCount, tableData); - - const onRowClicked = (event) => { - const engine = event.data.engine_name.toLowerCase(); - router.navigate(`/details?kb=${encodeURIComponent(kb)}&engine=${encodeURIComponent(engine)}`); - }; - - // Initialize ag-Grid instance - agGrid.createGrid(gridDiv, { - columnDefs: mainTableColumnDefs(), - rowData: rowData, - defaultColDef: { - sortable: true, - filter: true, - resizable: true, - flex: 1, - minWidth: 100, - }, - domLayout: "autoHeight", - rowStyle: { fontSize: "14px", cursor: "pointer" }, - onRowClicked: onRowClicked, - }); - } + setDetailsPageEvents(); + setCompareExecTreesEvents(); } catch (err) { console.error("Error loading /yaml_data:", err); - container.innerHTML = `
Failed to load data.
`; + showPage("error"); } }); diff --git a/src/qlever/evaluation/www/styles.css b/src/qlever/evaluation/www/styles.css index 149cd37d..7cbe4815 100644 --- a/src/qlever/evaluation/www/styles.css +++ b/src/qlever/evaluation/www/styles.css @@ -96,7 +96,9 @@ body { padding: 2px; } -#meta-info p { +#meta-info, +#meta-info-1, +#meta-info-2 p { font-size: 80%; } @@ -139,7 +141,7 @@ body { float: left; } -#result-tree .node { +.node { padding: 3px; border-radius: 3px; background-color: #fefefe; @@ -149,124 +151,143 @@ body { color: black; } -#result-tree .node > p { +.node > p { margin: 0; white-space: nowrap; } -#result-tree .node-name { +.node-name { font-weight: bold; } -#result-tree p.node-name { +p.node-name { width: 30em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#result-tree .node-status { +.node-status { font-size: 90%; color: #000099; } -#result-tree .node.not-started { +.node.not-started { color: #999; } -#result-tree p.fully-materialized { +p.fully-materialized { display: none; } -#result-tree p.lazily-materialized { +p.lazily-materialized { display: none; } -#result-tree .node-status.failed { +.node-status.failed { font-weight: bold; color: red; } -#result-tree .node-status.child-failed { +.node-status.child-failed { color: red; } -#result-tree .node-status.not-started { +.node-status.not-started { color: blue; } -#result-tree .node-status.optimized-out { +.node-status.optimized-out { color: blue; } -#result-tree .node-status.lazily-materialized { +.node-status.lazily-materialized { color: blue; } -#result-tree .node-cols:before { +.node-cols:before { content: "Cols: "; } -#result-tree p.node-cols { +p.node-cols { width: 30em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#result-tree .node-size:before { +.node-size:before { content: "Size: "; } -#result-tree .node-size { +.node-size { display: inline; } -#result-tree .node-size-estimate { +.node-size-estimate { display: inline; padding-left: 1em; } -#result-tree .node-time:before { +.node-time:before { content: "\ATime: "; white-space: pre; } -#result-tree .node-time { +.node-time { display: inline; } -#result-tree .node-cost-estimate { +.node-cost-estimate { display: inline; padding-left: 1em; } -#result-tree .node-status { +.node-status { display: inline; padding-left: 1em; } -#result-tree .node-time:after { +.node-time:after { content: "ms"; } -#result-tree .node-details { +.node-details { display: none; color: blue; } -#result-tree .node-details:before { +.node-details:before { content: "Details: "; } -#result-tree div.cached-not-pinned .node-time:after { +div.cached-not-pinned .node-time:after { content: "ms [cached]"; } -#result-tree div.cached-pinned .node-time:after { +div.cached-pinned .node-time:after { content: "ms [cached, pinned]"; } -#result-tree div.ancestor-cached .node-time:after { +div.ancestor-cached .node-time:after { content: "ms [ancestor cached]"; } -#result-tree p.node-cache-status { +p.node-cache-status { display: none; } -#result-tree .node.cached-pinned { +.node.cached-pinned { color: grey; border: 1px solid grey; } -#result-tree .node.cached-not-pinned { +.node.cached-not-pinned { color: grey; border: 1px solid grey; } -#result-tree .node-total { +.node-total { display: none; } -#result-tree .node.high { +.node.high { background-color: #ffeeee; } -#result-tree .node.veryhigh { +.node.veryhigh { background-color: #ffcccc; } -#result-tree .node.high.cached { +.node.high.cached { background-color: #ffffee; } -#result-tree .node.veryhigh.cached { +.node.veryhigh.cached { background-color: #ffffcc; } + +.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/util.js b/src/qlever/evaluation/www/util.js index 480d55f5..2bc708ce 100644 --- a/src/qlever/evaluation/www/util.js +++ b/src/qlever/evaluation/www/util.js @@ -40,7 +40,7 @@ function extractCoreValue(sparqlValue) { return sparqlValue; } -function showPage(pageId) { +function showPage(pageId, siteErrorMsg = null) { // Hide all pages document.querySelectorAll(".page").forEach((p) => { p.classList.remove("visible"); @@ -54,6 +54,9 @@ function showPage(pageId) { // Force reflow for transition to trigger void page.offsetWidth; page.classList.add("visible"); + if (pageId === "error" && siteErrorMsg !== null) { + document.querySelector("#siteErrorMsg").innerText = siteErrorMsg; + } } } @@ -170,10 +173,7 @@ function addTextElementsToExecTreeForTreant(tree_node, is_ancestor_cached = fals // Recurse over all children. Propagate "cached" status. tree_node["children"].map((child) => - addTextElementsToExecTreeForTreant( - child, - is_ancestor_cached || text["cache-status"] != "computed" - ) + addTextElementsToExecTreeForTreant(child, is_ancestor_cached || text["cache-status"] != "computed") ); } } @@ -181,3 +181,40 @@ function addTextElementsToExecTreeForTreant(tree_node, is_ancestor_cached = fals function formatInteger(number) { return number.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,"); } + +/** + * 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; +} From 02d02eab2de46854f5a50bad7f942a3035ae2109 Mon Sep 17 00:00:00 2001 From: tanmay-9 Date: Wed, 11 Jun 2025 00:52:33 +0200 Subject: [PATCH 06/58] First fully-completed version with all the functionalities --- src/qlever/evaluation/www/compareExecTrees.js | 102 +++++++++--------- src/qlever/evaluation/www/comparison.js | 37 +++++-- src/qlever/evaluation/www/details.js | 9 +- src/qlever/evaluation/www/index.html | 19 ++-- src/qlever/evaluation/www/main.js | 12 ++- src/qlever/evaluation/www/styles.css | 8 ++ 6 files changed, 111 insertions(+), 76 deletions(-) diff --git a/src/qlever/evaluation/www/compareExecTrees.js b/src/qlever/evaluation/www/compareExecTrees.js index 2d8b6098..843bf633 100644 --- a/src/qlever/evaluation/www/compareExecTrees.js +++ b/src/qlever/evaluation/www/compareExecTrees.js @@ -3,6 +3,7 @@ const baseTreeTextFontSize = 80; const minimumZoomPercent = 30; const maximumZoomPercent = 80; const zoomChange = 10; +let engineStatForQuery = null; function setCompareExecTreesEvents() { // Events to handle drag and scroll horizontally on compareExecTrees page @@ -93,6 +94,40 @@ function setCompareExecTreesEvents() { } }); }); + + document.querySelector("#compareExecTreesBtn").addEventListener("click", () => { + const select1Value = document.querySelector("#select1").value; + const select2Value = document.querySelector("#select2").value; + + if (!select1Value || !select2Value) { + alert("Please select both QLever instances before comparing."); + return; + } + if (!engineStatForQuery) { + showPage("error", "No Query Execution Tree Information found!"); + return; + } + + // Continue with comparison logic + let s1RuntimeTree = null; + let s2RuntimeTree = null; + for (const [engine, stats] of Object.entries(engineStatForQuery)) { + const runtimeInfo = stats.runtime_info; + if (engine === select1Value) { + s1RuntimeTree = runtimeInfo; + } + if (engine === select2Value) { + s2RuntimeTree = runtimeInfo; + } + } + + for (let [runtime_info, tree_idx] of [ + [s1RuntimeTree, "1"], + [s2RuntimeTree, "2"], + ]) { + renderExecTree(runtime_info, `#tree${tree_idx}`, `#meta-info-${tree_idx}`); + } + }); } /** @@ -134,55 +169,18 @@ function getNewFontSizeForTree(tree, purpose, currentFontSize) { return newFontSize; } -function addEventListenerToCompareExecTreesBtn(engineStatForQuery) { - document.querySelector("#compareExecTreesBtn").addEventListener("click", () => { - const select1Value = document.querySelector("#select1").value; - const select2Value = document.querySelector("#select2").value; - - if (!select1Value || !select2Value) { - alert("Please select both QLever instances before comparing."); - return; - } - - // Continue with comparison logic - let s1RuntimeTree = null; - let s2RuntimeTree = null; - for (const [ engine, stats ] of Object.entries(engineStatForQuery)) { - const runtimeInfo = stats.runtime_info; - if (engine === select1Value) { - s1RuntimeTree = runtimeInfo; - } - if (engine === select2Value) { - s2RuntimeTree = runtimeInfo; - } - } - - for (let [runtime_info, tree_idx] of [ - [s1RuntimeTree, "1"], - [s2RuntimeTree, "2"], - ]) { - renderExecTree(runtime_info, `#tree${tree_idx}`, `#meta-info-${tree_idx}`); - } - }); -} - -function populateSelect(selectEl, engines) { +function populateSelect(selectEl, engines, selectIndex) { // Clear existing options selectEl.innerHTML = ""; - // Add placeholder as the first option - const placeholderOption = document.createElement("option"); - placeholderOption.value = ""; - placeholderOption.disabled = true; - placeholderOption.selected = true; - placeholderOption.textContent = "Select a QLever instance"; - selectEl.appendChild(placeholderOption); - - // Add other options - engines.forEach((engine) => { + // Add engine options + engines.forEach((engine, index) => { const optionEl = document.createElement("option"); optionEl.value = engine; optionEl.textContent = capitalize(engine); + if (index === selectIndex) { + optionEl.selected = true; + } selectEl.appendChild(optionEl); }); } @@ -206,14 +204,14 @@ function getEnginesWithExecTrees(performanceDataForKb) { return execTreeEngines; } -function updateCompareExecTreesPage(kb, query, engineStatForQuery) { +function updateCompareExecTreesPage(kb, query, queryEngineStat) { const titleNode = document.querySelector("#compareExecTrees-title"); const queryNode = document.querySelector("#compareExecQuery"); const title = `Query Execution Tree comparison - ${capitalize(kb)}`; const queryTitle = `QUERY: ${query}`; let sparql = null; - for (const engineStat of Object.values(engineStatForQuery)) { + for (const engineStat of Object.values(queryEngineStat)) { if (engineStat.sparql) { sparql = engineStat.sparql; break; @@ -225,10 +223,14 @@ function updateCompareExecTreesPage(kb, query, engineStatForQuery) { queryNode.innerHTML = queryTitle; queryNode.title = sparql; - const engines = Object.keys(engineStatForQuery); - - for (const selectEl of document.querySelectorAll("#page-compareExecTrees select")) { - populateSelect(selectEl, engines); + for (let i = 1; i <= 2; i++) { + document.querySelector(`#meta-info-${i}`).innerHTML = ""; + document.querySelector(`#tree${i}`).innerHTML = ""; } - addEventListenerToCompareExecTreesBtn(engineStatForQuery); + + engineStatForQuery = queryEngineStat; + const engines = Object.keys(queryEngineStat); + + populateSelect(document.querySelector("#select1"), engines, 0); + populateSelect(document.querySelector("#select2"), engines, 1); } diff --git a/src/qlever/evaluation/www/comparison.js b/src/qlever/evaluation/www/comparison.js index b280392f..100c403f 100644 --- a/src/qlever/evaluation/www/comparison.js +++ b/src/qlever/evaluation/www/comparison.js @@ -30,12 +30,8 @@ function populateColumnCheckboxes(columnNames) { }); } -/** - * Attach a single change event listener to the container. - * Whenever any checkbox changes, it logs the currently checked values. - */ -function addEventListenerToCheckBoxContainer() { - document.getElementById("columnCheckboxContainer").addEventListener("change", (event) => { +function setComparisonPageEvents() { + document.querySelector("#columnCheckboxContainer").addEventListener("change", (event) => { if (event.target && event.target.matches('input[type="checkbox"]')) { const enginesToDisplay = Array.from( document.querySelectorAll('#columnCheckboxContainer input[type="checkbox"]:not(:checked)') @@ -45,9 +41,7 @@ function addEventListenerToCheckBoxContainer() { // You can now use selectedValues to hide/show columns, etc. } }); -} -function addEventListenerToResultSizeCheckbox() { document.querySelector("#showResultSize").addEventListener("change", (event) => { if (!gridApi) return; const showResultSize = event.target.checked; @@ -62,6 +56,18 @@ function addEventListenerToResultSizeCheckbox() { const visibleColumnDefs = getComparisonColumnDefs(enginesToDisplay, showResultSize); gridApi.setGridOption("columnDefs", visibleColumnDefs); }); + + document.querySelector("#goToCompareExecTreesBtn").addEventListener("click", () => { + if (!gridApi) return; + const selectedNode = gridApi.getSelectedNodes(); + if (selectedNode.length === 1) { + const selectedRowIdx = selectedNode[0].rowIndex; + const kb = new URLSearchParams(window.location.hash.split("?")[1]).get("kb"); + router.navigate(`/compareExecTrees?kb=${encodeURIComponent(kb)}&q=${selectedRowIdx}`); + } else { + alert("Please select a query from the Performance Comparison Table below!"); + } + }); } /** @@ -367,8 +373,14 @@ function updateComparisonPage(performanceData, kb) { populateColumnCheckboxes(Object.keys(performanceData[kb])); document.querySelector("#showResultSize").checked = false; - addEventListenerToCheckBoxContainer(); - addEventListenerToResultSizeCheckbox(); + let rowSelection = undefined; + const execTreeEngines = getEnginesWithExecTrees(performanceData[kb]); + if (execTreeEngines.length < 2) { + document.querySelector("#goToCompareExecTreesBtn").classList.add("d-none"); + } else { + document.querySelector("#goToCompareExecTreesBtn").classList.remove("d-none"); + rowSelection = { mode: "singleRow", headerCheckbox: false }; + } const tableData = getPerformanceComparisonPerKbDict(performanceData[kb]); const gridDiv = document.querySelector("#comparison-grid"); @@ -394,10 +406,13 @@ function updateComparisonPage(performanceData, kb) { }, domLayout: domLayout, rowStyle: { fontSize: "14px", cursor: "pointer" }, - onGridReady: params => {gridApi = params.api}, + onGridReady: (params) => { + gridApi = params.api; + }, tooltipShowDelay: 0, tooltipTrigger: "focus", tooltipInteraction: true, + rowSelection: rowSelection, }; // Initialize ag-Grid instance agGrid.createGrid(gridDiv, detailsGridOptions); diff --git a/src/qlever/evaluation/www/details.js b/src/qlever/evaluation/www/details.js index f691eada..75450728 100644 --- a/src/qlever/evaluation/www/details.js +++ b/src/qlever/evaluation/www/details.js @@ -167,14 +167,13 @@ function renderExecTree(runtime_info, treeNodeId, metaNodeId, purpose = "showTre const total_time_computing = "total_time_computing" in meta_info ? formatInteger(meta_info["total_time_computing"]) + " ms" : "N/A"; - // Inject meta info into the DOM + // Inject meta info into the DOM document.querySelector(metaNodeId).innerHTML = `

Time for query planning: ${time_query_planning}
Time for index scans during query planning: ${time_index_scans_query_planning}
Total time for computing the result: ${total_time_computing}

`; // Show the query execution tree (using Treant.js) addTextElementsToExecTreeForTreant(runtime_info["query_execution_tree"]); - console.log(runtime_info.query_execution_tree); const treant_tree = { chart: { @@ -359,9 +358,9 @@ function updateTabsWithSelectedRow(rowData) { const textDiv = document.querySelector("#results-container div.alert"); textDiv.classList.add("alert-danger"); textDiv.classList.remove("alert-secondary"); - textDiv.innerHTML = `Query failed in ${rowData.runtime_info.client_time.toFixed(2)} s with error:

${ - rowData.results - }`; + textDiv.innerHTML = `Query failed in ${rowData.runtime_info.client_time.toFixed( + 2 + )} s with error:

${rowData.results}`; } } diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html index a22bef6b..dcd2391a 100644 --- a/src/qlever/evaluation/www/index.html +++ b/src/qlever/evaluation/www/index.html @@ -181,17 +181,22 @@