diff --git a/src/qlever/commands/benchmark_queries.py b/src/qlever/commands/benchmark_queries.py index d50ef08b..f1fe0038 100644 --- a/src/qlever/commands/benchmark_queries.py +++ b/src/qlever/commands/benchmark_queries.py @@ -15,6 +15,7 @@ import yaml from termcolor import colored +from qlever import command_objects, script_name from qlever.command import QleverCommand from qlever.commands.clear_cache import ClearCacheCommand from qlever.commands.ui import dict_to_yaml @@ -29,19 +30,24 @@ class BenchmarkQueriesCommand(QleverCommand): """ def __init__(self): - pass + self.benchmark_name = None + self.benchmark_description = None def description(self) -> str: return ( "Run the given benchmark or example queries and show their " - "processing times and result sizes" + "processing times and result sizes. Optionally, store the " + "benchmark results in a YML file." ) def should_have_qleverfile(self) -> bool: return False def relevant_qleverfile_arguments(self) -> dict[str, list[str]]: - return {"server": ["host_name", "port"], "ui": ["ui_config"]} + return { + "server": ["host_name", "port", "timeout"], + "ui": ["ui_config"], + } def additional_arguments(self, subparser) -> None: subparser.add_argument( @@ -64,8 +70,8 @@ def additional_arguments(self, subparser) -> None: type=str, default=None, help=( - "Path to a TSV file containing benchmark queries " - "(query_description, full_sparql_query)" + "Path to a TSV file containing the benchmark queries " + "(short_query_name, full_sparql_query)" ), ) subparser.add_argument( @@ -73,11 +79,14 @@ def additional_arguments(self, subparser) -> None: type=str, default=None, help=( - "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 " - "description and 'sparql' for the full SPARQL query." + "Path to a YML file containing the benchmark queries. " + "The YAML must follow this structure ->" + "name: , " + "description: , " + "queries: where each query contains: " + "name: , " + "description , " + "query: " ), ) subparser.add_argument( @@ -142,10 +151,10 @@ def additional_arguments(self, subparser) -> None: help="Clear the cache before each query (only works for QLever)", ) subparser.add_argument( - "--width-query-description", + "--width-query-name", type=int, default=70, - help="Width for printing the query description", + help="Width for printing the query name", ) subparser.add_argument( "--width-error-message", @@ -164,7 +173,7 @@ def additional_arguments(self, subparser) -> None: action="store_true", default=False, help="Add the query type (SELECT, ASK, CONSTRUCT, DESCRIBE, " - "UNKNOWN) to the description", + "UNKNOWN) to the query description", ) subparser.add_argument( "--show-query", @@ -205,6 +214,30 @@ def additional_arguments(self, subparser) -> None: "YML file (Default = 5)" ), ) + subparser.add_argument( + "--benchmark-name", + type=str, + default=None, + help=( + "Benchmark name to be saved in result YML file (This will " + "override the 'name' field in --queries-yml file). This benchmark " + "name would be displayed as header title when comparing RDF Graph " + "Databases on the evaluation web app. Only relevant " + "when --result-file argument is passed." + ), + ) + subparser.add_argument( + "--benchmark-description", + type=str, + default=None, + help=( + "Benchmark description to be saved in result YML file (This " + "will override the 'description' field in --queries-yml file). " + "This benchmark description would be displayed as additional " + "help text on the evaluation web app for the given benchmark. " + "Only relevant when --result-file argument is passed." + ), + ) def pretty_printed_query(self, query: str, show_prefixes: bool) -> str: remove_prefixes_cmd = ( @@ -237,8 +270,8 @@ def sparql_query_type(self, query: str) -> str: @staticmethod def filter_queries( - queries: list[tuple[str, str]], query_ids: str, query_regex: str - ) -> list[tuple[str, str]]: + queries: list[tuple[str, str, str]], query_ids: str, query_regex: str + ) -> list[tuple[str, str, str]]: """ Given a list of queries (tuple of query desc and full sparql query), filter them and keep the ones which are a part of query_ids @@ -266,25 +299,29 @@ def filter_queries( if query_idx >= total_queries: continue - query_desc, sparql = queries[query_idx] + name, description, query = queries[query_idx] # Only include queries that match the query_regex if present if pattern and not ( - pattern.search(query_desc) or pattern.search(sparql) + pattern.search(name) + or pattern.search(description) + or pattern.search(query) ): continue - filtered_queries.append((query_desc, sparql)) + filtered_queries.append((name, description, query)) return filtered_queries except Exception as exc: log.error(f"Error filtering queries: {exc}") return [] @staticmethod - def parse_queries_tsv(queries_cmd: str) -> list[tuple[str, str]]: + def parse_queries_tsv(queries_cmd: str) -> list[tuple[str, str, str]]: """ Execute the given bash command to fetch tsv queries and return a - list of queries i.e. tuple(query_description, full_sparql_query) + list of queries i.e. tuple(query_name, "", full_sparql_query) + Note: query_description is returned as empty to match the return + structure of parse_queries_yml and for backward compatibility """ try: tsv_queries_str = run_command(queries_cmd, return_output=True) @@ -292,18 +329,20 @@ def parse_queries_tsv(queries_cmd: str) -> list[tuple[str, str]]: log.error("No queries found in the TSV queries file") return [] return [ - tuple(line.split("\t")) + (query_name, "", sparql_query) for line in tsv_queries_str.strip().splitlines() + for query_name, sparql_query in [line.split("\t", 1)] ] except Exception as exc: log.error(f"Failed to read the TSV queries file: {exc}") return [] - @staticmethod - def parse_queries_yml(queries_file: str) -> list[tuple[str, str]]: + def parse_queries_yml( + self, queries_file: str + ) -> list[tuple[str, str, str]]: """ Parse a YML file, validate its structure and return a list of - queries i.e. tuple(query_description, full_sparql_query) + queries i.e. tuple(query_name, query_description, full_sparql_query) """ with open(queries_file, "r", encoding="utf-8") as q_file: try: @@ -323,19 +362,27 @@ def parse_queries_yml(queries_file: str) -> list[tuple[str, str]]: log.error("Error: 'queries' key in YML file must hold a list.") return [] - for item in data["queries"]: + if name := data.get("name"): + self.benchmark_name = name + if description := data.get("description"): + self.benchmark_description = description + + queries = [] + for query in data["queries"]: if ( - not isinstance(item, dict) - or "query" not in item - or "sparql" not in item + not isinstance(query, dict) + or "query" not in query + or "name" not in query ): log.error( "Error: Each item in 'queries' must contain " - "'query' and 'sparql' keys." + "'name' and 'query' keys." ) return [] - - return [(query["query"], query["sparql"]) for query in data["queries"]] + queries.append( + (query["name"], query.get("description") or "", query["query"]) + ) + return queries def get_result_size( self, @@ -456,7 +503,7 @@ def execute(self, args) -> bool: return False # Extract dataset and sparql_engine name from result file - dataset, engine = None, None + dataset = engine = None if args.result_file is not None: result_file_parts = args.result_file.split(".") if len(result_file_parts) != 2: @@ -478,6 +525,11 @@ def execute(self, args) -> bool: ) results_dir_path.mkdir(parents=True, exist_ok=True) dataset, engine = result_file_parts + self.benchmark_name = dataset.capitalize() + self.benchmark_description = ( + f"{dataset.capitalize()} benchmark ran using {script_name} " + "benchmark-queries" + ) # If `args.accept` is `application/sparql-results+json` or # `application/qlever-results+json` or `AUTO`, we need `jq`. @@ -588,25 +640,52 @@ def execute(self, args) -> bool: # We want the width of the query description to be an uneven number (in # case we have to truncated it, in which case we want to have a " ... " # in the middle). - width_query_description_half = args.width_query_description // 2 - width_query_description = 2 * width_query_description_half + 1 + width_query_name_half = args.width_query_name // 2 + width_query_name = 2 * width_query_name_half + 1 + + try: + timeout = int(args.timeout[:-1]) + except ValueError: + timeout = None # Launch the queries one after the other and for each print: the # description, the result size (number of rows), and the query # processing time (seconds). query_times = [] result_sizes = [] - result_yml_query_records = {"queries": []} + if args.result_file: + result_yml_query_records = { + "name": self.benchmark_name, + "description": self.benchmark_description, + "queries": [], + } + if timeout: + result_yml_query_records["timeout"] = timeout + # Override the name and description if provided as args + if args.benchmark_name: + result_yml_query_records["name"] = args.benchmark_name + if args.benchmark_description: + result_yml_query_records["description"] = ( + args.benchmark_description + ) + + index_time, index_size = self.compute_index_stats(args) + result_yml_query_records["index_time"] = index_time + result_yml_query_records["index_size"] = index_size + num_failed = 0 - for description, query in filtered_queries: + for name, description, query in filtered_queries: if len(query) == 0: - log.error("Could not parse description and query, line is:") + log.error( + "Could not parse name, description and query, line is:" + ) log.info("") - log.info(f"{description}\t{query}") + log.info(f"{name}\t{description}\t{query}") return False query_type = self.sparql_query_type(query) if args.add_query_type_to_description or args.accept == "AUTO": - description = f"{description} [{query_type}]" + # If no query description, use name and append query type to it + description = f"{description or name} [{query_type}]" # Clear the cache. if args.clear_cache == "yes": @@ -756,10 +835,9 @@ def execute(self, args) -> bool: 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 - ), + name=name, + description=description, + query=self.pretty_printed_query(query, args.show_prefixes), client_time=time_seconds, result=query_results, result_size=result_length, @@ -768,12 +846,12 @@ def execute(self, args) -> bool: ) result_yml_query_records["queries"].append(query_record) - # Print description, time, result in tabular form. - if len(description) > width_query_description: - description = ( - description[: width_query_description_half - 2] + # Print name, time, result in tabular form. + if len(name) > width_query_name: + name = ( + name[: width_query_name_half - 2] + " ... " - + description[-width_query_description_half + 2 :] + + name[-width_query_name_half + 2 :] ) if error_msg is None: result_size = int(result_size) @@ -783,7 +861,7 @@ def execute(self, args) -> bool: else "" ) log.info( - f"{description:<{width_query_description}} " + f"{name:<{width_query_name}} " f"{time_seconds:6.2f} s " f"{result_size:>{args.width_result_size},}" f"{single_int_result}" @@ -806,7 +884,7 @@ def execute(self, args) -> bool: "\n" if args.show_query == "on-error" else " " ) log.info( - f"{description:<{width_query_description}} " + f"{name:<{width_query_name}} " f"{colored('FAILED ', 'red')}" f"{colored(error_msg['short'], 'red'):>{args.width_result_size}}" f"{seperator_short_long}" @@ -857,19 +935,19 @@ def execute(self, args) -> bool: description = f"TOTAL for {n} {query_or_queries}" log.info("") log.info( - f"{description:<{width_query_description}} " + f"{description:<{width_query_name}} " f"{total_query_time:6.2f} s " f"{total_result_size:>14,}" ) description = f"AVERAGE for {n} {query_or_queries}" log.info( - f"{description:<{width_query_description}} " + f"{description:<{width_query_name}} " f"{average_query_time:6.2f} s " f"{average_result_size:>14,}" ) description = f"MEDIAN for {n} {query_or_queries}" log.info( - f"{description:<{width_query_description}} " + f"{description:<{width_query_name}} " f"{median_query_time:6.2f} s " f"{median_result_size:>14,}" ) @@ -883,8 +961,7 @@ def execute(self, args) -> bool: num_failed_string += " [all]" log.info( colored( - f"{description:<{width_query_description}} " - f"{num_failed:>24}", + f"{description:<{width_query_name}} {num_failed:>24}", "red", ) ) @@ -892,10 +969,34 @@ def execute(self, args) -> bool: # Return success (has nothing to do with how many queries failed). return True + @staticmethod + def compute_index_stats(args) -> tuple[float | None, float | None]: + """ + Compute the index size (Bytes) and time (seconds) if available + """ + index_stats = command_objects["index-stats"] + # Set the args for index-stats command + args.time_unit = "s" + args.size_unit = "B" + args.ignore_text_index = False + index_time = index_size = None + index_log_file = next(Path.cwd().glob("*.index-log.txt"), None) + if index_log_file: + # index-stats needs args.name to get the text index filename + args.name = index_log_file.name.split(".")[0] + durations = index_stats.execute_time(args, index_log_file.name) + if len(durations) > 0 and "TOTAL time" in durations: + index_time = durations["TOTAL time"][0] + sizes = index_stats.execute_space(args) + if len(sizes) > 0 and "TOTAL size" in sizes: + index_size = sizes["TOTAL size"][0] + return index_time, index_size + def get_result_yml_query_record( self, + name: str, + description: str, query: str, - sparql: str, client_time: float, result: str | dict[str, str], result_size: int | None, @@ -906,8 +1007,9 @@ def get_result_yml_query_record( Construct a dictionary with query information for output result yaml file """ record = { + "name": name, + "description": description, "query": query, - "sparql": sparql, "runtime_info": {}, } if result_size is None: diff --git a/src/qlever/commands/index_stats.py b/src/qlever/commands/index_stats.py index 7f98746f..51712699 100644 --- a/src/qlever/commands/index_stats.py +++ b/src/qlever/commands/index_stats.py @@ -58,9 +58,12 @@ def additional_arguments(self, subparser) -> None: help="The size unit", ) - def execute_time(self, args, log_file_name) -> bool: + def execute_time( + self, args, log_file_name: str + ) -> dict[str, tuple[float | None, str]]: """ - Part of `execute` that shows the time used. + Part of `execute` that returns the time used for each part of indexing + along with the unit. """ # Read the content of `log_file_name` into a list of lines. @@ -69,19 +72,20 @@ def execute_time(self, args, log_file_name) -> bool: lines = log_file.readlines() except Exception as e: log.error(f"Problem reading index log file {log_file_name}: {e}") - return False + return {} # If there is a separate `add-text-index-log.txt` file, append those # lines. + text_log_file_name = f"{args.name}.text-index-log.txt" try: - text_log_file_name = f"{args.name}.text-index-log.txt" if Path(text_log_file_name).exists(): with open(text_log_file_name, "r") as text_log_file: lines.extend(text_log_file.readlines()) except Exception as e: log.error( - f"Problem reading text index log file " f"{text_log_file_name}: {e}" + f"Problem reading text index log file " + f"{text_log_file_name}: {e}" ) - return False + return {} # Helper function that finds the next line matching the given `regex`, # starting from `current_line`, and extracts the time. Returns a tuple @@ -95,7 +99,7 @@ def execute_time(self, args, log_file_name) -> bool: # line if no match is found. current_line = 0 - def find_next_line(regex, update_current_line=True): + def find_next_line(regex: str, update_current_line: bool = True): nonlocal lines nonlocal current_line current_line_backup = current_line @@ -109,7 +113,8 @@ def find_next_line(regex, update_current_line=True): if regex_match: try: return datetime.strptime( - re.match(timestamp_regex, line).group(), timestamp_format + re.match(timestamp_regex, line).group(), + timestamp_format, ), regex_match except Exception as e: log.error( @@ -167,18 +172,20 @@ def find_next_line(regex, update_current_line=True): # Check whether at least the first phase is done. if overall_begin is None: log.error("Missing line that index build has started") - return False + return {} if overall_begin and not merge_begin: log.error( "According to the log file, the index build " "has started, but is still in its first " "phase (parsing the input)" ) - return False + return {} - # Helper function that shows the duration for a phase (if the start and + # Helper function that computes the duration for a phase (if the start and # end timestamps are available). - def show_duration(heading, start_end_pairs): + def duration( + start_end_pairs: list[tuple[datetime | None, datetime | None]], + ) -> float | None: nonlocal time_unit num_start_end_pairs = 0 diff_seconds = 0 @@ -187,28 +194,32 @@ def show_duration(heading, start_end_pairs): diff_seconds += (end - start).total_seconds() num_start_end_pairs += 1 if num_start_end_pairs > 0: - if time_unit == "h": - diff = diff_seconds / 3600 - elif time_unit == "min": - diff = diff_seconds / 60 - else: - diff = diff_seconds - log.info(f"{heading:<21} : {diff:>6.1f} {time_unit}") + diff = diff_seconds / self.get_time_unit_factor(time_unit) + return diff + return None + # log.info(f"{heading:<21} : {diff:>6.1f} {time_unit}") # Get the times of the various phases (hours or minutes, depending on # how long the first phase took). - time_unit = args.time_unit - if time_unit == "auto": - time_unit = "h" - if merge_begin and overall_begin: - parse_duration = (merge_begin - overall_begin).total_seconds() - if parse_duration < 200: - time_unit = "s" - elif parse_duration < 3600: - time_unit = "min" - show_duration("Parse input", [(overall_begin, merge_begin)]) - show_duration("Build vocabularies", [(merge_begin, convert_begin)]) - show_duration("Convert to global IDs", [(convert_begin, convert_end)]) + parse_duration = None + if merge_begin and overall_begin: + parse_duration = (merge_begin - overall_begin).total_seconds() + time_unit = self.get_time_unit(args.time_unit, parse_duration) + + durations = {} + + durations["Parse input"] = ( + duration([(overall_begin, merge_begin)]), + time_unit, + ) + durations["Build vocabularies"] = ( + duration([(merge_begin, convert_begin)]), + time_unit, + ) + durations["Convert to global IDs"] = ( + duration([(convert_begin, convert_end)]), + time_unit, + ) for i in range(len(perm_begin_and_info)): perm_begin, perm_info = perm_begin_and_info[i] perm_end = ( @@ -217,65 +228,108 @@ def show_duration(heading, start_end_pairs): else normal_end ) perm_info_text = ( - perm_info.group(1).replace(" and ", " & ") if perm_info else f"#{i + 1}" + perm_info.group(1).replace(" and ", " & ") + if perm_info + else f"#{i + 1}" + ) + durations[f"Permutation {perm_info_text}"] = ( + duration([(perm_begin, perm_end)]), + time_unit, ) - show_duration(f"Permutation {perm_info_text}", [(perm_begin, perm_end)]) - show_duration("Text index", [(text_begin, text_end)]) + durations["Text index"] = ( + duration([(text_begin, text_end)]), + time_unit, + ) if text_begin and text_end: log.info("") - show_duration( - "TOTAL time", [(overall_begin, normal_end), (text_begin, text_end)] + durations["TOTAL time"] = ( + duration( + [(overall_begin, normal_end), (text_begin, text_end)] + ), + time_unit, ) elif normal_end: log.info("") - show_duration("TOTAL time", [(overall_begin, normal_end)]) - return True + durations["TOTAL time"] = ( + duration([(overall_begin, normal_end)]), + time_unit, + ) + return durations + + @staticmethod + def get_time_unit(time_unit: str, parse_duration: float | None) -> str: + if time_unit != "auto": + return time_unit + time_unit = "h" + if parse_duration is not None: + if parse_duration < 200: + time_unit = "s" + elif parse_duration < 3600: + time_unit = "min" + return time_unit + + @staticmethod + def get_time_unit_factor(time_unit: str) -> int: + unit_factor = { + "s": 1, + "min": 60, + "h": 3600, + }[time_unit] + + return unit_factor - def execute_space(self, args) -> bool: + def execute_space(self, args) -> dict[str, tuple[float, str]]: """ - Part of `execute` that shows the space used. + Part of `execute` that returns the space used by different types of + index along with the unit. """ - - # Get the sizes for the various groups of index files. - index_size = get_total_file_size([f"{args.name}.index.*"]) - vocab_size = get_total_file_size([f"{args.name}.vocabulary.*"]) - text_size = get_total_file_size([f"{args.name}.text.*"]) + sizes = {} + for size_type in ["index", "vocabulary", "text"]: + sizes[size_type] = get_total_file_size( + [f"{args.name}.{size_type}.*"] + ) if args.ignore_text_index: - text_size = 0 - total_size = index_size + vocab_size + text_size - - # Determing the proper unit for the size. - size_unit = args.size_unit - if size_unit == "auto": - size_unit = "TB" - if total_size < 1e6: - size_unit = "B" - elif total_size < 1e9: - size_unit = "MB" - elif total_size < 1e12: - size_unit = "GB" - - # Helper function for showing the size in a uniform way. - def show_size(heading, size): - nonlocal size_unit - if size_unit == "GB": - size /= 1e9 - elif size_unit == "MB": - size /= 1e6 - elif size_unit == "TB": - size /= 1e12 - if size_unit == "B": - log.info(f"{heading:<21} : {size:,} {size_unit}") - else: - log.info(f"{heading:<21} : {size:>6.1f} {size_unit}") + sizes["text"] = 0 + sizes["total"] = sum(sizes.values()) + + size_unit = self.get_size_unit(args.size_unit, sizes["total"]) + unit_factor = self.get_size_unit_factor(size_unit) + + for size_type in sizes: + sizes[size_type] /= unit_factor - show_size("Files index.*", index_size) - show_size("Files vocabulary.*", vocab_size) - if text_size > 0: - show_size("Files text.*", text_size) - log.info("") - show_size("TOTAL size", total_size) - return True + sizes_to_show = {} + + sizes_to_show["Files index.*"] = (sizes["index"], size_unit) + sizes_to_show["Files vocabulary.*"] = (sizes["vocabulary"], size_unit) + if sizes["text"] > 0: + sizes_to_show["Files text.*"] = (sizes["text"], size_unit) + sizes_to_show["TOTAL size"] = (sizes["total"], size_unit) + return sizes_to_show + + @staticmethod + def get_size_unit(size_unit: str, total_size: int) -> str: + if size_unit != "auto": + return size_unit + size_unit = "TB" + if total_size < 1e6: + size_unit = "B" + elif total_size < 1e9: + size_unit = "MB" + elif total_size < 1e12: + size_unit = "GB" + return size_unit + + @staticmethod + def get_size_unit_factor(size_unit: str) -> int: + unit_factor = { + "B": 1, + "MB": 1e6, + "GB": 1e9, + "TB": 1e12, + }[size_unit] + + return unit_factor def execute(self, args) -> bool: return_value = True @@ -290,7 +344,15 @@ def execute(self, args) -> bool: only_show=args.show, ) if not args.show: - return_value &= self.execute_time(args, log_file_name) + durations = self.execute_time(args, log_file_name) + for heading, (duration, time_unit) in durations.items(): + if duration is not None: + if heading == "TOTAL time": + log.info("") + log.info( + f"{heading:<21} : {duration:>6.1f} {time_unit}" + ) + return_value &= len(durations) != 0 if not args.only_time: log.info("") @@ -301,6 +363,14 @@ def execute(self, args) -> bool: only_show=args.show, ) if not args.show: - return_value &= self.execute_space(args) + sizes = self.execute_space(args) + for heading, (size, size_unit) in sizes.items(): + if heading == "TOTAL size": + log.info("") + if size_unit == "B": + log.info(f"{heading:<21} : {size:,} {size_unit}") + else: + log.info(f"{heading:<21} : {size:>6.1f} {size_unit}") + return_value &= len(sizes) != 0 return return_value diff --git a/src/qlever/commands/serve_evaluation_app.py b/src/qlever/commands/serve_evaluation_app.py new file mode 100644 index 00000000..fb07e7d8 --- /dev/null +++ b/src/qlever/commands/serve_evaluation_app.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import json +import statistics +from functools import partial +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +from typing import Any +from urllib.parse import unquote + +import yaml + +from qlever.command import QleverCommand +from qlever.log import log + +EVAL_DIR = Path(__file__).parent.parent / "evaluation" + + +PERFORMANCE_STATS_DICT = { + "ameanTime": None, + "gmeanTime2": None, + "gmeanTime10": None, + "medianTime": None, + "under1s": 0.0, + "between1to5s": 0.0, + "over5s": 0.0, + "failed": 0.0, +} + + +def get_performance_data(result_data: dict[str, Any]) -> dict[str, Any]: + queries = result_data.get("queries") + timeout = result_data.get("timeout") + performance_data = {stat: val for stat, val in PERFORMANCE_STATS_DICT.items()} + if not queries: + return performance_data + failed = under_1 = bw_1_to_5 = over_5 = 0 + runtimes_gm2 = [] + runtimes_gm10 = [] + + for query in queries: + # Have the old query and sparql keys to not break the web app + query["serverRestarted"] = bool(query.get("server_restarted")) + query["sparql"] = query.pop("query") + query["query"] = query.pop("name") + runtime = float(query["runtime_info"]["client_time"]) + if len(query["headers"]) == 0 and isinstance(query["results"], str): + failed += 1 + runtime_gm2 = runtime * 2 if timeout is None else timeout * 2 + runtime_gm10 = runtime * 10 if timeout is None else timeout * 10 + runtimes_gm2.append(runtime_gm2) + runtimes_gm10.append(runtime_gm10) + else: + if runtime <= 1: + under_1 += 1 + elif runtime > 5: + over_5 += 1 + else: + bw_1_to_5 += 1 + runtimes_gm2.append(runtime) + runtimes_gm10.append(runtime) + + performance_data["timeout"] = timeout + performance_data["indexTime"] = result_data.get("index_time") + performance_data["indexSize"] = result_data.get("index_size") + performance_data["ameanTime"] = statistics.mean(runtimes_gm2) + performance_data["gmeanTime2"] = statistics.geometric_mean(runtimes_gm2) + performance_data["gmeanTime10"] = statistics.geometric_mean(runtimes_gm10) + performance_data["medianTime"] = statistics.median(runtimes_gm2) + performance_data["failed"] = (failed / len(queries)) * 100 + performance_data["under1s"] = (under_1 / len(queries)) * 100 + performance_data["between1to5s"] = (bw_1_to_5 / len(queries)) * 100 + performance_data["over5s"] = (over_5 / len(queries)) * 100 + performance_data["queries"] = queries + return performance_data + + +def create_json_data(yaml_dir: Path | None, title: str) -> dict | None: + data = { + "performance_data": None, + "additional_data": { + "title": title, + "kbs": {}, + }, + } + performance_data = {} + if not yaml_dir or 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 result_file: + result_data = yaml.safe_load(result_file) + data["additional_data"]["kbs"][dataset] = { + "name": result_data.get("name"), + "description": result_data.get("description"), + "scale": result_data.get("scale"), + } + per_kb_engine_data = get_performance_data(result_data) + performance_data[dataset][engine] = {**per_kb_engine_data} + data["performance_data"] = performance_data + return data + + +class CustomHTTPRequestHandler(SimpleHTTPRequestHandler): + def __init__( + self, + *args, + yaml_dir: Path | None = None, + title: str = "RDF Graph Database Performance Evaluation", + **kwargs, + ) -> None: + self.yaml_dir = yaml_dir + self.title = title + super().__init__(*args, **kwargs) + + def do_GET(self) -> None: + path = unquote(self.path) + + if path == "/yaml_data": + try: + data = create_json_data(self.yaml_dir, self.title) + 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 web app for the RDF Graph " + "Database Performance Evaluation" + ) + + 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)" + ), + ) + subparser.add_argument( + "--title-overview-page", + type=str, + default="RDF Graph Database Performance Evaluation", + help="Title text displayed in the navigation bar of the Overview page.", + ) + + def execute(self, args) -> bool: + yaml_dir = Path(args.results_dir) + handler = partial( + CustomHTTPRequestHandler, + directory=EVAL_DIR, + yaml_dir=yaml_dir, + title=args.title_overview_page, + ) + 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/compareExecTrees.js b/src/qlever/evaluation/www/compareExecTrees.js new file mode 100644 index 00000000..10205d85 --- /dev/null +++ b/src/qlever/evaluation/www/compareExecTrees.js @@ -0,0 +1,237 @@ +// Zoom settings +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 + 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 (["BUTTON", "I"].includes(event.target.tagName)) { + const engine1 = document.querySelector("#select1").value; + const engine2 = document.querySelector("#select2").value; + if (!engine1 || !engine2) return; + const buttonId = event.target.closest("button").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) + ); + } + } + }); + }); + + 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}`); + } + }); +} + +/** + * 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 populateSelect(selectEl, engines, selectIndex) { + // Clear existing options + selectEl.innerHTML = ""; + + // 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); + }); +} + +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, queryEngineStat) { + const titleNode = document.querySelector("#main-page-header"); + 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(queryEngineStat)) { + if (engineStat.sparql) { + sparql = engineStat.sparql; + break; + } + } + + if (titleNode.innerHTML === title && queryNode.innerHTML === queryTitle) return; + removeTitleInfoPill(); + titleNode.innerHTML = title; + queryNode.innerHTML = queryTitle; + queryNode.title = sparql; + + for (let i = 1; i <= 2; i++) { + document.querySelector(`#meta-info-${i}`).innerHTML = ""; + document.querySelector(`#tree${i}`).innerHTML = ""; + } + + 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 new file mode 100644 index 00000000..5ed9baa1 --- /dev/null +++ b/src/qlever/evaluation/www/comparison.js @@ -0,0 +1,607 @@ +let gridApi; + +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"]:checked') + ).map((cb) => cb.value); + // console.log("Currently checked:", selectedValues); + updateHiddenColumns(enginesToDisplay); + // You can now use selectedValues to hide/show columns, etc. + } + }); + + document.querySelector("#orderColumnsDropdown").addEventListener("change", (event) => { + const selectedValue = event.target.value; + const [metric, order] = selectedValue.split("-"); + const kb = document.querySelector("#page-comparison").dataset.kb; + const enginesToDisplay = Array.from( + document.querySelectorAll('#columnCheckboxContainer input[type="checkbox"]:checked') + ).map((cb) => cb.value); + const sortedEngines = sortEngines(enginesToDisplay, kb, metric, order); + const showResultSize = document.querySelector("#showResultSize").checked; + const sortedColumnDefs = getComparisonColumnDefs(sortedEngines, showResultSize); + const showMetrics = document.querySelector("#showMetrics").checked; + sortedColumnDefs[0].headerName = showMetrics ? "Metric/Query" : "Query"; + const colState = gridApi.getColumnState(); + gridApi.updateGridOptions({ + columnDefs: sortedColumnDefs, + maintainColumnOrder: false, + }); + gridApi.applyColumnState({ + state: colState, + }); + }); + + document.querySelector("#showMetrics").addEventListener("change", (event) => { + if (!gridApi) return; + const showMetrics = event.target.checked; + const enginesToDisplay = gridApi + .getColumns() + .filter((col) => { + return col.colId !== "query"; + }) + .map((col) => { + return col.colId; + }); + const columnDefs = gridApi.getColumnDefs(); + let pinnedMetricData = []; + let queryHeader = "Query"; + if (showMetrics) { + const kb = document.querySelector("#page-comparison").dataset.kb; + pinnedMetricData = getPinnedMetricData(enginesToDisplay, kb); + queryHeader = "Metric/Query"; + } + columnDefs[0].headerName = queryHeader; + const colState = gridApi.getColumnState(); + gridApi.updateGridOptions({ + pinnedTopRowData: pinnedMetricData, + columnDefs: columnDefs, + }); + gridApi.applyColumnState({ + state: colState, + }); + }); + + 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); + const showMetrics = document.querySelector("#showMetrics").checked; + visibleColumnDefs[0].headerName = showMetrics ? "Metric/Query" : "Query"; + const colState = gridApi.getColumnState(); + gridApi.updateGridOptions({ + columnDefs: visibleColumnDefs, + maintainColumnOrder: true, + }); + gridApi.applyColumnState({ + state: colState, + }); + }); + + document.querySelector("#goToCompareExecTreesBtn").addEventListener("click", () => { + goToCompareExecTreesPage(gridApi, "Performance Comparison"); + }); + + document.querySelector("#comparisonDownloadTsv").addEventListener("click", () => { + const kb = document.querySelector("#page-comparison").dataset.kb; + if (!gridApi) { + alert(`The evaluation results table for ${kb} could not be downloaded!`); + return; + } + gridApi.exportDataAsCsv({ + fileName: `${kb}_evaluation_results.tsv`, + columnSeparator: "\t", + }); + }); +} + +/** + * 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 = document.querySelector("#page-comparison").dataset.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 [metric, order] = document.querySelector("#orderColumnsDropdown").value.split("-"); + const sortedEngines = sortEngines(enginesToDisplay, kb, metric, order); + const visibleColumnDefs = getComparisonColumnDefs(sortedEngines, showResultSize); + const showMetrics = document.querySelector("#showMetrics").checked; + visibleColumnDefs[0].headerName = showMetrics ? "Metric/Query" : "Query"; + const colState = gridApi.getColumnState(); + gridApi.updateGridOptions({ + columnDefs: visibleColumnDefs, + rowData: visibleRowData, + maintainColumnOrder: false, + }); + gridApi.applyColumnState({ + state: colState, + }); +} + +class WarningCellRenderer { + init(params) { + const value = params.value; + const container = document.createElement("div"); + container.style.whiteSpace = "normal"; + + const warning = getWarningSpan("bi-exclamation-triangle-fill"); + + if (params.node.rowPinned) { + container.classList.add("fw-bold"); + let textValue = "N/A"; + if (typeof value === "string") { + textValue = value; + } else if (typeof value === "number") { + const unit = params.data.query === "Failed Queries" ? "%" : "s"; + textValue = `${value.toFixed(2)} ${unit}`; + } + container.appendChild(document.createTextNode(textValue)); + } else if (params.column.getColId() === "query") { + if (params.data.row_warning) { + warning.title = "The result sizes for the engines do not match!"; + container.appendChild(warning); + } + container.appendChild(document.createTextNode(`${value}`)); + } else { + const engine = params.column.getColId(); + const kb = document.querySelector("#page-comparison").dataset.kb; + const timeout = performanceData[kb][engine].timeout; + const engineStatsColumn = engine + "_stats"; + const engineStats = params.data[engineStatsColumn]; + let cellValue = `${value} s`; + if (engineStats && typeof engineStats === "object") { + if (engineStats.size_warning) { + container.appendChild(warning); + } + if (typeof engineStats.results === "string") { + cellValue = timeout && value >= timeout ? "timeout" : "failed"; + if (engineStats.serverRestarted) { + container.appendChild(getWarningSpan("bi-bootstrap-reboot")); + } + } + } + container.appendChild(document.createTextNode(cellValue)); + 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; + + function getWarningSpan(cls) { + const warning = document.createElement("i"); + warning.className = `bi ${cls} me-2`; + return warning; + } + } + + 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: "var(--bs-danger-border-subtle)" }; + } else if (engineStats.has_best_runtime) { + return { backgroundColor: "var(--bs-success-border-subtle)" }; + } + } + 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 { title: value.description || "", sparql: value.sparql || "" }; + } + } + return null; + } + const engine = params.column.getColId(); + const kb = document.querySelector("#page-comparison").dataset.kb; + const timeout = performanceData[kb][engine].timeout; + const engineStatsColumn = engine + "_stats"; + const engineStats = params.data[engineStatsColumn]; + + if (engineStats && typeof engineStats === "object") { + let tooltipLines = []; + if (typeof engineStats.results === "string") { + const runtime = params.value; + const serverRestarted = engineStats.serverRestarted; + + const isTimeout = timeout && runtime >= timeout; + + if (isTimeout) { + tooltipLines.push(`Query timed out after ${runtime} s`); + + if (serverRestarted) { + if (runtime >= timeout + 30) { + tooltipLines.push( + "Server was restarted after this query due to no response after timeout + 30s!" + ); + } else { + tooltipLines.push("Server was restarted after this query because the server crashed!"); + } + } + } else { + tooltipLines.push(`Query failed in ${runtime} s`); + + if (serverRestarted) { + tooltipLines.push("Server was restarted after this query because the server crashed!"); + } + } + + tooltipLines.push(engineStats.results); // add results after an empty line + } else { + if (engineStats.size_warning) { + let resultSize = engineStats.result_size_to_display; + if (resultSize.startsWith("1 [")) { + resultSize = resultSize.slice(3, -1); + } + tooltipLines.push( + `Result size ${resultSize} doesn't match the majority ${engineStats.majority_result_size}!` + ); + } + tooltipLines.push(`Result size: ${engineStats.result_size_to_display}`); + } + return tooltipLines.join("\n\n"); + } + return null; +} + +class CustomTooltip { + init(params) { + const container = createTooltipContainer(params); + + const tooltipText = typeof params.value !== "string" ? params.value.sparql : params.value; + + if (window.isSecureContext) { + // Copy button + const copyButton = document.createElement("button"); + // copyButton.innerHTML = "copy"; + copyButton.className = "copy-btn btn-sm"; + copyButton.title = "Copy"; + + const copyIcon = document.createElement("i"); + copyIcon.className = "bi bi-copy"; + copyButton.appendChild(copyIcon); + + copyButton.onclick = () => { + navigator.clipboard + .writeText(tooltipText) + .then(() => { + copyIcon.className = "bi bi-check-circle-fill"; // success + setTimeout(() => (copyIcon.className = "bi bi-copy"), 1000); + }) + .catch((err) => { + console.error("Failed to copy:", err); + copyIcon.className = "bi bi-x-circle-fill"; // failure + setTimeout(() => (copyIcon.className = "bi bi-copy"), 1000); + }); + }; + + container.appendChild(copyButton); + } + + this.eGui = container; + } + + getGui() { + return this.eGui; + } +} + +function getPinnedMetricData(engines, kb) { + let pinnedMetricData = []; + const metricKeyNameObj = { + gmeanTime2: "Geometric Mean (P=2)", + gmeanTime10: "Geometric Mean (P=10)", + failed: "Failed Queries", + medianTime: "Median (P=2)", + ameanTime: "Arithmetic Mean (P=2)", + indexTime: "Index Time", + indexSize: "Index Size", + }; + for (const [metric, metricName] of Object.entries(metricKeyNameObj)) { + let metricData = { query: metricName }; + for (const engine of engines) { + metricData[engine] = performanceData[kb][engine][metric]; + } + pinnedMetricData.push(metricData); + } + return pinnedMetricData; +} + +/** + * 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, + minWidth: 120, + }, + ]; + const kb = document.querySelector("#page-comparison").dataset.kb; + for (const engine of engines) { + const timeout = performanceData[kb][engine].timeout; + columnDefs.push({ + field: engine, + type: "numericColumn", + filter: "agNumberColumnFilter", + filterValueGetter: (params) => { + let value = params.data[engine]; + const engineStats = params.data[`${engine}_stats`]; + if (typeof engineStats.results === "string") { + value = timeout && value >= timeout ? timeout * 1000 : timeout * 100; + } + return value; + }, + filterParams: { + allowedCharPattern: "0-9\\.adefilmotuADEFILMOTU\\s", + numberParser: (text) => { + if (text == null) return null; + const lower = text.toLowerCase().trim(); + if ("failed".includes(lower)) return timeout * 100; + if ("timeout".includes(lower)) return timeout * 1000; + return parseFloat(text); + }, + numberFormatter: (value) => { + if (value == null) return null; + if (value === timeout * 100) return "failed"; + if (value === timeout * 1000) return "timeout"; + return value.toString(); + }, + }, + flex: 1, + cellRenderer: WarningCellRenderer, + cellRendererParams: { showResultSize: showResultSize }, + cellStyle: comparisonGridCellStyle, + autoHeight: true, + tooltipValueGetter: getTooltipValue, + tooltipComponent: CustomTooltip, + }); + } + return columnDefs; +} + +function updateComparisonPage(performanceData, kb, kbAdditionalData) { + const pageNode = document.querySelector("#page-comparison"); + const lastKb = pageNode.dataset.kb; + removeTitleInfoPill(); + const titleNode = document.querySelector("#main-page-header"); + let kbHeader = kbAdditionalData?.name || capitalize(kb); + let title = `Performance Evaluation for ${kbHeader}`; + let infoPill = null; + if (kbAdditionalData.description) { + infoPill = createBenchmarkDescriptionInfoPill(kbAdditionalData.description, "bottom"); + } + titleNode.innerHTML = title; + if (infoPill) { + titleNode.appendChild(infoPill); + new bootstrap.Popover(infoPill); + } + if (lastKb === kb) return; + pageNode.dataset.kb = kb; + document.querySelector("#orderColumnsDropdown").selectedIndex = 0; + + // populateColumnCheckboxes(Object.keys(performanceData[kb])); + const showEnginesContainer = document.querySelector("#columnCheckboxContainer"); + showEnginesContainer.innerHTML = ""; + const showEnginesColumns = Object.fromEntries( + Object.keys(performanceData[kb]).map((engine) => [engine, capitalize(engine)]) + ); + showEnginesContainer.appendChild(getColumnVisibilityMultiSelectFragment(showEnginesColumns)); + document.querySelector("#showResultSize").checked = false; + document.querySelector("#showMetrics").checked = false; + 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"); + + const rowCount = tableData.query.length; + const rowData = getGridRowData(rowCount, tableData); + gridDiv.innerHTML = ""; + + gridDiv.style.height = `${document.documentElement.clientHeight - 235}px`; + + // Default column ordering = first option of orderColumnsDropdown + const sortedEngines = sortEngines(Object.keys(performanceData[kb]), kb, "gmeanTime2", "asc"); + const comparisonGridOptions = { + columnDefs: getComparisonColumnDefs(sortedEngines), + rowData: rowData, + defaultColDef: { + sortable: true, + filter: true, + resizable: true, + flex: 1, + minWidth: 90, + }, + domLayout: "normal", + rowStyle: { fontSize: "clamp(12px, 1vw + 8px, 14px)", cursor: "pointer" }, + onGridReady: (params) => { + gridApi = params.api; + }, + tooltipShowDelay: 0, + tooltipTrigger: "focus", + tooltipInteraction: true, + rowSelection: rowSelection, + suppressDragLeaveHidesColumns: true, + }; + // Initialize ag-Grid instance + agGrid.createGrid(gridDiv, comparisonGridOptions); +} diff --git a/src/qlever/evaluation/www/details.js b/src/qlever/evaluation/www/details.js new file mode 100644 index 00000000..9edddd3a --- /dev/null +++ b/src/qlever/evaluation/www/details.js @@ -0,0 +1,326 @@ +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)); + } + } + }); + + document.querySelector("#detailsCompareExecTreesBtn").addEventListener("click", () => { + goToCompareExecTreesPage(detailsGridApi, "Query Runtimes"); + }); +} + +/** + * 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: [], + description: [], + sparql: [], + runtime: [], + failed: [], + result_size: [], + }; + + for (const queryData of allQueriesData) { + queryRuntimes.query.push(queryData.query); + queryRuntimes.description.push(queryData.description || ""); + 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 container = createTooltipContainer(params); + 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() { + let columnDefs = [ + { + headerName: "SPARQL Query", + field: "query", + filter: "agTextColumnFilter", + flex: 3, + tooltipValueGetter: (params) => { + return { title: params.data.description || "", sparql: 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` : ""), + }, + ]; + if (window.matchMedia("(min-width: 768px)").matches) { + columnDefs.push( + ...[ + { + headerName: "Result Size", + field: "result_size", + type: "numericColumn", + filter: "agTextColumnFilter", + flex: 1.5, + }, + ] + ); + } + return columnDefs; +} + +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 exec_tree_listener = null; + +function updateTabsWithSelectedRow(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"); + } + const queryTitle = rowData?.description; + if (queryTitle) { + document.querySelector("#query-title").innerHTML = queryTitle; + document.querySelector("#query-title").className = "fw-bold pb-3"; + } + 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"); + 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("#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: "clamp(12px, 1vw + 8px, 14px)", cursor: "pointer" }, + suppressDragLeaveHidesColumns: true, + }); + } 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]); + } else { + setTabsToDefault(); + } +} + +function updateDetailsPage(performanceData, kb, engine, kbAdditionalData) { + const pageNode = document.querySelector("#page-details"); + removeTitleInfoPill(); + let engineHeader = capitalize(engine); + if (engineHeader === "Qlever") engineHeader = "QLever"; + let kbHeader = kbAdditionalData?.name || capitalize(kb); + const titleNode = document.querySelector("#main-page-header"); + titleNode.innerHTML = `Per-query results for ${engineHeader} on ${kbHeader}`; + if (pageNode.dataset.kb === kb && pageNode.dataset.engine === engine) return; + pageNode.dataset.kb = kb; + pageNode.dataset.engine = engine; + + setTabsToDefault(); + const tab = new bootstrap.Tab(document.querySelector("#runtimes-tab")); + tab.show(); + + const execTreeEngines = getEnginesWithExecTrees(performanceData[kb]); + if (execTreeEngines.length < 2 || !execTreeEngines.includes(engine)) { + document.querySelector("#detailsCompareExecTreesBtn").classList.add("d-none"); + } else { + document.querySelector("#detailsCompareExecTreesBtn").classList.remove("d-none"); + } + + const tableData = getQueryRuntimes(performanceData, kb, engine); + const gridDiv = document.querySelector("#details-grid"); + + const rowCount = tableData.query.length; + const rowData = getGridRowData(rowCount, tableData); + gridDiv.innerHTML = ""; + + gridDiv.style.height = `${document.documentElement.clientHeight - 150}px`; + + let selectedRow = null; + const detailsGridOptions = { + columnDefs: getQueryRuntimesColumnDefs(), + rowData: rowData, + defaultColDef: { + sortable: true, + filter: true, + resizable: true, + }, + domLayout: "normal", + onGridReady: (params) => { + detailsGridApi = params.api; + }, + getRowStyle: (params) => { + let rowStyle = { fontSize: "clamp(12px, 1vw + 8px, 14px)", cursor: "pointer" }; + if (params.data.failed === true) { + rowStyle.backgroundColor = "var(--bs-danger-border-subtle)"; + } + return rowStyle; + }, + rowSelection: { mode: "singleRow", headerCheckbox: false, enableClickSelection: true }, + onRowSelected: (event) => { + 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: 1500, + suppressDragLeaveHidesColumns: true, + }; + // Initialize ag-Grid instance + agGrid.createGrid(gridDiv, detailsGridOptions); +} diff --git a/src/qlever/evaluation/www/index.html b/src/qlever/evaluation/www/index.html new file mode 100644 index 00000000..d7c97f05 --- /dev/null +++ b/src/qlever/evaluation/www/index.html @@ -0,0 +1,566 @@ + + + + + + RDF Graph Database Performance Evaluation + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Loading... +
+ Fetching evaluation results +
+ +
+ +
+
+
+

+ +

+
+
+

+ Each table shows the aggregate metrics for all RDF graph databases + evaluated on the given benchmark. +

+
    +
  • + Column headers: Hover over any header to see detailed + explanations of the metrics, including how failed queries are penalized. +
  • +
  • + Sorting: Click a column header to sort by that metric. Hold + Shift and click multiple headers to sort by more than + one column in order. +
  • +
  • + Filtering: Each column has a built-in filter menu with powerful + options to quickly narrow down the results. +
  • +
  • + Column resizing: Drag the edges of column headers to resize. + Hold Shift while resizing to change the column width + without shifting neighboring columns. +
  • +
  • + Column reordering: Drag and drop column headers to reorder + them. +
  • +
  • + Rows: Click a row to view the individual query details + for that system on the given benchmark. +
  • +
  • + Compare results: Use the compare button to see a per-query + performance comparison of all systems. +
  • +
  • + Download: Export the table contents as TSV for + offline analysis or reporting. +
  • +
+
+
+
+
+ + +
+ +
+
+ + + + + + + + + diff --git a/src/qlever/evaluation/www/main.js b/src/qlever/evaluation/www/main.js new file mode 100644 index 00000000..3837f848 --- /dev/null +++ b/src/qlever/evaluation/www/main.js @@ -0,0 +1,500 @@ +const mainGridApis = {}; +const engineMetrics = { + gmeanTime2: "Geom. Mean (P=2)", + gmeanTime10: "Geom. Mean (P=10)", + medianTime: "Median (P=2)", + ameanTime: "Arith. Mean (P=2)", + indexTime: "Index time", + indexSize: "Index size", + failed: "Failed", + under1s: "<= 1s", + between1to5s: "(1s, 5s]", + over5s: "> 5s", +}; + +/** + * 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 + Object.keys(engineMetrics).forEach((key) => { + enginesDictForTable[key] = []; + }); + + for (const [engine, engineStats] of Object.entries(enginesDict)) { + enginesDictForTable.engine_name.push(capitalize(engine)); + for (const metricKey of Object.keys(engineMetrics)) { + enginesDictForTable[metricKey].push(engineStats[metricKey]); + } + } + return enginesDictForTable; +} + +function setMainPageEvents() { + const showMetricsContainer = document.querySelector("#showMetricsContainer"); + showMetricsContainer.addEventListener("change", (event) => { + if (event.target && event.target.matches('input[type="checkbox"]')) { + const metricsToDisplay = Array.from( + showMetricsContainer.querySelectorAll('input[type="checkbox"]:checked') + ).map((cb) => cb.value); + const metricsToHide = Object.keys(engineMetrics).filter((metric) => !metricsToDisplay.includes(metric)); + for (const mainGridApi of Object.values(mainGridApis)) { + mainGridApi.setColumnsVisible(metricsToDisplay, true); + mainGridApi.setColumnsVisible(metricsToHide, false); + } + } + }); +} + +/** + * Returns ag-Grid gridOptions for comparing SPARQL engines for a given knowledge base. + * + * 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 mainTableColumnDefs() { + // Define custom formatting and filters based on column keys + return [ + { + headerName: "System", + field: "engine_name", + filter: "agTextColumnFilter", + headerTooltip: "Name of the RDF graph database being benchmarked.", + tooltipComponent: CustomDetailsTooltip, + flex: 1.1, + }, + { + headerName: "Geom. Mean (P=2)", + field: "gmeanTime2", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)} s` : "N/A"), + headerTooltip: `Geometric mean of all query runtimes. Failed queries are penalized with a runtime of timeout × 2`, + tooltipComponent: CustomDetailsTooltip, + flex: 1.5, + }, + { + headerName: "Geom. Mean (P=10)", + field: "gmeanTime10", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)} s` : "N/A"), + headerTooltip: `Geometric mean of all query runtimes. Failed queries are penalized with a runtime of timeout × 10`, + tooltipComponent: CustomDetailsTooltip, + flex: 1.5, + }, + { + headerName: "Median (P=2)", + field: "medianTime", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)} s` : "N/A"), + headerTooltip: `Median runtime of all queries. Failed queries are penalized with a runtime of timeout × 2`, + tooltipComponent: CustomDetailsTooltip, + flex: 1.25, + }, + { + headerName: "Arith. Mean (P=2)", + field: "ameanTime", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)} s` : "N/A"), + headerTooltip: `Arithmetic mean of all query runtimes. Failed queries are penalized with a runtime of timeout × 2`, + tooltipComponent: CustomDetailsTooltip, + flex: 1.4, + }, + { + headerName: "Index time", + field: "indexTime", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? value : "N/A"), + headerTooltip: `Total indexing time for the system on the benchmark dataset`, + tooltipComponent: CustomDetailsTooltip, + flex: 1, + }, + { + headerName: "Index size", + field: "indexSize", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? value : "N/A"), + headerTooltip: `Total index size used by the system for the benchmark dataset`, + tooltipComponent: CustomDetailsTooltip, + flex: 1, + }, + { + headerName: "Failed", + field: "failed", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)} %` : "N/A"), + headerTooltip: "Percentage of queries that failed to return results.", + tooltipComponent: CustomDetailsTooltip, + flex: 1, + }, + { + headerName: "<= 1s", + field: "under1s", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)} %` : "N/A"), + headerTooltip: "Percentage of all queries that successfully finished in 1 second or less", + tooltipComponent: CustomDetailsTooltip, + flex: 1, + }, + { + headerName: "(1s, 5s]", + field: "between1to5s", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)} %` : "N/A"), + headerTooltip: + "Percentage of all queries that successfully completed in more than 1 second and up to 5 seconds", + tooltipComponent: CustomDetailsTooltip, + flex: 1, + }, + { + headerName: "> 5s", + field: "over5s", + filter: "agNumberColumnFilter", + type: "numericColumn", + valueFormatter: ({ value }) => (value != null ? `${value.toFixed(2)} %` : "N/A"), + headerTooltip: "Percentage of all queries that successfully completed in more than 5 seconds", + tooltipComponent: CustomDetailsTooltip, + flex: 1, + }, + ]; +} + +function updateMainPage(performanceData, additionalData) { + document.querySelector("#main-page-header").innerHTML = additionalData.title; + const container = document.getElementById("main-table-container"); + removeTitleInfoPill(); + + // Clear container if any existing content + container.innerHTML = ""; + const fragment = document.createDocumentFragment(); + + const sortedKbNames = Object.entries(additionalData.kbs) + .sort(([keyA, kbA], [keyB, kbB]) => { + const scaleA = kbA?.scale ?? 0; + const scaleB = kbB?.scale ?? 0; + const nameA = kbA?.name ?? ""; + const nameB = kbB?.name ?? ""; + + if (scaleB !== scaleA) return scaleA - scaleB; + return nameA.localeCompare(nameB); + }) + .map(([key, _kb]) => key); + + const showMetricsContainer = document.querySelector("#showMetricsContainer"); + showMetricsContainer.innerHTML = ""; + showMetricsContainer.appendChild(getColumnVisibilityMultiSelectFragment(engineMetrics)); + + // For each knowledge base (kb) key in performanceData + for (const kb of sortedKbNames) { + // Create section wrapper + const section = document.createElement("div"); + section.className = "mb-4"; + + // Header with KB name and a compare button + const header = document.createElement("div"); + header.className = "kg-header px-2 px-md-0"; + + // const titleWrapper = document.createElement("div"); + // titleWrapper.className = "d-inline-flex align-items-center"; + + const benchmarkDescription = additionalData.kbs[kb].description; + const benchmarkName = additionalData.kbs[kb].name; + + const title = document.createElement("div"); + title.textContent = benchmarkName || capitalize(kb); + // title.style.fontWeight = "bold"; + title.classList.add("fs-5", "fw-bold", "mb-1"); + + let infoPill = null; + if (benchmarkDescription) { + infoPill = createBenchmarkDescriptionInfoPill(benchmarkDescription); + } + + const btnGroup = document.createElement("div"); + btnGroup.className = "d-flex align-items-center gap-2"; + + const downloadBtn = document.createElement("button"); + downloadBtn.className = "btn btn-outline-theme btn-sm"; + const downloadIcon = document.createElement("i"); + downloadIcon.className = "bi bi-download"; + downloadBtn.appendChild(downloadIcon); + downloadBtn.title = "Download as TSV"; + downloadBtn.onclick = () => { + if (!mainGridApis || !mainGridApis.hasOwnProperty(kb)) { + alert(`The aggregate metrics table for ${kb} could not be downloaded!`); + return; + } + mainGridApis[kb].exportDataAsCsv({ + fileName: `${kb}_aggregate_metrics.tsv`, + columnSeparator: "\t", + }); + }; + + const compareBtn = document.createElement("button"); + compareBtn.className = "btn btn-outline-theme btn-sm"; + compareBtn.innerHTML = `Detailed Results per Query`; + compareBtn.title = "Compare per-query results"; + compareBtn.onclick = () => { + router.navigate(`/comparison?kb=${encodeURIComponent(kb)}`); + }; + + btnGroup.appendChild(downloadBtn); + btnGroup.appendChild(compareBtn); + + // titleWrapper.appendChild(title); + if (infoPill) { + title.appendChild(infoPill); + new bootstrap.Popover(infoPill); + } + header.appendChild(title); + header.appendChild(btnGroup); + + const html = document.documentElement; + const currentTheme = html.getAttribute("data-bs-theme") || "light"; + + // Grid div with ag-theme-balham styling + const gridDiv = document.createElement("div"); + gridDiv.className = currentTheme === "light" ? "ag-theme-balham" : "ag-theme-balham-dark"; + gridDiv.style.width = "100%"; + + // Append header and grid div to section + section.appendChild(header); + section.appendChild(gridDiv); + fragment.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)}`); + }; + + // const penaltyFactor = additionalData.penalty?.toString() ?? "Penalty Factor"; + + // Initialize ag-Grid instance + agGrid.createGrid(gridDiv, { + columnDefs: mainTableColumnDefs(), + rowData: rowData, + defaultColDef: { + sortable: true, + filter: true, + resizable: true, + minWidth: 80, + }, + domLayout: "autoHeight", + rowStyle: { fontSize: "clamp(12px, 1vw + 8px, 14px)", cursor: "pointer" }, + tooltipShowDelay: 500, + onRowClicked: onRowClicked, + suppressDragLeaveHidesColumns: true, + onGridReady: (params) => { + mainGridApis[kb] = params.api; + }, + }); + } + container.appendChild(fragment); +} + +/** + * Handles light/dark theme management including: + * - Setting the preferred theme based on system settings + * - Toggling between light and dark modes on button click + * - Updating Bootstrap and Ag-Grid theme classes + * - Adjusting toggle button icon and title + */ +function initThemeManager() { + const themeToggleBtn = document.getElementById("themeToggleBtn"); + const themeToggleIcon = document.getElementById("themeToggleIcon"); + const html = document.documentElement; + + /** + * Updates the current theme across UI components. + * @param {string} theme - The theme to apply ("light" or "dark"). + */ + function applyTheme(theme) { + html.setAttribute("data-bs-theme", theme); + themeToggleIcon.className = theme === "light" ? "bi bi-moon-fill" : "bi bi-sun-fill"; + themeToggleBtn.title = `Click to change to ${theme === "light" ? "dark" : "light"} mode!`; + + const grids = document.querySelectorAll(".ag-theme-balham, .ag-theme-balham-dark"); + grids.forEach((grid) => { + grid.classList.toggle("ag-theme-balham", theme === "light"); + grid.classList.toggle("ag-theme-balham-dark", theme === "dark"); + }); + } + + /** + * Detects and applies the user's preferred color scheme. + */ + function applyPreferredTheme() { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + applyTheme(prefersDark ? "dark" : "light"); + } + + /** + * Toggles the theme between light and dark. + */ + function toggleTheme() { + const currentTheme = html.getAttribute("data-bs-theme") || "light"; + const newTheme = currentTheme === "light" ? "dark" : "light"; + applyTheme(newTheme); + } + + // Initialize preferred theme + applyPreferredTheme(); + + // Attach click listener to toggle button + themeToggleBtn.addEventListener("click", toggleTheme); +} + +document.addEventListener("DOMContentLoaded", async () => { + router = new Navigo("/", { hash: true }); + + initThemeManager(); + + try { + showSpinner(); + const yaml_path = window.location.origin + window.location.pathname.replace(/\/$/, "").replace(/\/[^/]*$/, "/"); + const response = await fetch(`${yaml_path}yaml_data`); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const data = await response.json(); + performanceData = data.performance_data; + const additionalData = data.additional_data; + + for (const kb in performanceData) { + // Gather all index stats for this KB + const times = Object.values(performanceData[kb]).map((e) => e?.indexTime ?? null); + const sizes = Object.values(performanceData[kb]).map((e) => e?.indexSize ?? null); + const { unit: timeUnit, factor: timeFactor } = pickTimeUnit(times); + const { unit: sizeUnit, factor: sizeFactor } = pickSizeUnit(sizes); + + for (const engine in performanceData[kb]) { + const engineObj = performanceData[kb][engine]; + engineObj.indexTime = formatIndexStat(engineObj.indexTime, timeFactor, timeUnit); + engineObj.indexSize = formatIndexStat(engineObj.indexSize, sizeFactor, sizeUnit); + + const queries = engineObj.queries; + if (Array.isArray(queries)) { + queries.forEach((query) => { + try { + query.sparql = spfmt.format(query.sparql); + } catch (err) { + console.log(err); + } + }); + } + } + } + + // Routes + router + .on({ + "/": () => { + showPage("main"); + updateMainPage(performanceData, additionalData); + }, + "/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, additionalData.kbs[kb]); + 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, additionalData.kbs[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 ( + isNaN(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"); + updateMainPage(performanceData, additionalData); + }); + + router.resolve(); + + setMainPageEvents(); + setDetailsPageEvents(); + setComparisonPageEvents(); + setCompareExecTreesEvents(); + } catch (err) { + console.error("Error loading /yaml_data:", err); + showPage("error"); + } finally { + hideSpinner(); + } +}); diff --git a/src/qlever/evaluation/www/styles.css b/src/qlever/evaluation/www/styles.css new file mode 100644 index 00000000..940428cc --- /dev/null +++ b/src/qlever/evaluation/www/styles.css @@ -0,0 +1,538 @@ +body { + /* background-color: #f9f9f9; */ + font-family: "Helvetica Neue", sans-serif; +} + +html { + font-size: clamp(12px, 1vw + 10px, 16px); +} + +/* .ag-theme-balham, +.ag-theme-balham-dark { + font-size: clamp(12px, 1vw + 8px, 14px); +} */ + +.btn-outline-theme { + color: var(--bs-body-color); + border-color: var(--bs-body-color); + + &:hover { + color: var(--bs-body-bg); + background-color: var(--bs-body-color); + } +} + +.spinner-text { + font-weight: bold; + font-size: 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; +} + +.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; +} + +#comparison-grid .ag-row-focus { + background-color: unset !important; +} + +/* .ag-row-focus { + background-color: unset !important; +} */ + +.nav-link.active { + background-color: var(--bs-body-color) !important; + color: var(--bs-body-bg) !important; +} + +/* .custom-tooltip { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px; + background-color: #212529; + color: white; + border-radius: 4px; + font-size: 12px; +} + +.tooltip-text { + flex: 1; + user-select: text; + white-space: pre-wrap; + word-break: break-word; +} + +.popover-body { + color: white; +} */ + +.custom-tooltip, +.popover { + background-color: var(--bs-body-color); + color: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.25rem; + box-shadow: var(--bs-box-shadow-sm); + /* padding: 0.5rem; */ +} + +.popover-body { + color: var(--bs-body-bg); +} + +/* Common styling */ +.custom-tooltip { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px; + border-radius: 4px; + font-size: 12px; +} + +.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; */ + color: var(--bs-body-bg); +} + +/* .form-check-input:checked { + background-color: var(--bs-body-color); + border-color: var(--bs-body-color); +} */ + +#meta-info, +#meta-info-1, +#meta-info-2 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; +} + +.node { + padding: 3px; + border-radius: 3px; + background-color: #fefefe; + border: 1px solid #000; + min-width: 25em; + font-size: 80%; + color: black; +} + +.node > p { + margin: 0; + white-space: nowrap; +} +.node-name { + font-weight: bold; +} +p.node-name { + width: 30em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.node-status { + font-size: 90%; + color: #000099; +} +.node.not-started { + color: #999; +} +p.fully-materialized { + display: none; +} +p.lazily-materialized { + display: none; +} +.node-status.failed { + font-weight: bold; + color: red; +} +.node-status.child-failed { + color: red; +} +.node-status.not-started { + color: blue; +} +.node-status.optimized-out { + color: blue; +} +.node-status.lazily-materialized { + color: blue; +} +.node-cols:before { + content: "Cols: "; +} +p.node-cols { + width: 30em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.node-size:before { + content: "Size: "; +} +.node-size { + display: inline; +} +.node-size-estimate { + display: inline; + padding-left: 1em; +} +.node-time:before { + content: "\ATime: "; + white-space: pre; +} +.node-time { + display: inline; +} +.node-cost-estimate { + display: inline; + padding-left: 1em; +} +.node-status { + display: inline; + padding-left: 1em; +} +.node-time:after { + content: "ms"; +} +.node-details { + display: none; + color: blue; +} +.node-details:before { + content: "Details: "; +} +div.cached-not-pinned .node-time:after { + content: "ms [cached]"; +} +div.cached-pinned .node-time:after { + content: "ms [cached, pinned]"; +} +div.ancestor-cached .node-time:after { + content: "ms [ancestor cached]"; +} +p.node-cache-status { + display: none; +} +.node.cached-pinned { + color: grey; + border: 1px solid grey; +} +.node.cached-not-pinned { + color: grey; + border: 1px solid grey; +} +.node-total { + display: none; +} +.node.high { + background-color: #ffeeee; +} +.node.veryhigh { + background-color: #ffcccc; +} +.node.high.cached { + background-color: #ffffee; +} +.node.veryhigh.cached { + background-color: #ffffcc; +} */ + +.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 var(--bs-border-color); + position: absolute; + top: 1px; + right: 1px; + cursor: pointer; +} + +.Treant .collapsed .collapse-switch { + background-color: var(--bs-primary); +} + +.Treant > .node img { + border: none; + float: left; +} + +.node { + padding: 3px; + border-radius: 3px; + background-color: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + min-width: 25em; + font-size: 80%; + color: var(--bs-body-color); +} + +.node > p { + margin: 0; + white-space: nowrap; +} + +.node-name { + font-weight: bold; +} + +p.node-name { + width: 30em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.node-status { + font-size: 90%; + color: var(--bs-link-color); + display: inline; + padding-left: 1em; +} + +.node.not-started { + color: var(--bs-secondary-color); +} + +p.fully-materialized, +p.lazily-materialized, +p.node-cache-status, +.node-total { + display: none; +} + +.node-status.failed, +.node-status.child-failed { + font-weight: bold; + color: var(--bs-danger-text); +} + +.node-status.not-started, +.node-status.optimized-out, +.node-status.lazily-materialized { + color: var(--bs-primary); +} + +.node-cols:before { + content: "Cols: "; +} + +p.node-cols { + width: 30em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.node-size:before { + content: "Size: "; +} + +.node-size { + display: inline; +} + +.node-size-estimate { + display: inline; + padding-left: 1em; +} + +.node-time:before { + content: "\ATime: "; + white-space: pre; +} + +.node-time { + display: inline; +} + +.node-time:after { + content: "ms"; +} + +.node-cost-estimate { + display: inline; + padding-left: 1em; +} + +.node-details { + display: none; + color: var(--bs-link-color); +} + +.node-details:before { + content: "Details: "; +} + +/* Cache status suffixes */ +div.cached-not-pinned .node-time:after { + content: "ms [cached]"; +} + +div.cached-pinned .node-time:after { + content: "ms [cached, pinned]"; +} + +div.ancestor-cached .node-time:after { + content: "ms [ancestor cached]"; +} + +/* Cached nodes */ +.node.cached-pinned, +.node.cached-not-pinned { + color: var(--bs-secondary-color); + border: 1px solid var(--bs-border-color); +} + +/* Severity styling */ +.node.high { + background-color: var(--bs-warning-bg-subtle); /* yellow-ish */ +} + +.node.veryhigh { + background-color: var(--bs-danger-bg-subtle); /* red-ish */ +} + +/* Cached + high severity — subtle differences */ +.node.high.cached { + background-color: var(--bs-warning-border-subtle); /* lighter warning shade */ +} + +.node.veryhigh.cached { + background-color: var(--bs-danger-border-subtle); /* lighter danger shade */ +} + +.Treant .line, +.Treant .line-horizontal, +.Treant .line-vertical, +.Treant .line-diagonal { + border-color: var(--bs-border-color); + background-color: var(--bs-border-color); +} + +.Treant svg path { + stroke: var(--bs-border-color); +} + +.font-size-30 { + font-size: 30%; +} +.font-size-40 { + font-size: 40%; +} +.font-size-50 { + font-size: 50%; +} +.font-size-60 { + font-size: 60%; +} +.font-size-70 { + font-size: 70%; +} +.font-size-80 { + font-size: 80%; +} diff --git a/src/qlever/evaluation/www/treant.js b/src/qlever/evaluation/www/treant.js new file mode 100644 index 00000000..124d725b --- /dev/null +++ b/src/qlever/evaluation/www/treant.js @@ -0,0 +1,2171 @@ +/* + * Treant-js + * + * (c) 2013 Fran Peručić + * Treant-js may be freely distributed under the MIT license. + * For all details and documentation: + * http://fperucic.github.io/treant-js + * + * Treant is an open-source JavaScipt library for visualization of tree diagrams. + * It implements the node positioning algorithm of John Q. Walker II "Positioning nodes for General Trees". + * + * References: + * Emilio Cortegoso Lobato: ECOTree.js v1.0 (October 26th, 2006) + * + * Contributors: + * Fran Peručić, https://github.com/fperucic + * Dave Goodchild, https://github.com/dlgoodchild + */ + +;( function() { + // Polyfill for IE to use startsWith + if (!String.prototype.startsWith) { + String.prototype.startsWith = function(searchString, position){ + return this.substr(position || 0, searchString.length) === searchString; + }; + } + + var $ = null; + + var UTIL = { + + /** + * Directly updates, recursively/deeply, the first object with all properties in the second object + * @param {object} applyTo + * @param {object} applyFrom + * @return {object} + */ + inheritAttrs: function( applyTo, applyFrom ) { + for ( var attr in applyFrom ) { + if ( applyFrom.hasOwnProperty( attr ) ) { + if ( ( applyTo[attr] instanceof Object && applyFrom[attr] instanceof Object ) && ( typeof applyFrom[attr] !== 'function' ) ) { + this.inheritAttrs( applyTo[attr], applyFrom[attr] ); + } + else { + applyTo[attr] = applyFrom[attr]; + } + } + } + return applyTo; + }, + + /** + * Returns a new object by merging the two supplied objects + * @param {object} obj1 + * @param {object} obj2 + * @returns {object} + */ + createMerge: function( obj1, obj2 ) { + var newObj = {}; + if ( obj1 ) { + this.inheritAttrs( newObj, this.cloneObj( obj1 ) ); + } + if ( obj2 ) { + this.inheritAttrs( newObj, obj2 ); + } + return newObj; + }, + + /** + * Takes any number of arguments + * @returns {*} + */ + extend: function() { + if ( $ ) { + Array.prototype.unshift.apply( arguments, [true, {}] ); + return $.extend.apply( $, arguments ); + } + else { + return UTIL.createMerge.apply( this, arguments ); + } + }, + + /** + * @param {object} obj + * @returns {*} + */ + cloneObj: function ( obj ) { + if ( Object( obj ) !== obj ) { + return obj; + } + var res = new obj.constructor(); + for ( var key in obj ) { + if ( obj.hasOwnProperty(key) ) { + res[key] = this.cloneObj(obj[key]); + } + } + return res; + }, + + /** + * @param {Element} el + * @param {string} eventType + * @param {function} handler + */ + addEvent: function( el, eventType, handler ) { + if ( $ ) { + $( el ).on( eventType+'.treant', handler ); + } + else if ( el.addEventListener ) { // DOM Level 2 browsers + el.addEventListener( eventType, handler, false ); + } + else if ( el.attachEvent ) { // IE <= 8 + el.attachEvent( 'on' + eventType, handler ); + } + else { // ancient browsers + el['on' + eventType] = handler; + } + }, + + /** + * @param {string} selector + * @param {boolean} raw + * @param {Element} parentEl + * @returns {Element|jQuery} + */ + findEl: function( selector, raw, parentEl ) { + parentEl = parentEl || document; + + if ( $ ) { + var $element = $( selector, parentEl ); + return ( raw? $element.get( 0 ): $element ); + } + else { + // todo: getElementsByName() + // todo: getElementsByTagName() + // todo: getElementsByTagNameNS() + if ( selector.charAt( 0 ) === '#' ) { + return parentEl.getElementById( selector.substring( 1 ) ); + } + else if ( selector.charAt( 0 ) === '.' ) { + var oElements = parentEl.getElementsByClassName( selector.substring( 1 ) ); + return ( oElements.length? oElements[0]: null ); + } + + throw new Error( 'Unknown container element' ); + } + }, + + getOuterHeight: function( element ) { + var nRoundingCompensation = 1; + if ( typeof element.getBoundingClientRect === 'function' ) { + return element.getBoundingClientRect().height; + } + else if ( $ ) { + return Math.ceil( $( element ).outerHeight() ) + nRoundingCompensation; + } + else { + return Math.ceil( + element.clientHeight + + UTIL.getStyle( element, 'border-top-width', true ) + + UTIL.getStyle( element, 'border-bottom-width', true ) + + UTIL.getStyle( element, 'padding-top', true ) + + UTIL.getStyle( element, 'padding-bottom', true ) + + nRoundingCompensation + ); + } + }, + + getOuterWidth: function( element ) { + var nRoundingCompensation = 1; + if ( typeof element.getBoundingClientRect === 'function' ) { + return element.getBoundingClientRect().width; + } + else if ( $ ) { + return Math.ceil( $( element ).outerWidth() ) + nRoundingCompensation; + } + else { + return Math.ceil( + element.clientWidth + + UTIL.getStyle( element, 'border-left-width', true ) + + UTIL.getStyle( element, 'border-right-width', true ) + + UTIL.getStyle( element, 'padding-left', true ) + + UTIL.getStyle( element, 'padding-right', true ) + + nRoundingCompensation + ); + } + }, + + getStyle: function( element, strCssRule, asInt ) { + var strValue = ""; + if ( document.defaultView && document.defaultView.getComputedStyle ) { + strValue = document.defaultView.getComputedStyle( element, '' ).getPropertyValue( strCssRule ); + } + else if( element.currentStyle ) { + strCssRule = strCssRule.replace(/\-(\w)/g, + function (strMatch, p1){ + return p1.toUpperCase(); + } + ); + strValue = element.currentStyle[strCssRule]; + } + //Number(elem.style.width.replace(/[^\d\.\-]/g, '')); + return ( asInt? parseFloat( strValue ): strValue ); + }, + + addClass: function( element, cssClass ) { + if ( $ ) { + $( element ).addClass( cssClass ); + } + else { + if ( !UTIL.hasClass( element, cssClass ) ) { + if ( element.classList ) { + element.classList.add( cssClass ); + } + else { + element.className += " "+cssClass; + } + } + } + }, + + hasClass: function(element, my_class) { + return (" " + element.className + " ").replace(/[\n\t]/g, " ").indexOf(" "+my_class+" ") > -1; + }, + + toggleClass: function ( element, cls, apply ) { + if ( $ ) { + $( element ).toggleClass( cls, apply ); + } + else { + if ( apply ) { + //element.className += " "+cls; + element.classList.add( cls ); + } + else { + element.classList.remove( cls ); + } + } + }, + + setDimensions: function( element, width, height ) { + if ( $ ) { + $( element ).width( width ).height( height ); + } + else { + element.style.width = width+'px'; + element.style.height = height+'px'; + } + }, + isjQueryAvailable: function() {return(typeof ($) !== 'undefined' && $);}, + }; + + /** + * ImageLoader is used for determining if all the images from the Tree are loaded. + * Node size (width, height) can be correctly determined only when all inner images are loaded + */ + var ImageLoader = function() { + this.reset(); + }; + + ImageLoader.prototype = { + + /** + * @returns {ImageLoader} + */ + reset: function() { + this.loading = []; + return this; + }, + + /** + * @param {TreeNode} node + * @returns {ImageLoader} + */ + processNode: function( node ) { + var aImages = node.nodeDOM.getElementsByTagName( 'img' ); + + var i = aImages.length; + while ( i-- ) { + this.create( node, aImages[i] ); + } + return this; + }, + + /** + * @returns {ImageLoader} + */ + removeAll: function( img_src ) { + var i = this.loading.length; + while ( i-- ) { + if ( this.loading[i] === img_src ) { + this.loading.splice( i, 1 ); + } + } + return this; + }, + + /** + * @param {TreeNode} node + * @param {Element} image + * @returns {*} + */ + create: function ( node, image ) { + var self = this, source = image.src; + + function imgTrigger() { + self.removeAll( source ); + node.width = node.nodeDOM.offsetWidth; + node.height = node.nodeDOM.offsetHeight; + } + + if ( image.src.indexOf( 'data:' ) !== 0 ) { + this.loading.push( source ); + + if ( image.complete ) { + return imgTrigger(); + } + + UTIL.addEvent( image, 'load', imgTrigger ); + UTIL.addEvent( image, 'error', imgTrigger ); // handle broken url-s + + // load event is not fired for cached images, force the load event + image.src += ( ( image.src.indexOf( '?' ) > 0)? '&': '?' ) + new Date().getTime(); + } + else { + imgTrigger(); + } + }, + + /** + * @returns {boolean} + */ + isNotLoading: function() { + return ( this.loading.length === 0 ); + } + }; + + /** + * Class: TreeStore + * TreeStore is used for holding initialized Tree objects + * Its purpose is to avoid global variables and enable multiple Trees on the page. + */ + var TreeStore = { + + store: [], + + /** + * @param {object} jsonConfig + * @returns {Tree} + */ + createTree: function( jsonConfig ) { + var nNewTreeId = this.store.length; + this.store.push( new Tree( jsonConfig, nNewTreeId ) ); + return this.get( nNewTreeId ); + }, + + /** + * @param {number} treeId + * @returns {Tree} + */ + get: function ( treeId ) { + return this.store[treeId]; + }, + + /** + * @param {number} treeId + * @returns {TreeStore} + */ + destroy: function( treeId ) { + var tree = this.get( treeId ); + if ( tree ) { + tree._R.remove(); + var draw_area = tree.drawArea; + + while ( draw_area.firstChild ) { + draw_area.removeChild( draw_area.firstChild ); + } + + var classes = draw_area.className.split(' '), + classes_to_stay = []; + + for ( var i = 0; i < classes.length; i++ ) { + var cls = classes[i]; + if ( cls !== 'Treant' && cls !== 'Treant-loaded' ) { + classes_to_stay.push(cls); + } + } + draw_area.style.overflowY = ''; + draw_area.style.overflowX = ''; + draw_area.className = classes_to_stay.join(' '); + + this.store[treeId] = null; + } + return this; + } + }; + + /** + * Tree constructor. + * @param {object} jsonConfig + * @param {number} treeId + * @constructor + */ + var Tree = function (jsonConfig, treeId ) { + + /** + * @param {object} jsonConfig + * @param {number} treeId + * @returns {Tree} + */ + this.reset = function( jsonConfig, treeId ) { + this.initJsonConfig = jsonConfig; + this.initTreeId = treeId; + + this.id = treeId; + + this.CONFIG = UTIL.extend( Tree.CONFIG, jsonConfig.chart ); + this.drawArea = UTIL.findEl( this.CONFIG.container, true ); + if ( !this.drawArea ) { + throw new Error( 'Failed to find element by selector "'+this.CONFIG.container+'"' ); + } + + UTIL.addClass( this.drawArea, 'Treant' ); + + // kill of any child elements that may be there + this.drawArea.innerHTML = ''; + + this.imageLoader = new ImageLoader(); + + this.nodeDB = new NodeDB( jsonConfig.nodeStructure, this ); + + // key store for storing reference to node connectors, + // key = nodeId where the connector ends + this.connectionStore = {}; + + this.loaded = false; + + this._R = new Raphael( this.drawArea, 100, 100 ); + + return this; + }; + + /** + * @returns {Tree} + */ + this.reload = function() { + this.reset( this.initJsonConfig, this.initTreeId ).redraw(); + return this; + }; + + this.reset( jsonConfig, treeId ); + }; + + Tree.prototype = { + + /** + * @returns {NodeDB} + */ + getNodeDb: function() { + return this.nodeDB; + }, + + /** + * @param {TreeNode} parentTreeNode + * @param {object} nodeDefinition + * @returns {TreeNode} + */ + addNode: function( parentTreeNode, nodeDefinition ) { + var dbEntry = this.nodeDB.get( parentTreeNode.id ); + + this.CONFIG.callback.onBeforeAddNode.apply( this, [parentTreeNode, nodeDefinition] ); + + var oNewNode = this.nodeDB.createNode( nodeDefinition, parentTreeNode.id, this ); + oNewNode.createGeometry( this ); + + oNewNode.parent().createSwitchGeometry( this ); + + this.positionTree(); + + this.CONFIG.callback.onAfterAddNode.apply( this, [oNewNode, parentTreeNode, nodeDefinition] ); + + return oNewNode; + }, + + /** + * @returns {Tree} + */ + redraw: function() { + this.positionTree(); + return this; + }, + + /** + * @param {function} callback + * @returns {Tree} + */ + positionTree: function( callback ) { + var self = this; + + if ( this.imageLoader.isNotLoading() ) { + var root = this.root(), + orient = this.CONFIG.rootOrientation; + + this.resetLevelData(); + + this.firstWalk( root, 0 ); + this.secondWalk( root, 0, 0, 0 ); + + this.positionNodes(); + + if ( this.CONFIG.animateOnInit ) { + setTimeout( + function() { + root.toggleCollapse(); + }, + this.CONFIG.animateOnInitDelay + ); + } + + if ( !this.loaded ) { + UTIL.addClass( this.drawArea, 'Treant-loaded' ); // nodes are hidden until .loaded class is added + if ( Object.prototype.toString.call( callback ) === "[object Function]" ) { + callback( self ); + } + self.CONFIG.callback.onTreeLoaded.apply( self, [root] ); + this.loaded = true; + } + + } + else { + setTimeout( + function() { + self.positionTree( callback ); + }, 10 + ); + } + return this; + }, + + /** + * In a first post-order walk, every node of the tree is assigned a preliminary + * x-coordinate (held in field node->prelim). + * In addition, internal nodes are given modifiers, which will be used to move their + * children to the right (held in field node->modifier). + * @param {TreeNode} node + * @param {number} level + * @returns {Tree} + */ + firstWalk: function( node, level ) { + node.prelim = null; + node.modifier = null; + + this.setNeighbors( node, level ); + this.calcLevelDim( node, level ); + + var leftSibling = node.leftSibling(); + + if ( node.childrenCount() === 0 || level == this.CONFIG.maxDepth ) { + // set preliminary x-coordinate + if ( leftSibling ) { + node.prelim = leftSibling.prelim + leftSibling.size() + this.CONFIG.siblingSeparation; + } + else { + node.prelim = 0; + } + } + else { + //node is not a leaf, firstWalk for each child + for ( var i = 0, n = node.childrenCount(); i < n; i++ ) { + this.firstWalk(node.childAt(i), level + 1); + } + + var midPoint = node.childrenCenter() - node.size() / 2; + + if ( leftSibling ) { + node.prelim = leftSibling.prelim + leftSibling.size() + this.CONFIG.siblingSeparation; + node.modifier = node.prelim - midPoint; + this.apportion( node, level ); + } + else { + node.prelim = midPoint; + } + + // handle stacked children positioning + if ( node.stackParent ) { // handle the parent of stacked children + node.modifier += this.nodeDB.get( node.stackChildren[0] ).size()/2 + node.connStyle.stackIndent; + } + else if ( node.stackParentId ) { // handle stacked children + node.prelim = 0; + } + } + return this; + }, + + /* + * Clean up the positioning of small sibling subtrees. + * Subtrees of a node are formed independently and + * placed as close together as possible. By requiring + * that the subtrees be rigid at the time they are put + * together, we avoid the undesirable effects that can + * accrue from positioning nodes rather than subtrees. + */ + apportion: function (node, level) { + var firstChild = node.firstChild(), + firstChildLeftNeighbor = firstChild.leftNeighbor(), + compareDepth = 1, + depthToStop = this.CONFIG.maxDepth - level; + + while( firstChild && firstChildLeftNeighbor && compareDepth <= depthToStop ) { + // calculate the position of the firstChild, according to the position of firstChildLeftNeighbor + + var modifierSumRight = 0, + modifierSumLeft = 0, + leftAncestor = firstChildLeftNeighbor, + rightAncestor = firstChild; + + for ( var i = 0; i < compareDepth; i++ ) { + leftAncestor = leftAncestor.parent(); + rightAncestor = rightAncestor.parent(); + modifierSumLeft += leftAncestor.modifier; + modifierSumRight += rightAncestor.modifier; + + // all the stacked children are oriented towards right so use right variables + if ( rightAncestor.stackParent !== undefined ) { + modifierSumRight += rightAncestor.size() / 2; + } + } + + // find the gap between two trees and apply it to subTrees + // and mathing smaller gaps to smaller subtrees + + var totalGap = (firstChildLeftNeighbor.prelim + modifierSumLeft + firstChildLeftNeighbor.size() + this.CONFIG.subTeeSeparation) - (firstChild.prelim + modifierSumRight ); + + if ( totalGap > 0 ) { + var subtreeAux = node, + numSubtrees = 0; + + // count all the subtrees in the LeftSibling + while ( subtreeAux && subtreeAux.id !== leftAncestor.id ) { + subtreeAux = subtreeAux.leftSibling(); + numSubtrees++; + } + + if ( subtreeAux ) { + var subtreeMoveAux = node, + singleGap = totalGap / numSubtrees; + + while ( subtreeMoveAux.id !== leftAncestor.id ) { + subtreeMoveAux.prelim += totalGap; + subtreeMoveAux.modifier += totalGap; + + totalGap -= singleGap; + subtreeMoveAux = subtreeMoveAux.leftSibling(); + } + } + } + + compareDepth++; + + firstChild = ( firstChild.childrenCount() === 0 )? + node.leftMost(0, compareDepth): + firstChild = firstChild.firstChild(); + + if ( firstChild ) { + firstChildLeftNeighbor = firstChild.leftNeighbor(); + } + } + }, + + /* + * During a second pre-order walk, each node is given a + * final x-coordinate by summing its preliminary + * x-coordinate and the modifiers of all the node's + * ancestors. The y-coordinate depends on the height of + * the tree. (The roles of x and y are reversed for + * RootOrientations of EAST or WEST.) + */ + secondWalk: function( node, level, X, Y ) { + if ( level <= this.CONFIG.maxDepth ) { + var xTmp = node.prelim + X, + yTmp = Y, align = this.CONFIG.nodeAlign, + orient = this.CONFIG.rootOrientation, + levelHeight, nodesizeTmp; + + if (orient === 'NORTH' || orient === 'SOUTH') { + levelHeight = this.levelMaxDim[level].height; + nodesizeTmp = node.height; + if (node.pseudo) { + node.height = levelHeight; + } // assign a new size to pseudo nodes + } + else if (orient === 'WEST' || orient === 'EAST') { + levelHeight = this.levelMaxDim[level].width; + nodesizeTmp = node.width; + if (node.pseudo) { + node.width = levelHeight; + } // assign a new size to pseudo nodes + } + + node.X = xTmp; + + if (node.pseudo) { // pseudo nodes need to be properly aligned, otherwise position is not correct in some examples + if (orient === 'NORTH' || orient === 'WEST') { + node.Y = yTmp; // align "BOTTOM" + } + else if (orient === 'SOUTH' || orient === 'EAST') { + node.Y = (yTmp + (levelHeight - nodesizeTmp)); // align "TOP" + } + + } else { + node.Y = ( align === 'CENTER' ) ? (yTmp + (levelHeight - nodesizeTmp) / 2) : + ( align === 'TOP' ) ? (yTmp + (levelHeight - nodesizeTmp)) : + yTmp; + } + + if ( orient === 'WEST' || orient === 'EAST' ) { + var swapTmp = node.X; + node.X = node.Y; + node.Y = swapTmp; + } + + if (orient === 'SOUTH' ) { + node.Y = -node.Y - nodesizeTmp; + } + else if ( orient === 'EAST' ) { + node.X = -node.X - nodesizeTmp; + } + + if ( node.childrenCount() !== 0 ) { + if ( node.id === 0 && this.CONFIG.hideRootNode ) { + // ako je root node Hiden onda nemoj njegovu dijecu pomaknut po Y osi za Level separation, neka ona budu na vrhu + this.secondWalk(node.firstChild(), level + 1, X + node.modifier, Y); + } + else { + this.secondWalk(node.firstChild(), level + 1, X + node.modifier, Y + levelHeight + this.CONFIG.levelSeparation); + } + } + + if ( node.rightSibling() ) { + this.secondWalk( node.rightSibling(), level, X, Y ); + } + } + }, + + /** + * position all the nodes, center the tree in center of its container + * 0,0 coordinate is in the upper left corner + * @returns {Tree} + */ + positionNodes: function() { + var self = this, + treeSize = { + x: self.nodeDB.getMinMaxCoord('X', null, null), + y: self.nodeDB.getMinMaxCoord('Y', null, null) + }, + + treeWidth = treeSize.x.max - treeSize.x.min, + treeHeight = treeSize.y.max - treeSize.y.min, + + treeCenter = { + x: treeSize.x.max - treeWidth/2, + y: treeSize.y.max - treeHeight/2 + }; + + this.handleOverflow(treeWidth, treeHeight); + + var + containerCenter = { + x: self.drawArea.clientWidth/2, + y: self.drawArea.clientHeight/2 + }, + + deltaX = containerCenter.x - treeCenter.x, + deltaY = containerCenter.y - treeCenter.y, + + // all nodes must have positive X or Y coordinates, handle this with offsets + negOffsetX = ((treeSize.x.min + deltaX) <= 0) ? Math.abs(treeSize.x.min) : 0, + negOffsetY = ((treeSize.y.min + deltaY) <= 0) ? Math.abs(treeSize.y.min) : 0, + i, len, node; + + // position all the nodes + for ( i = 0, len = this.nodeDB.db.length; i < len; i++ ) { + + node = this.nodeDB.get(i); + + self.CONFIG.callback.onBeforePositionNode.apply( self, [node, i, containerCenter, treeCenter] ); + + if ( node.id === 0 && this.CONFIG.hideRootNode ) { + self.CONFIG.callback.onAfterPositionNode.apply( self, [node, i, containerCenter, treeCenter] ); + continue; + } + + // if the tree is smaller than the draw area, then center the tree within drawing area + node.X += negOffsetX + ((treeWidth < this.drawArea.clientWidth) ? deltaX : this.CONFIG.padding); + node.Y += negOffsetY + ((treeHeight < this.drawArea.clientHeight) ? deltaY : this.CONFIG.padding); + + var collapsedParent = node.collapsedParent(), + hidePoint = null; + + if (collapsedParent) { + // position the node behind the connector point of the parent, so future animations can be visible + hidePoint = collapsedParent.connectorPoint( true ); + node.hide(hidePoint); + + } + else if (node.positioned) { + // node is already positioned, + node.show(); + } + else { // inicijalno stvaranje nodeova, postavi lokaciju + node.nodeDOM.style.left = node.X + 'px'; + node.nodeDOM.style.top = node.Y + 'px'; + node.positioned = true; + } + + if (node.id !== 0 && !(node.parent().id === 0 && this.CONFIG.hideRootNode)) { + this.setConnectionToParent(node, hidePoint); // skip the root node + } + else if (!this.CONFIG.hideRootNode && node.drawLineThrough) { + // drawlinethrough is performed for for the root node also + node.drawLineThroughMe(); + } + + self.CONFIG.callback.onAfterPositionNode.apply( self, [node, i, containerCenter, treeCenter] ); + } + return this; + }, + + /** + * Create Raphael instance, (optionally set scroll bars if necessary) + * @param {number} treeWidth + * @param {number} treeHeight + * @returns {Tree} + */ + handleOverflow: function( treeWidth, treeHeight ) { + var viewWidth = (treeWidth < this.drawArea.clientWidth) ? this.drawArea.clientWidth : treeWidth + this.CONFIG.padding*2, + viewHeight = (treeHeight < this.drawArea.clientHeight) ? this.drawArea.clientHeight : treeHeight + this.CONFIG.padding*2; + + this._R.setSize( viewWidth, viewHeight ); + + if ( this.CONFIG.scrollbar === 'resize') { + UTIL.setDimensions( this.drawArea, viewWidth, viewHeight ); + } + else if ( !UTIL.isjQueryAvailable() || this.CONFIG.scrollbar === 'native' ) { + + if ( this.drawArea.clientWidth < treeWidth ) { // is overflow-x necessary + this.drawArea.style.overflowX = "auto"; + } + + if ( this.drawArea.clientHeight < treeHeight ) { // is overflow-y necessary + this.drawArea.style.overflowY = "auto"; + } + } + // Fancy scrollbar relies heavily on jQuery, so guarding with if ( $ ) + else if ( this.CONFIG.scrollbar === 'fancy') { + var jq_drawArea = $( this.drawArea ); + if (jq_drawArea.hasClass('ps-container')) { // znaci da je 'fancy' vec inicijaliziran, treba updateat + jq_drawArea.find('.Treant').css({ + width: viewWidth, + height: viewHeight + }); + + jq_drawArea.perfectScrollbar('update'); + } + else { + var mainContainer = jq_drawArea.wrapInner('
'), + child = mainContainer.find('.Treant'); + + child.css({ + width: viewWidth, + height: viewHeight + }); + + mainContainer.perfectScrollbar(); + } + } // else this.CONFIG.scrollbar == 'None' + + return this; + }, + /** + * @param {TreeNode} treeNode + * @param {boolean} hidePoint + * @returns {Tree} + */ + setConnectionToParent: function( treeNode, hidePoint ) { + var stacked = treeNode.stackParentId, + connLine, + parent = ( stacked? this.nodeDB.get( stacked ): treeNode.parent() ), + + pathString = hidePoint? + this.getPointPathString(hidePoint): + this.getPathString(parent, treeNode, stacked); + + if ( this.connectionStore[treeNode.id] ) { + // connector already exists, update the connector geometry + connLine = this.connectionStore[treeNode.id]; + this.animatePath( connLine, pathString ); + } + else { + connLine = this._R.path( pathString ); + this.connectionStore[treeNode.id] = connLine; + + // don't show connector arrows por pseudo nodes + if ( treeNode.pseudo ) { + delete parent.connStyle.style['arrow-end']; + } + if ( parent.pseudo ) { + delete parent.connStyle.style['arrow-start']; + } + + connLine.attr( parent.connStyle.style ); + + if ( treeNode.drawLineThrough || treeNode.pseudo ) { + treeNode.drawLineThroughMe( hidePoint ); + } + } + treeNode.connector = connLine; + return this; + }, + + /** + * Create the path which is represented as a point, used for hiding the connection + * A path with a leading "_" indicates the path will be hidden + * See: http://dmitrybaranovskiy.github.io/raphael/reference.html#Paper.path + * @param {object} hidePoint + * @returns {string} + */ + getPointPathString: function( hidePoint ) { + return ["_M", hidePoint.x, ",", hidePoint.y, 'L', hidePoint.x, ",", hidePoint.y, hidePoint.x, ",", hidePoint.y].join(' '); + }, + + /** + * This method relied on receiving a valid Raphael Paper.path. + * See: http://dmitrybaranovskiy.github.io/raphael/reference.html#Paper.path + * A pathString is typically in the format of "M10,20L30,40" + * @param path + * @param {string} pathString + * @returns {Tree} + */ + animatePath: function( path, pathString ) { + if (path.hidden && pathString.charAt(0) !== "_") { // path will be shown, so show it + path.show(); + path.hidden = false; + } + + // See: http://dmitrybaranovskiy.github.io/raphael/reference.html#Element.animate + path.animate( + { + path: pathString.charAt(0) === "_"? + pathString.substring(1): + pathString // remove the "_" prefix if it exists + }, + this.CONFIG.animation.connectorsSpeed, + this.CONFIG.animation.connectorsAnimation, + function() { + if ( pathString.charAt(0) === "_" ) { // animation is hiding the path, hide it at the and of animation + path.hide(); + path.hidden = true; + } + } + ); + return this; + }, + + /** + * + * @param {TreeNode} from_node + * @param {TreeNode} to_node + * @param {boolean} stacked + * @returns {string} + */ + getPathString: function( from_node, to_node, stacked ) { + var startPoint = from_node.connectorPoint( true ), + endPoint = to_node.connectorPoint( false ), + orientation = this.CONFIG.rootOrientation, + connType = from_node.connStyle.type, + P1 = {}, P2 = {}; + + if ( orientation === 'NORTH' || orientation === 'SOUTH' ) { + P1.y = P2.y = (startPoint.y + endPoint.y) / 2; + + P1.x = startPoint.x; + P2.x = endPoint.x; + } + else if ( orientation === 'EAST' || orientation === 'WEST' ) { + P1.x = P2.x = (startPoint.x + endPoint.x) / 2; + + P1.y = startPoint.y; + P2.y = endPoint.y; + } + + // sp, p1, pm, p2, ep == "x,y" + var sp = startPoint.x+','+startPoint.y, p1 = P1.x+','+P1.y, p2 = P2.x+','+P2.y, ep = endPoint.x+','+endPoint.y, + pm = (P1.x + P2.x)/2 +','+ (P1.y + P2.y)/2, pathString, stackPoint; + + if ( stacked ) { // STACKED CHILDREN + + stackPoint = (orientation === 'EAST' || orientation === 'WEST')? + endPoint.x+','+startPoint.y: + startPoint.x+','+endPoint.y; + + if ( connType === "step" || connType === "straight" ) { + pathString = ["M", sp, 'L', stackPoint, 'L', ep]; + } + else if ( connType === "curve" || connType === "bCurve" ) { + var helpPoint, // used for nicer curve lines + indent = from_node.connStyle.stackIndent; + + if ( orientation === 'NORTH' ) { + helpPoint = (endPoint.x - indent)+','+(endPoint.y - indent); + } + else if ( orientation === 'SOUTH' ) { + helpPoint = (endPoint.x - indent)+','+(endPoint.y + indent); + } + else if ( orientation === 'EAST' ) { + helpPoint = (endPoint.x + indent) +','+startPoint.y; + } + else if ( orientation === 'WEST' ) { + helpPoint = (endPoint.x - indent) +','+startPoint.y; + } + pathString = ["M", sp, 'L', helpPoint, 'S', stackPoint, ep]; + } + + } + else { // NORMAL CHILDREN + if ( connType === "step" ) { + pathString = ["M", sp, 'L', p1, 'L', p2, 'L', ep]; + } + else if ( connType === "curve" ) { + pathString = ["M", sp, 'C', p1, p2, ep ]; + } + else if ( connType === "bCurve" ) { + pathString = ["M", sp, 'Q', p1, pm, 'T', ep]; + } + else if (connType === "straight" ) { + pathString = ["M", sp, 'L', sp, ep]; + } + } + + return pathString.join(" "); + }, + + /** + * Algorithm works from left to right, so previous processed node will be left neighbour of the next node + * @param {TreeNode} node + * @param {number} level + * @returns {Tree} + */ + setNeighbors: function( node, level ) { + node.leftNeighborId = this.lastNodeOnLevel[level]; + if ( node.leftNeighborId ) { + node.leftNeighbor().rightNeighborId = node.id; + } + this.lastNodeOnLevel[level] = node.id; + return this; + }, + + /** + * Used for calculation of height and width of a level (level dimensions) + * @param {TreeNode} node + * @param {number} level + * @returns {Tree} + */ + calcLevelDim: function( node, level ) { // root node is on level 0 + this.levelMaxDim[level] = { + width: Math.max( this.levelMaxDim[level]? this.levelMaxDim[level].width: 0, node.width ), + height: Math.max( this.levelMaxDim[level]? this.levelMaxDim[level].height: 0, node.height ) + }; + return this; + }, + + /** + * @returns {Tree} + */ + resetLevelData: function() { + this.lastNodeOnLevel = []; + this.levelMaxDim = []; + return this; + }, + + /** + * @returns {TreeNode} + */ + root: function() { + return this.nodeDB.get( 0 ); + } + }; + + /** + * NodeDB is used for storing the nodes. Each tree has its own NodeDB. + * @param {object} nodeStructure + * @param {Tree} tree + * @constructor + */ + var NodeDB = function ( nodeStructure, tree ) { + this.reset( nodeStructure, tree ); + }; + + NodeDB.prototype = { + + /** + * @param {object} nodeStructure + * @param {Tree} tree + * @returns {NodeDB} + */ + reset: function( nodeStructure, tree ) { + + this.db = []; + + var self = this; + + /** + * @param {object} node + * @param {number} parentId + */ + function iterateChildren( node, parentId ) { + var newNode = self.createNode( node, parentId, tree, null ); + + if ( node.children ) { + // pseudo node is used for descending children to the next level + if ( node.childrenDropLevel && node.childrenDropLevel > 0 ) { + while ( node.childrenDropLevel-- ) { + // pseudo node needs to inherit the connection style from its parent for continuous connectors + var connStyle = UTIL.cloneObj( newNode.connStyle ); + newNode = self.createNode( 'pseudo', newNode.id, tree, null ); + newNode.connStyle = connStyle; + newNode.children = []; + } + } + + var stack = ( node.stackChildren && !self.hasGrandChildren( node ) )? newNode.id: null; + + // children are positioned on separate levels, one beneath the other + if ( stack !== null ) { + newNode.stackChildren = []; + } + + for ( var i = 0, len = node.children.length; i < len ; i++ ) { + if ( stack !== null ) { + newNode = self.createNode( node.children[i], newNode.id, tree, stack ); + if ( ( i + 1 ) < len ) { + // last node cant have children + newNode.children = []; + } + } + else { + iterateChildren( node.children[i], newNode.id ); + } + } + } + } + + if ( tree.CONFIG.animateOnInit ) { + nodeStructure.collapsed = true; + } + + iterateChildren( nodeStructure, -1 ); // root node + + this.createGeometries( tree ); + + return this; + }, + + /** + * @param {Tree} tree + * @returns {NodeDB} + */ + createGeometries: function( tree ) { + var i = this.db.length; + + while ( i-- ) { + this.get( i ).createGeometry( tree ); + } + return this; + }, + + /** + * @param {number} nodeId + * @returns {TreeNode} + */ + get: function ( nodeId ) { + return this.db[nodeId]; // get TreeNode by ID + }, + + /** + * @param {function} callback + * @returns {NodeDB} + */ + walk: function( callback ) { + var i = this.db.length; + + while ( i-- ) { + callback.apply( this, [ this.get( i ) ] ); + } + return this; + }, + + /** + * + * @param {object} nodeStructure + * @param {number} parentId + * @param {Tree} tree + * @param {number} stackParentId + * @returns {TreeNode} + */ + createNode: function( nodeStructure, parentId, tree, stackParentId ) { + var node = new TreeNode( nodeStructure, this.db.length, parentId, tree, stackParentId ); + + this.db.push( node ); + + // skip root node (0) + if ( parentId >= 0 ) { + var parent = this.get( parentId ); + + // todo: refactor into separate private method + if ( nodeStructure.position ) { + if ( nodeStructure.position === 'left' ) { + parent.children.push( node.id ); + } + else if ( nodeStructure.position === 'right' ) { + parent.children.splice( 0, 0, node.id ); + } + else if ( nodeStructure.position === 'center' ) { + parent.children.splice( Math.floor( parent.children.length / 2 ), 0, node.id ); + } + else { + // edge case when there's only 1 child + var position = parseInt( nodeStructure.position ); + if ( parent.children.length === 1 && position > 0 ) { + parent.children.splice( 0, 0, node.id ); + } + else { + parent.children.splice( + Math.max( position, parent.children.length - 1 ), + 0, node.id + ); + } + } + } + else { + parent.children.push( node.id ); + } + } + + if ( stackParentId ) { + this.get( stackParentId ).stackParent = true; + this.get( stackParentId ).stackChildren.push( node.id ); + } + + return node; + }, + + getMinMaxCoord: function( dim, parent, MinMax ) { // used for getting the dimensions of the tree, dim = 'X' || 'Y' + // looks for min and max (X and Y) within the set of nodes + parent = parent || this.get(0); + + MinMax = MinMax || { // start with root node dimensions + min: parent[dim], + max: parent[dim] + ( ( dim === 'X' )? parent.width: parent.height ) + }; + + var i = parent.childrenCount(); + + while ( i-- ) { + var node = parent.childAt( i ), + maxTest = node[dim] + ( ( dim === 'X' )? node.width: node.height ), + minTest = node[dim]; + + if ( maxTest > MinMax.max ) { + MinMax.max = maxTest; + } + if ( minTest < MinMax.min ) { + MinMax.min = minTest; + } + + this.getMinMaxCoord( dim, node, MinMax ); + } + return MinMax; + }, + + /** + * @param {object} nodeStructure + * @returns {boolean} + */ + hasGrandChildren: function( nodeStructure ) { + var i = nodeStructure.children.length; + while ( i-- ) { + if ( nodeStructure.children[i].children ) { + return true; + } + } + return false; + } + }; + + /** + * TreeNode constructor. + * @param {object} nodeStructure + * @param {number} id + * @param {number} parentId + * @param {Tree} tree + * @param {number} stackParentId + * @constructor + */ + var TreeNode = function( nodeStructure, id, parentId, tree, stackParentId ) { + this.reset( nodeStructure, id, parentId, tree, stackParentId ); + }; + + TreeNode.prototype = { + + /** + * @param {object} nodeStructure + * @param {number} id + * @param {number} parentId + * @param {Tree} tree + * @param {number} stackParentId + * @returns {TreeNode} + */ + reset: function( nodeStructure, id, parentId, tree, stackParentId ) { + this.id = id; + this.parentId = parentId; + this.treeId = tree.id; + + this.prelim = 0; + this.modifier = 0; + this.leftNeighborId = null; + + this.stackParentId = stackParentId; + + // pseudo node is a node with width=height=0, it is invisible, but necessary for the correct positioning of the tree + this.pseudo = nodeStructure === 'pseudo' || nodeStructure['pseudo']; // todo: surely if nodeStructure is a scalar then the rest will error: + + this.meta = nodeStructure.meta || {}; + this.image = nodeStructure.image || null; + + this.link = UTIL.createMerge( tree.CONFIG.node.link, nodeStructure.link ); + + this.connStyle = UTIL.createMerge( tree.CONFIG.connectors, nodeStructure.connectors ); + this.connector = null; + + this.drawLineThrough = nodeStructure.drawLineThrough === false ? false : ( nodeStructure.drawLineThrough || tree.CONFIG.node.drawLineThrough ); + + this.collapsable = nodeStructure.collapsable === false ? false : ( nodeStructure.collapsable || tree.CONFIG.node.collapsable ); + this.collapsed = nodeStructure.collapsed; + + this.text = nodeStructure.text; + + // '.node' DIV + this.nodeInnerHTML = nodeStructure.innerHTML; + this.nodeHTMLclass = (tree.CONFIG.node.HTMLclass ? tree.CONFIG.node.HTMLclass : '') + // globally defined class for the nodex + (nodeStructure.HTMLclass ? (' ' + nodeStructure.HTMLclass) : ''); // + specific node class + + this.nodeHTMLid = nodeStructure.HTMLid; + + this.children = []; + + return this; + }, + + /** + * @returns {Tree} + */ + getTree: function() { + return TreeStore.get( this.treeId ); + }, + + /** + * @returns {object} + */ + getTreeConfig: function() { + return this.getTree().CONFIG; + }, + + /** + * @returns {NodeDB} + */ + getTreeNodeDb: function() { + return this.getTree().getNodeDb(); + }, + + /** + * @param {number} nodeId + * @returns {TreeNode} + */ + lookupNode: function( nodeId ) { + return this.getTreeNodeDb().get( nodeId ); + }, + + /** + * @returns {Tree} + */ + Tree: function() { + return TreeStore.get( this.treeId ); + }, + + /** + * @param {number} nodeId + * @returns {TreeNode} + */ + dbGet: function( nodeId ) { + return this.getTreeNodeDb().get( nodeId ); + }, + + /** + * Returns the width of the node + * @returns {float} + */ + size: function() { + var orientation = this.getTreeConfig().rootOrientation; + + if ( this.pseudo ) { + // prevents separating the subtrees + return ( -this.getTreeConfig().subTeeSeparation ); + } + + if ( orientation === 'NORTH' || orientation === 'SOUTH' ) { + return this.width; + } + else if ( orientation === 'WEST' || orientation === 'EAST' ) { + return this.height; + } + }, + + /** + * @returns {number} + */ + childrenCount: function () { + return ( ( this.collapsed || !this.children)? 0: this.children.length ); + }, + + /** + * @param {number} index + * @returns {TreeNode} + */ + childAt: function( index ) { + return this.dbGet( this.children[index] ); + }, + + /** + * @returns {TreeNode} + */ + firstChild: function() { + return this.childAt( 0 ); + }, + + /** + * @returns {TreeNode} + */ + lastChild: function() { + return this.childAt( this.children.length - 1 ); + }, + + /** + * @returns {TreeNode} + */ + parent: function() { + return this.lookupNode( this.parentId ); + }, + + /** + * @returns {TreeNode} + */ + leftNeighbor: function() { + if ( this.leftNeighborId ) { + return this.lookupNode( this.leftNeighborId ); + } + }, + + /** + * @returns {TreeNode} + */ + rightNeighbor: function() { + if ( this.rightNeighborId ) { + return this.lookupNode( this.rightNeighborId ); + } + }, + + /** + * @returns {TreeNode} + */ + leftSibling: function () { + var leftNeighbor = this.leftNeighbor(); + + if ( leftNeighbor && leftNeighbor.parentId === this.parentId ){ + return leftNeighbor; + } + }, + + /** + * @returns {TreeNode} + */ + rightSibling: function () { + var rightNeighbor = this.rightNeighbor(); + + if ( rightNeighbor && rightNeighbor.parentId === this.parentId ) { + return rightNeighbor; + } + }, + + /** + * @returns {number} + */ + childrenCenter: function () { + var first = this.firstChild(), + last = this.lastChild(); + + return ( first.prelim + ((last.prelim - first.prelim) + last.size()) / 2 ); + }, + + /** + * Find out if one of the node ancestors is collapsed + * @returns {*} + */ + collapsedParent: function() { + var parent = this.parent(); + if ( !parent ) { + return false; + } + if ( parent.collapsed ) { + return parent; + } + return parent.collapsedParent(); + }, + + /** + * Returns the leftmost child at specific level, (initial level = 0) + * @param level + * @param depth + * @returns {*} + */ + leftMost: function ( level, depth ) { + if ( level >= depth ) { + return this; + } + if ( this.childrenCount() === 0 ) { + return; + } + + for ( var i = 0, n = this.childrenCount(); i < n; i++ ) { + var leftmostDescendant = this.childAt( i ).leftMost( level + 1, depth ); + if ( leftmostDescendant ) { + return leftmostDescendant; + } + } + }, + + // returns start or the end point of the connector line, origin is upper-left + connectorPoint: function(startPoint) { + var orient = this.Tree().CONFIG.rootOrientation, point = {}; + + if ( this.stackParentId ) { // return different end point if node is a stacked child + if ( orient === 'NORTH' || orient === 'SOUTH' ) { + orient = 'WEST'; + } + else if ( orient === 'EAST' || orient === 'WEST' ) { + orient = 'NORTH'; + } + } + + // if pseudo, a virtual center is used + if ( orient === 'NORTH' ) { + point.x = (this.pseudo) ? this.X - this.Tree().CONFIG.subTeeSeparation/2 : this.X + this.width/2; + point.y = (startPoint) ? this.Y + this.height : this.Y; + } + else if (orient === 'SOUTH') { + point.x = (this.pseudo) ? this.X - this.Tree().CONFIG.subTeeSeparation/2 : this.X + this.width/2; + point.y = (startPoint) ? this.Y : this.Y + this.height; + } + else if (orient === 'EAST') { + point.x = (startPoint) ? this.X : this.X + this.width; + point.y = (this.pseudo) ? this.Y - this.Tree().CONFIG.subTeeSeparation/2 : this.Y + this.height/2; + } + else if (orient === 'WEST') { + point.x = (startPoint) ? this.X + this.width : this.X; + point.y = (this.pseudo) ? this.Y - this.Tree().CONFIG.subTeeSeparation/2 : this.Y + this.height/2; + } + return point; + }, + + /** + * @returns {string} + */ + pathStringThrough: function() { // get the geometry of a path going through the node + var startPoint = this.connectorPoint( true ), + endPoint = this.connectorPoint( false ); + + return ["M", startPoint.x+","+startPoint.y, 'L', endPoint.x+","+endPoint.y].join(" "); + }, + + /** + * @param {object} hidePoint + */ + drawLineThroughMe: function( hidePoint ) { // hidepoint se proslijedjuje ako je node sakriven zbog collapsed + var pathString = hidePoint? + this.Tree().getPointPathString( hidePoint ): + this.pathStringThrough(); + + this.lineThroughMe = this.lineThroughMe || this.Tree()._R.path(pathString); + + var line_style = UTIL.cloneObj( this.connStyle.style ); + + delete line_style['arrow-start']; + delete line_style['arrow-end']; + + this.lineThroughMe.attr( line_style ); + + if ( hidePoint ) { + this.lineThroughMe.hide(); + this.lineThroughMe.hidden = true; + } + }, + + addSwitchEvent: function( nodeSwitch ) { + var self = this; + UTIL.addEvent( nodeSwitch, 'click', + function( e ) { + e.preventDefault(); + if ( self.getTreeConfig().callback.onBeforeClickCollapseSwitch.apply( self, [ nodeSwitch, e ] ) === false ) { + return false; + } + + self.toggleCollapse(); + + self.getTreeConfig().callback.onAfterClickCollapseSwitch.apply( self, [ nodeSwitch, e ] ); + } + ); + }, + + /** + * @returns {TreeNode} + */ + collapse: function() { + if ( !this.collapsed ) { + this.toggleCollapse(); + } + return this; + }, + + /** + * @returns {TreeNode} + */ + expand: function() { + if ( this.collapsed ) { + this.toggleCollapse(); + } + return this; + }, + + /** + * @returns {TreeNode} + */ + toggleCollapse: function() { + var oTree = this.getTree(); + + if ( !oTree.inAnimation ) { + oTree.inAnimation = true; + + this.collapsed = !this.collapsed; // toggle the collapse at each click + UTIL.toggleClass( this.nodeDOM, 'collapsed', this.collapsed ); + + oTree.positionTree(); + + var self = this; + + setTimeout( + function() { // set the flag after the animation + oTree.inAnimation = false; + oTree.CONFIG.callback.onToggleCollapseFinished.apply( oTree, [ self, self.collapsed ] ); + }, + ( oTree.CONFIG.animation.nodeSpeed > oTree.CONFIG.animation.connectorsSpeed )? + oTree.CONFIG.animation.nodeSpeed: + oTree.CONFIG.animation.connectorsSpeed + ); + } + return this; + }, + + hide: function( collapse_to_point ) { + collapse_to_point = collapse_to_point || false; + + var bCurrentState = this.hidden; + this.hidden = true; + + this.nodeDOM.style.overflow = 'hidden'; + + var tree = this.getTree(), + config = this.getTreeConfig(), + oNewState = { + opacity: 0 + }; + + if ( collapse_to_point ) { + oNewState.left = collapse_to_point.x; + oNewState.top = collapse_to_point.y; + } + + // if parent was hidden in initial configuration, position the node behind the parent without animations + if ( !this.positioned || bCurrentState ) { + this.nodeDOM.style.visibility = 'hidden'; + if ( $ ) { + $( this.nodeDOM ).css( oNewState ); + } + else { + this.nodeDOM.style.left = oNewState.left + 'px'; + this.nodeDOM.style.top = oNewState.top + 'px'; + } + this.positioned = true; + } + else { + // todo: fix flashy bug when a node is manually hidden and tree.redraw is called. + if ( $ ) { + $( this.nodeDOM ).animate( + oNewState, config.animation.nodeSpeed, config.animation.nodeAnimation, + function () { + this.style.visibility = 'hidden'; + } + ); + } + else { + this.nodeDOM.style.transition = 'all '+config.animation.nodeSpeed+'ms ease'; + this.nodeDOM.style.transitionProperty = 'opacity, left, top'; + this.nodeDOM.style.opacity = oNewState.opacity; + this.nodeDOM.style.left = oNewState.left + 'px'; + this.nodeDOM.style.top = oNewState.top + 'px'; + this.nodeDOM.style.visibility = 'hidden'; + } + } + + // animate the line through node if the line exists + if ( this.lineThroughMe ) { + var new_path = tree.getPointPathString( collapse_to_point ); + if ( bCurrentState ) { + // update without animations + this.lineThroughMe.attr( { path: new_path } ); + } + else { + // update with animations + tree.animatePath( this.lineThroughMe, tree.getPointPathString( collapse_to_point ) ); + } + } + + return this; + }, + + /** + * @returns {TreeNode} + */ + hideConnector: function() { + var oTree = this.Tree(); + var oPath = oTree.connectionStore[this.id]; + if ( oPath ) { + oPath.animate( + { 'opacity': 0 }, + oTree.CONFIG.animation.connectorsSpeed, + oTree.CONFIG.animation.connectorsAnimation + ); + } + return this; + }, + + show: function() { + var bCurrentState = this.hidden; + this.hidden = false; + + this.nodeDOM.style.visibility = 'visible'; + + var oTree = this.Tree(); + + var oNewState = { + left: this.X, + top: this.Y, + opacity: 1 + }, + config = this.getTreeConfig(); + + // if the node was hidden, update opacity and position + if ( $ ) { + $( this.nodeDOM ).animate( + oNewState, + config.animation.nodeSpeed, config.animation.nodeAnimation, + function () { + // $.animate applies "overflow:hidden" to the node, remove it to avoid visual problems + this.style.overflow = ""; + } + ); + } + else { + this.nodeDOM.style.transition = 'all '+config.animation.nodeSpeed+'ms ease'; + this.nodeDOM.style.transitionProperty = 'opacity, left, top'; + this.nodeDOM.style.left = oNewState.left + 'px'; + this.nodeDOM.style.top = oNewState.top + 'px'; + this.nodeDOM.style.opacity = oNewState.opacity; + this.nodeDOM.style.overflow = ''; + } + + if ( this.lineThroughMe ) { + this.getTree().animatePath( this.lineThroughMe, this.pathStringThrough() ); + } + + return this; + }, + + /** + * @returns {TreeNode} + */ + showConnector: function() { + var oTree = this.Tree(); + var oPath = oTree.connectionStore[this.id]; + if ( oPath ) { + oPath.animate( + { 'opacity': 1 }, + oTree.CONFIG.animation.connectorsSpeed, + oTree.CONFIG.animation.connectorsAnimation + ); + } + return this; + } + }; + + + /** + * Build a node from the 'text' and 'img' property and return with it. + * + * The node will contain all the fields that present under the 'text' property + * Each field will refer to a css class with name defined as node-{$property_name} + * + * Example: + * The definition: + * + * text: { + * desc: "some description", + * paragraph: "some text" + * } + * + * will generate the following elements: + * + *

some description

+ *

some text

+ * + * @Returns the configured node + */ + TreeNode.prototype.buildNodeFromText = function (node) { + // IMAGE + if (this.image) { + image = document.createElement('img'); + image.src = this.image; + node.appendChild(image); + } + + // TEXT + if (this.text) { + for (var key in this.text) { + // adding DATA Attributes to the node + if (key.startsWith("data-")) { + node.setAttribute(key, this.text[key]); + } else { + + var textElement = document.createElement(this.text[key].href ? 'a' : 'p'); + + // make an element if required + if (this.text[key].href) { + textElement.href = this.text[key].href; + if (this.text[key].target) { + textElement.target = this.text[key].target; + } + } + + textElement.className = "node-"+key; + textElement.appendChild(document.createTextNode( + this.text[key].val ? this.text[key].val : + this.text[key] instanceof Object ? "'val' param missing!" : this.text[key] + ) + ); + + node.appendChild(textElement); + } + } + } + return node; + }; + + /** + * Build a node from 'nodeInnerHTML' property that defines an existing HTML element, referenced by it's id, e.g: #someElement + * Change the text in the passed node to 'Wrong ID selector' if the referenced element does ot exist, + * return with a cloned and configured node otherwise + * + * @Returns node the configured node + */ + TreeNode.prototype.buildNodeFromHtml = function(node) { + // get some element by ID and clone its structure into a node + if (this.nodeInnerHTML.charAt(0) === "#") { + var elem = document.getElementById(this.nodeInnerHTML.substring(1)); + if (elem) { + node = elem.cloneNode(true); + node.id += "-clone"; + node.className += " node"; + } + else { + node.innerHTML = " Wrong ID selector "; + } + } + else { + // insert your custom HTML into a node + node.innerHTML = this.nodeInnerHTML; + } + return node; + }; + + /** + * @param {Tree} tree + */ + TreeNode.prototype.createGeometry = function( tree ) { + if ( this.id === 0 && tree.CONFIG.hideRootNode ) { + this.width = 0; + this.height = 0; + return; + } + + var drawArea = tree.drawArea, + image, + + /////////// CREATE NODE ////////////// + node = document.createElement( this.link.href? 'a': 'div' ); + + node.className = ( !this.pseudo )? TreeNode.CONFIG.nodeHTMLclass: 'pseudo'; + if ( this.nodeHTMLclass && !this.pseudo ) { + node.className += ' ' + this.nodeHTMLclass; + } + + if ( this.nodeHTMLid ) { + node.id = this.nodeHTMLid; + } + + if ( this.link.href ) { + node.href = this.link.href; + node.target = this.link.target; + } + + if ( $ ) { + $( node ).data( 'treenode', this ); + } + else { + node.data = { + 'treenode': this + }; + } + + /////////// BUILD NODE CONTENT ////////////// + if ( !this.pseudo ) { + node = this.nodeInnerHTML? this.buildNodeFromHtml(node) : this.buildNodeFromText(node) + + // handle collapse switch + if ( this.collapsed || (this.collapsable && this.childrenCount() && !this.stackParentId) ) { + this.createSwitchGeometry( tree, node ); + } + } + + tree.CONFIG.callback.onCreateNode.apply( tree, [this, node] ); + + /////////// APPEND all ////////////// + drawArea.appendChild(node); + + this.width = node.offsetWidth; + this.height = node.offsetHeight; + + this.nodeDOM = node; + + tree.imageLoader.processNode(this); + }; + + /** + * @param {Tree} tree + * @param {Element} nodeEl + */ + TreeNode.prototype.createSwitchGeometry = function( tree, nodeEl ) { + nodeEl = nodeEl || this.nodeDOM; + + // safe guard and check to see if it has a collapse switch + var nodeSwitchEl = UTIL.findEl( '.collapse-switch', true, nodeEl ); + if ( !nodeSwitchEl ) { + nodeSwitchEl = document.createElement( 'a' ); + nodeSwitchEl.className = "collapse-switch"; + + nodeEl.appendChild( nodeSwitchEl ); + this.addSwitchEvent( nodeSwitchEl ); + if ( this.collapsed ) { + nodeEl.className += " collapsed"; + } + + tree.CONFIG.callback.onCreateNodeCollapseSwitch.apply( tree, [this, nodeEl, nodeSwitchEl] ); + } + return nodeSwitchEl; + }; + + + // ########################################### + // Expose global + default CONFIG params + // ########################################### + + + Tree.CONFIG = { + maxDepth: 100, + rootOrientation: 'NORTH', // NORTH || EAST || WEST || SOUTH + nodeAlign: 'CENTER', // CENTER || TOP || BOTTOM + levelSeparation: 30, + siblingSeparation: 30, + subTeeSeparation: 30, + + hideRootNode: false, + + animateOnInit: false, + animateOnInitDelay: 500, + + padding: 15, // the difference is seen only when the scrollbar is shown + scrollbar: 'native', // "native" || "fancy" || "None" (PS: "fancy" requires jquery and perfect-scrollbar) + + connectors: { + type: 'curve', // 'curve' || 'step' || 'straight' || 'bCurve' + style: { + stroke: 'black' + }, + stackIndent: 15 + }, + + node: { // each node inherits this, it can all be overridden in node config + + // HTMLclass: 'node', + // drawLineThrough: false, + // collapsable: false, + link: { + target: '_self' + } + }, + + animation: { // each node inherits this, it can all be overridden in node config + nodeSpeed: 450, + nodeAnimation: 'linear', + connectorsSpeed: 450, + connectorsAnimation: 'linear' + }, + + callback: { + onCreateNode: function( treeNode, treeNodeDom ) {}, // this = Tree + onCreateNodeCollapseSwitch: function( treeNode, treeNodeDom, switchDom ) {}, // this = Tree + onAfterAddNode: function( newTreeNode, parentTreeNode, nodeStructure ) {}, // this = Tree + onBeforeAddNode: function( parentTreeNode, nodeStructure ) {}, // this = Tree + onAfterPositionNode: function( treeNode, nodeDbIndex, containerCenter, treeCenter) {}, // this = Tree + onBeforePositionNode: function( treeNode, nodeDbIndex, containerCenter, treeCenter) {}, // this = Tree + onToggleCollapseFinished: function ( treeNode, bIsCollapsed ) {}, // this = Tree + onAfterClickCollapseSwitch: function( nodeSwitch, event ) {}, // this = TreeNode + onBeforeClickCollapseSwitch: function( nodeSwitch, event ) {}, // this = TreeNode + onTreeLoaded: function( rootTreeNode ) {} // this = Tree + } + }; + + TreeNode.CONFIG = { + nodeHTMLclass: 'node' + }; + + // ############################################# + // Makes a JSON chart config out of Array config + // ############################################# + + var JSONconfig = { + make: function( configArray ) { + + var i = configArray.length, node; + + this.jsonStructure = { + chart: null, + nodeStructure: null + }; + //fist loop: find config, find root; + while(i--) { + node = configArray[i]; + if (node.hasOwnProperty('container')) { + this.jsonStructure.chart = node; + continue; + } + + if (!node.hasOwnProperty('parent') && ! node.hasOwnProperty('container')) { + this.jsonStructure.nodeStructure = node; + node._json_id = 0; + } + } + + this.findChildren(configArray); + + return this.jsonStructure; + }, + + findChildren: function(nodes) { + var parents = [0]; // start with a a root node + + while(parents.length) { + var parentId = parents.pop(), + parent = this.findNode(this.jsonStructure.nodeStructure, parentId), + i = 0, len = nodes.length, + children = []; + + for(;i")) { + 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, siteErrorMsg = null) { + // 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"); + if (pageId === "error" && siteErrorMsg !== null) { + document.querySelector("#siteErrorMsg").innerText = siteErrorMsg; + } + } +} + +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 + ) { + const resultValue = extractCoreValue(queryData.results[0]); + // Try formatting as int with commas + const intVal = parseInt(resultValue, 10); + if (!isNaN(intVal)) { + singleResult = intVal.toLocaleString(); + } + } + return singleResult; +} + +function pickTimeUnit(values) { + const valid = values.filter((v) => typeof v === "number"); + if (valid.length === 0) return { unit: "s", factor: 1 }; + + const min = Math.min(...valid); + + if (min < 200) return { unit: "s", factor: 1 }; + if (min < 3600) return { unit: "min", factor: 60 }; + return { unit: "h", factor: 3600 }; +} + +function pickSizeUnit(values) { + const valid = values.filter((v) => typeof v === "number"); + if (valid.length === 0) return { unit: "B", factor: 1 }; + + const max = Math.max(...valid); + + if (max < 1e3) return { unit: "B", factor: 1 }; + if (max < 1e6) return { unit: "KB", factor: 1e3 }; + if (max < 1e9) return { unit: "MB", factor: 1e6 }; + if (max < 1e12) return { unit: "GB", factor: 1e9 }; + return { unit: "TB", factor: 1e12 }; +} + +function formatIndexStat(value, factor, unit) { + if (value == null || typeof value !== "number") return null; + if (unit === "s") return `${value / factor} ${unit}`; + return `${(value / factor).toFixed(1)} ${unit}`; +} + +/** + * Displays the loading spinner by updating the relevant CSS classes. + */ +function showSpinner() { + document.querySelector("#spinner").classList.remove("d-none", "d-flex"); + document.querySelector("#spinner").classList.add("d-flex"); +} + +/** + * Hides the loading spinner by updating the relevant CSS classes. + */ +function hideSpinner() { + document.querySelector("#spinner").classList.remove("d-none", "d-flex"); + document.querySelector("#spinner").classList.add("d-none"); +} + +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,"); +} + +function renderExecTree(runtime_info, treeNodeId, metaNodeId, purpose = "showTree", currentFontSize) { + // 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.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"]); + + const treant_tree = { + chart: { + container: treeNodeId, + rootOrientation: "NORTH", + connectors: { type: "step" }, + node: { HTMLclass: "font-size-" + maximumZoomPercent }, + }, + nodeStructure: runtime_info["query_execution_tree"], + }; + const newFontSize = getNewFontSizeForTree(treant_tree, purpose, currentFontSize); + treant_tree.chart.node.HTMLclass = "font-size-" + newFontSize.toString(); + + // Create new Treant tree + 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); + } + }); + + const high_query_time_ms = 100; + const very_high_query_time_ms = 1000; + + // Highlight high/very high node-time values + document.querySelectorAll("p.node-time").forEach(function (p) { + const time = parseInt(p.textContent.replace(/,/g, "")); + if (time >= high_query_time_ms) { + p.parentElement.classList.add("high"); + } + if (time >= 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); + }); +} + +/** + * 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; +} + +function goToCompareExecTreesPage(agGridApi, tableName) { + if (!agGridApi) return; + const selectedNode = agGridApi.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 ${tableName} Table!`); + } +} + +function sortEngines(engines, kb, metric, order) { + return engines.slice().sort((a, b) => { + const left = order === "asc" ? a : b; + const right = order === "asc" ? b : a; + return performanceData[kb][left][metric] - performanceData[kb][right][metric]; + }); +} + +function extractFirstUrl(text) { + // Regex matches http(s):// and www. patterns, stops before spaces or closing punctuation + const regex = /\b((?:https?:\/\/|www\.)[^\s<>"]+[^.,;:!?()\[\]{}<>\s"])/i; + const match = text.match(regex); + + if (match) { + let url = match[1].trim(); + + // Normalize URLs starting with www. + if (url.startsWith("www.")) { + url = "http://" + url; + } + + return url; + } + return null; +} + +function createBenchmarkDescriptionInfoPill(indexDescription, tooltipPlacement = "right") { + const infoPill = document.createElement("a"); + infoPill.setAttribute("tabindex", 0); + + infoPill.className = "mx-2"; + infoPill.style.color = "var(--bs-body-color)"; + infoPill.style.cursor = "pointer"; + + const icon = document.createElement("i"); + icon.className = "bi bi-info-circle-fill"; + icon.style.fontSize = "0.8rem"; + infoPill.appendChild(icon); + + // Bootstrap popover attributes + infoPill.setAttribute("data-bs-toggle", "popover"); + infoPill.setAttribute("data-bs-trigger", "focus"); + infoPill.setAttribute("data-bs-placement", tooltipPlacement); + infoPill.setAttribute("data-bs-html", "true"); + infoPill.setAttribute( + "data-bs-content", + anchorme({ + input: indexDescription, + options: { attributes: { target: "_blank", class: "text-primary" } }, + }) + ); + + return infoPill; +} + +function removeTitleInfoPill() { + document.querySelector("#mainTitleWrapper a")?.remove(); +} + +function createTooltipContainer(params) { + const isSparql = typeof params.value !== "string"; + const tooltipText = isSparql ? params.value.sparql : params.value; + const tooltipTitle = params.value.title; + + const container = document.createElement("div"); + container.className = "custom-tooltip"; + + const textDiv = document.createElement("div"); + textDiv.className = "tooltip-text"; + const pre = document.createElement("pre"); + pre.textContent = tooltipText; + if (tooltipTitle) { + textDiv.innerHTML = `${tooltipTitle}

`; + } + if (isSparql) { + textDiv.appendChild(pre); + } else { + textDiv.textContent = tooltipText; + } + container.appendChild(textDiv); + return container; +} + +/** + * Populate the checkbox container inside the accordion with column names. + * @param {string[]} columnNames - List of Ag Grid column field names + */ +function getColumnVisibilityMultiSelectFragment(columns, allChecked = true) { + const fragment = document.createDocumentFragment(); + for (const [colKey, colValue] of Object.entries(columns)) { + 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 = colKey; + checkbox.value = colKey; + checkbox.checked = allChecked; + + const label = document.createElement("label"); + label.className = "form-check-label"; + label.style.cursor = "pointer"; + label.setAttribute("for", colKey); + label.textContent = colValue; + + div.appendChild(checkbox); + div.appendChild(label); + fragment.appendChild(div); + } + return fragment; +} + +function escapeLatex(str) { + if (!str) return ""; + + const replacements = { + "\\": "\\textbackslash{}", + "{": "\\{", + "}": "\\}", + $: "\\$", + "&": "\\&", + "#": "\\#", + _: "\\_", + "%": "\\%", + "~": "\\textasciitilde{}", + "^": "\\textasciicircum{}", + "<": "\\textless{}", + ">": "\\textgreater{}", + }; + + return str.replace(/([\\{}$&#_%~^<>])/g, (match) => replacements[match] || match).trim(); +} + +function csvToLatexTable(csvText) { + // Split CSV into rows + const rows = csvText + .trim() + .split("\n") + .map((row) => row.split(",")); + + const colAlign = "l" + "r".repeat(rows[0].length - 1); + + // Build LaTeX table + let latex = `% Required packages (uncomment in LaTeX preamble): +% \\usepackage{booktabs} +% \\usepackage[table]{xcolor} + +\\rowcolors{2}{gray!15}{white} +\\begin{tabular}{${colAlign}} +\\toprule +${rows[0].map((h) => `\\textbf{${escapeLatex(h)}}`).join(" & ")} \\\\ +\\midrule +${rows + .slice(1) + .map((r) => r.map((c) => escapeLatex(c)).join(" & ") + " \\\\") + .join("\n")} +\\bottomrule +\\end{tabular}`; + + return latex; +}