diff --git a/backend/.gitignore b/backend/.gitignore index 1a49ec347..bddc63870 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -9,6 +9,7 @@ !user_data/workflows/only_import.yaml !user_data/workflows/only_import_and_filter_proteins.yaml !user_data/workflows/standard.yaml +!user_data/workflows/cl_monomer.yaml !user_data/workflows/.test-run-empty.yaml !user_data/example_dataset diff --git a/backend/main/urls.py b/backend/main/urls.py index d21bf32e6..91e05bf05 100644 --- a/backend/main/urls.py +++ b/backend/main/urls.py @@ -49,6 +49,16 @@ path("api/save_workflow/", views.save_workflow, name="save_workflow"), path("api/get_step_form/", views.get_step_form, name="get_step_form"), path("api/get_step_plots/", views.get_step_plots, name="get_step_plots"), + path( + "api/get_step_visualizations/", + views.get_step_visualizations, + name="get_step_visualizations", + ), + path( + "api/get_downloads_from_step/", + views.get_downloads_from_step, + name="get_downloads_from_step", + ), path( "api/get_current_step_output_labels/", views.get_current_step_output_labels, @@ -76,6 +86,61 @@ path("api/get_databases", views_settings.get_databases, name="get_databases"), path("api/upload_database", views_settings.database_upload, name="database_upload"), path("api/delete_database", views_settings.database_delete, name="database_delete"), + path( + "api/get_monomer_structure", + views_settings.get_monomer_structure, + name="get_monomer_structure", + ), + path( + "api/upload_monomer_structure", + views_settings.upload_monomer_structure, + name="upload_monomer_structure", + ), + path( + "api/delete_monomer_structure", + views_settings.delete_monomer_structure, + name="delete_monomer_structure", + ), + path( + "api/get_multimer_structure", + views_settings.get_multimer_structure, + name="get_multimer_structure", + ), + path( + "api/upload_multimer_structure", + views_settings.upload_multimer_structure, + name="upload_multimer_structure", + ), + path( + "api/delete_multimer_structure", + views_settings.delete_multimer_structure, + name="delete_multimer_structure", + ), + path( + "api/get_cl_defaults", + views_settings.get_cl_defaults, + name="get_cl_defaults", + ), + path( + "api/update_cl_default", + views_settings.update_cl_default, + name="update_cl_default", + ), + path( + "api/delete_cl_default", + views_settings.delete_cl_default, + name="delete_cl_default", + ), + path( + "api/get_cl_colors", + views_settings.get_cl_colors, + name="get_cl_colors", + ), + path( + "api/update_cl_colors", + views_settings.update_cl_colors, + name="update_cl_colors", + ), path( "api/load_ptm_settings", views_settings.load_ptm_settings, diff --git a/backend/main/views.py b/backend/main/views.py index 51c7d8b1c..9b2ed0afc 100644 --- a/backend/main/views.py +++ b/backend/main/views.py @@ -3,6 +3,7 @@ import traceback from zipfile import ZipFile import re +import traceback import logging from plotly.io import to_json @@ -41,6 +42,7 @@ parameters_from_post, sanitize_name, _dataframe_as_datagrid_rows, + create_visualization, ) from backend.protzilla.all_steps import get_all_possible_steps @@ -686,6 +688,70 @@ def get_step_plots(request): ) +def get_downloads_from_step(request: HttpRequest): + if request.method != "POST": + return JsonResponse( + {"success": False, "message": "Invalid request method"}, status=405 + ) + + data = json.loads(request.body) + run_name = data.get("run_name") + step_id = data.get("step_id") + output_key = data.get("output_key") + + run = Run(run_name) + step = run.steps.get_step_by_id(step_id) + downloads = step.output.get(output_key) + if downloads is None: + downloads = {} + if not isinstance(downloads, dict): + return JsonResponse( + { + "success": False, + "message": f"Requested output must be dict object, is {str(type(downloads))}", + }, + status=405, + ) + return JsonResponse( + { + "success": True, + "message": "Got the available download(s) for the step", + "data": {"json_downloads": downloads}, + } + ) + + +def get_step_visualizations(request): + if request.method == "POST": + data = json.loads(request.body) + run_name = data.get("run_name") + step_id = data.get("step_id") + output_key = data.get("output_key") + + run = Run(run_name) + step = run.steps.get_step_by_id(step_id) + visualization_dict = step.output.get(output_key) + if visualization_dict is None: + return JsonResponse( + { + "success": False, + "message": "Got no available visualization for the step", + "data": {}, + } + ) + return JsonResponse( + { + "success": True, + "message": "Got the available visualization for the step", + "data": create_visualization(**visualization_dict), + } + ) + else: + return JsonResponse( + {"success": False, "message": "Invalid request method"}, status=405 + ) + + def get_png_from_step(request: HttpRequest): """ API call. Returns a base64-encoded PNG of a step output to the front-end @@ -713,7 +779,9 @@ def get_png_from_step(request: HttpRequest): ) content = output.decode("utf-8") - return JsonResponse({"success": True, "message": "OK", "data": content}) + return JsonResponse( + {"success": True, "message": "OK", "data": {"base64image": content}} + ) def get_current_step_table_data(request): diff --git a/backend/main/views_helper.py b/backend/main/views_helper.py index d33202cb3..281799b94 100644 --- a/backend/main/views_helper.py +++ b/backend/main/views_helper.py @@ -1,6 +1,5 @@ import re from pathlib import Path -from typing import Any import numpy as np import pandas as pd @@ -203,3 +202,130 @@ def _dataframe_as_datagrid_rows(_data: pd.DataFrame) -> list[dict] | None: return cleaned_data.to_dict(orient="records") else: return None + + +# ------------------------- helper for get_step_visualization: ------------------------- + + +def create_visualization( + cif_df: pd.DataFrame, + structure_entry_id: str, + crosslinking_df: pd.DataFrame | None = None, +) -> dict: + """ + Create visualization data, by packaging a mmCIF string (converted from a CIF DataFrame) with its structure entry ID. + Optionally include crosslinks. + + :param cif_df: DataFrame containing mmCIF atom_site information. + :param structure_entry_id: Protein identifier to include in the mmCIF header. + :param crosslinking_df: Optional DataFrame containing crosslink positions. + :return: Dictionary containing: + - "structureEntryId" (str) + - "cifString" (str) + - "crosslinks" (optional, list of dicts) + """ + try: + cif_string = convert_cif_df_to_mmcif_for_visualization( + cif_df, structure_entry_id + ) + except (ValueError, TypeError): + cif_string = "" + + result = {"structureEntryId": structure_entry_id, "cifString": cif_string} + + if crosslinking_df is not None: + result["crosslinks"] = extract_relevant_crosslink_information(crosslinking_df) + + return result + + +def convert_cif_df_to_mmcif_for_visualization( + cif_df: pd.DataFrame, structure_entry_id: str +) -> str: + """ + Convert a DataFrame containing mmCIF atom_site information back into a mmCIF string. + + :param cif_df: DataFrame with CIF columns + :param structure_entry_id: Optional entry ID for the CIF block + :return: A string representing the mmCIF file + """ + if cif_df is None or cif_df.empty: + raise ValueError("CIF-DataFrame is empty, cannot create mmCIF content.") + + lines = [ + f"data_{structure_entry_id}", + "#", + f"_entry.id {structure_entry_id}", + "#", + "loop_", + ] + + for column in cif_df.columns: + lines.append(column) + + for _, row in cif_df.iterrows(): + row_items = [] + for column in cif_df.columns: + value = row[column] + if value is None: + value_str = "." + else: + value_str = str(value) + if " " in value_str or any(char in value_str for char in "();,"): + value_str = f"'{value_str}'" + row_items.append(value_str) + lines.append(" ".join(row_items)) + + cif_string = "\n".join(lines) + return cif_string + + +def extract_relevant_crosslink_information( + crosslinking_df: pd.DataFrame, +) -> list[dict[str, int]]: + """ + For each crosslink extract its relevant information from a DataFrame. + This includes information on where the crosslinker binds on both its ends, + such as the chain and the absolute crosslinker position within the chain. + As well as a boolean for its validity and wether it is an intra or inter crosslink. + + :param crosslinking_df: DataFrame with columns + 'crosslinker_position1', + 'crosslinker_position2', + 'Chain_id1', + 'Chain_id2', + 'valid_crosslink', + 'Is_intra_crosslink', + :return: List of dicts with keys + 'crosslinkerPosition1', + 'crosslinkerPosition2', + 'ChainId1', + 'ChainId2', + 'isValid', + 'isIntraCrosslink', + """ + crosslinks = [] + for _, row in crosslinking_df.iterrows(): + position1 = row.get("crosslinker_position1") + position2 = row.get("crosslinker_position2") + # When the validation is extended to treat multimeres with more than one chain correctly, + # it should ideally store chain_id1 and chain_id2 into the crosslinking_df. + # Since we already need those chain ids to calculate correct distances in the validation, + # it would be unnecessary to determine those again in the visualization. + # Therefore we use placeholders for now and need to change the following, when the validation is extended: + chain_id1 = row.get("Chain_id1") + chain_id2 = row.get("Chain_id2") + is_valid = row.get("valid_crosslink") + is_intra_crosslink = row.get("Is_intra_crosslink") + if pd.notnull(position1) and pd.notnull(position2) and pd.notnull(is_valid): + crosslinks.append( + { + "crosslinkerPosition1": int(position1), + "crosslinkerPosition2": int(position2), + "chainId1": str(chain_id1), + "chainId2": str(chain_id2), + "isValid": bool(is_valid), + "isIntraCrosslink": bool(is_intra_crosslink), + } + ) + return crosslinks diff --git a/backend/main/views_settings.py b/backend/main/views_settings.py index 33a98d5b6..7a1a6bf8b 100644 --- a/backend/main/views_settings.py +++ b/backend/main/views_settings.py @@ -1,10 +1,12 @@ import json import os import shutil -from datetime import date +import re +from datetime import date, datetime, timezone from io import BytesIO -import pandas + +import pandas as pd import plotly.graph_objects as go import plotly.io as pio from PIL import Image @@ -12,13 +14,25 @@ from django.http import JsonResponse, FileResponse from backend.main import settings -from backend.main.views_helper import sanitize_name, load_settings_from_file -from backend.protzilla.constants.paths import EXTERNAL_DATA_PATH, SETTINGS_PATH +from backend.main.views_helper import ( + sanitize_name, + load_settings_from_file, +) +from backend.protzilla.utilities.utilities import copy_file_to_directory +from backend.protzilla.constants.paths import ( + EXTERNAL_DATA_PATH, + SETTINGS_PATH, + AF_MONOMER_METADATA_CSV_PATH, + AF_MULTIMER_METADATA_CSV_PATH, + ALPHAFOLD_MONOMER_PATH, + ALPHAFOLD_MULTIMER_PATH, +) from backend.protzilla.data_integration.database_query import ( uniprot_columns, uniprot_databases, ) from backend.protzilla.disk_operator import YamlOperator +from backend.protzilla.disk_operator import DefaultsOperator from backend.main.views_helper import load_yaml_from_file from backend.protzilla.constants.paths import ( CUSTOM_PLOT_SETTINGS_FILE_STEM, @@ -221,6 +235,513 @@ def save_ptm_settings(request, default_file_stem: str = DEFAULT_PTM_SETTINGS_FIL ) +# <--- helper functions for monomer and multimer structure prediction ---> +def check_and_copy_files_to_directory(file_names: list, target_dir: str): + if target_dir.exists(): + return ( + False, + 'Entry ID is not unique. Entry IDs are compared case insensitively, so "ABC" and "abc" are treated as the same ID.', + ) + else: + target_dir.mkdir(parents=True, exist_ok=True) + + for file_name in file_names: + source_file = settings.FILE_UPLOAD_TEMP_DIR / file_name + success, message = copy_file_to_directory(source_file, target_dir) + if not success: + shutil.rmtree(target_dir, ignore_errors=True) + return False, message + return True, "All files successfully uploaded" + + +def get_metadata_df(csv_file_path: str, expected_columns: list[str]) -> pd.DataFrame: + if csv_file_path.exists(): + df = pd.read_csv(csv_file_path, usecols=lambda c: c in expected_columns) + else: + df = pd.DataFrame(columns=expected_columns) + return df + + +def delete_structure(dir_path: str, csv_file_path: str, request): + if request.method != "POST": + return JsonResponse( + {"success": False, "message": "Invalid request method"}, status=405 + ) + + data = json.loads(request.body) + entry_id = (str(data.get("entry_id") or "")).strip() + if not entry_id: + return JsonResponse( + {"success": False, "message": "Missing entry_id"}, status=400 + ) + + # delete folder with files for the monomer structure + target_dir = dir_path / entry_id.upper() + metadata_csv = csv_file_path + + if not target_dir.exists() or not target_dir.is_dir(): + return JsonResponse( + {"success": False, "message": f"Entry folder not found: {target_dir.name}"}, + status=404, + ) + + try: + shutil.rmtree(target_dir) + except Exception as e: + return JsonResponse( + {"success": False, "message": f"Failed to delete folder: {str(e)}"}, + status=500, + ) + + # remove entry out of metadata csv + if ( + metadata_csv.exists() + and metadata_csv.is_file() + and metadata_csv.stat().st_size > 0 + ): + try: + df = pd.read_csv(metadata_csv, dtype=str) + df = df[ + (df["entry_id"].fillna("").str.strip().str).upper() != entry_id.upper() + ] + df.to_csv(metadata_csv, index=False) + + except Exception as e: + return JsonResponse( + { + "success": True, + "message": f"Folder deleted. Failed to update CSV: {str(e)}", + }, + status=200, + ) + + return JsonResponse( + {"success": True, "message": "Entry deleted successfully"}, status=200 + ) + + +def extend_metadata_csv( + entry_id: str, + metadata_csv: str, + existing_metadata_df: pd.DataFrame, + metadata_df: pd.DataFrame, +) -> None: + try: + combined = pd.concat([existing_metadata_df, metadata_df], ignore_index=True) + combined.to_csv(metadata_csv, index=False) + return True, f'"{metadata_csv}" updated successfully.' + + except Exception: + msg = f'Failed to write AlphaFold metadata CSV to "{metadata_csv}".' + return False, msg + + +# <--- Monomer Structure Predictions ---> + + +def get_monomer_structure(request): + metadata_csv = AF_MONOMER_METADATA_CSV_PATH + expected_columns = [ + "entry_id", + "uniprot_accession", + "model_created_date", + "gene", + "model_used", + ] + + df = get_metadata_df(csv_file_path=metadata_csv, expected_columns=expected_columns) + df = df.fillna("") + + df_infos = df.rename( + columns={ + "entry_id": "entry_id", + "uniprot_accession": "uniprot_id", + "model_created_date": "date_modified", + "gene": "gene", + "model_used": "model_used", + } + ).to_dict(orient="records") + + return JsonResponse(df_infos, safe=False) + + +def upload_monomer_structure(request): + if request.method == "POST": + data = json.loads(request.body) + uniprot_id = data.get("uniprot_id") + entry_id = data.get("entry_id") + model_used = data.get("model_used") + gene = data.get("gene") + cif_file = data.get("cif_file") + confidence = data.get("confidence") + pae = data.get("pae") + fasta_file = data.get("fasta_file") + + if not entry_id: + return JsonResponse( + data={ + "success": False, + "message": "The entry Id cannot be empty or None.", + }, + status=500, + ) + + if not uniprot_id: + return JsonResponse( + data={ + "success": False, + "message": "Uniprot Id cannot be empty or None.", + }, + status=500, + ) + + ALPHAFOLD_MONOMER_PATH.mkdir(parents=True, exist_ok=True) + metadata_csv = AF_MONOMER_METADATA_CSV_PATH + + expected_columns = [ + "entry_id", + "uniprot_accession", + "model_created_date", + "gene", + "model_used", + ] + + existing_metadata_df = get_metadata_df( + csv_file_path=metadata_csv, expected_columns=expected_columns + ) + + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + new_row = { + "entry_id": entry_id, + "uniprot_accession": uniprot_id, + "model_created_date": timestamp, + "gene": "" if gene is None else gene, + "model_used": "" if model_used is None else model_used, + } + + metadata_df = pd.DataFrame([new_row]) + + mask = ( + existing_metadata_df["entry_id"].astype(str).str.upper() == entry_id.upper() + ) + if mask.any(): + msg = f'Entry ID "{entry_id}" not unique. Entry IDs are compared case insensitively, so "ABC" and "abc" are treated as the same ID.' + return False, msg + + # Copy files to source directory out of temp directory + + target_dir = ALPHAFOLD_MONOMER_PATH / entry_id.upper() + file_names = [cif_file, confidence, pae, fasta_file] + success, message = check_and_copy_files_to_directory( + file_names=file_names, target_dir=target_dir + ) + + if not success: + return JsonResponse( + {"success": False, "message": message}, + status=500, + ) + + # add row to metadata csv + success, message = extend_metadata_csv( + entry_id=entry_id, + metadata_csv=metadata_csv, + existing_metadata_df=existing_metadata_df, + metadata_df=metadata_df, + ) + if not success: + shutil.rmtree(target_dir, ignore_errors=True) + return JsonResponse( + {"success": False, "message": message}, + status=500, + ) + + return JsonResponse( + { + "success": True, + "message": ( + f"Predicted monomer structure uploaded successfully. \n {message}" + if len(message) > 0 + else "Predicted monomer structure uploaded successfully." + ), + }, + status=200, + ) + else: + return JsonResponse( + {"success": False, "message": "Invalid request method"}, status=405 + ) + + +def delete_monomer_structure(request): + return delete_structure( + dir_path=ALPHAFOLD_MONOMER_PATH, + csv_file_path=AF_MONOMER_METADATA_CSV_PATH, + request=request, + ) + + +# <--- Multimer Structure Predictions ---> + + +def get_multimer_structure(request): + metadata_csv = AF_MULTIMER_METADATA_CSV_PATH + expected_columns = [ + "entry_id", + "uniprot_ids", + "model_created_date", + "model_used", + ] + df = get_metadata_df(csv_file_path=metadata_csv, expected_columns=expected_columns) + df = df.fillna("") + + df_infos = df.rename( + columns={ + "entry_id": "entry_id", + "uniprot_ids": "uniprot_ids", + "model_created_date": "date_modified", + "model_used": "model_used", + } + ).to_dict(orient="records") + + return JsonResponse(df_infos, safe=False) + + +def upload_multimer_structure(request): + if request.method == "POST": + data = json.loads(request.body) + entry_id = data.get("entry_id") + uniprot_ids = data.get("uniprot_ids") + model_used = data.get("model_used") + fasta_file = data.get("fasta_file") + cif_file = data.get("cif_file") + confidence_file = data.get("confidence_file") + full_data_file = data.get("full_data_file") + job_request_file = data.get("job_request_file") + + ALPHAFOLD_MULTIMER_PATH.mkdir(parents=True, exist_ok=True) + + if not entry_id: + return JsonResponse( + data={ + "success": False, + "message": "The entry Id cannot be empty or None.", + }, + status=500, + ) + + if not uniprot_ids: + return JsonResponse( + data={ + "success": False, + "message": "Uniprot Ids cannot be empty or None.", + }, + status=500, + ) + + metadata_csv = AF_MULTIMER_METADATA_CSV_PATH + expected_columns = [ + "entry_id", + "uniprot_ids", + "model_created_date", + "model_used", + ] + + existing_metadata_df = get_metadata_df( + csv_file_path=metadata_csv, expected_columns=expected_columns + ) + + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + uniprot_ids_as_list = re.split(r"\s*,\s*", uniprot_ids.strip()) + + new_row = { + "entry_id": entry_id, + "uniprot_ids": uniprot_ids_as_list, + "model_created_date": timestamp, + "model_used": "" if model_used is None else model_used, + } + + metadata_df = pd.DataFrame([new_row]) + + mask = ( + existing_metadata_df["entry_id"].astype(str).str.upper() == entry_id.upper() + ) + if mask.any(): + msg = f'Entry ID "{entry_id}" not unique. Entry IDs are compared case insensitively, so "ABC" and "abc" are treated as the same ID.' + return False, msg + + # Copy files to source directory out of temp directory + + target_dir = ALPHAFOLD_MULTIMER_PATH / entry_id.upper() + file_names = [ + fasta_file, + cif_file, + confidence_file, + full_data_file, + job_request_file, + ] + success, message = check_and_copy_files_to_directory( + file_names=file_names, target_dir=target_dir + ) + if not success: + return JsonResponse( + data={"success": False, "message": message}, + status=500, + ) + + # add row to metadata csv + success, message = extend_metadata_csv( + entry_id=entry_id, + metadata_csv=metadata_csv, + existing_metadata_df=existing_metadata_df, + metadata_df=metadata_df, + ) + if not success: + shutil.rmtree(target_dir, ignore_errors=True) + return JsonResponse( + {"success": False, "message": message}, + status=500, + ) + + return JsonResponse( + { + "success": True, + "message": ( + f"Predicted multimer structure uploaded successfully. \n {message}" + if len(message) > 0 + else "Predicted multimer structure uploaded successfully." + ), + }, + status=200, + ) + else: + return JsonResponse( + {"success": False, "message": "Invalid request method"}, status=405 + ) + + +def delete_multimer_structure(request): + return delete_structure( + dir_path=ALPHAFOLD_MULTIMER_PATH, + csv_file_path=AF_MULTIMER_METADATA_CSV_PATH, + request=request, + ) + + +# <--- Crosslink defaults ---> + + +def get_cl_defaults(request): + default_operator = DefaultsOperator() + defaults = default_operator.read_default(name="crosslinker_lengths") + return JsonResponse(defaults, safe=False) + + +def update_cl_default(request): + if request.method == "POST": + data = json.loads(request.body) + cl_name = data.get("cl_name") + cl_length = data.get("cl_length") if data.get("cl_length") != "" else 0 + cl_upper_deviation = ( + data.get("cl_upper_deviation") + if data.get("cl_upper_deviation") != "" + else 0 + ) + cl_lower_deviation = ( + data.get("cl_lower_deviation") + if data.get("cl_lower_deviation") != "" + else 0 + ) + + try: + defaults_operator = DefaultsOperator() + all_cl_defaults = defaults_operator.read_default(name="crosslinker_lengths") + all_cl_defaults[cl_name] = { + "cl_length": cl_length, + "cl_upper_deviation": cl_upper_deviation, + "cl_lower_deviation": cl_lower_deviation, + } + defaults_operator.write_default( + name="crosslinker_lengths", value=all_cl_defaults + ) + return JsonResponse( + { + "success": True, + "message": (f"Default values updated successfully. "), + }, + status=200, + ) + except Exception: + return JsonResponse( + {"success": False, "message": "Default values could not be updated."}, + status=405, + ) + else: + return JsonResponse( + {"success": False, "message": "Invalid request method"}, status=405 + ) + + +def delete_cl_default(request): + if request.method == "POST": + try: + data = json.loads(request.body) + cl_name = data.get("cl_name") + defaults_operator = DefaultsOperator() + cl_defaults = defaults_operator.read_default(name="crosslinker_lengths") + del cl_defaults[cl_name] + defaults_operator.write_default( + name="crosslinker_lengths", value=cl_defaults + ) + return JsonResponse( + { + "success": True, + "message": "Default values deleted successfully.", + }, + status=200, + ) + except Exception: + return JsonResponse( + {"success": False, "message": "Error occured while deleting."}, + status=405, + ) + return JsonResponse( + {"success": False, "message": "Invalid request method"}, status=405 + ) + + +# <--- Crosslink colors ---> + + +def get_cl_colors(request): + operator = DefaultsOperator() + colors = operator.read_default(name="crosslinker_colors") + return JsonResponse(colors or {}, safe=False) + + +def update_cl_colors(request): + if request.method == "POST": + data = json.loads(request.body) + + try: + operator = DefaultsOperator() + operator.write_default(name="crosslinker_colors", value=data) + + return JsonResponse( + {"success": True, "message": "Colours updated successfully."}, + status=200, + ) + except Exception: + return JsonResponse( + {"success": False, "message": "Could not update colours."}, + status=405, + ) + + return JsonResponse({"success": False, "message": "Invalid method"}, status=405) + + # <--- Databases ---> @@ -279,7 +800,7 @@ def database_upload(request): return JsonResponse({"success": False, "message": msg}, status=400) try: - dataframe = pandas.read_csv(path, sep="\t") + dataframe = pd.read_csv(path, sep="\t") except UnicodeDecodeError: msg = "File could not be decoded." messages.add_message(request, messages.ERROR, msg, "alert-danger") diff --git a/backend/protzilla/all_steps.py b/backend/protzilla/all_steps.py index 8f99cd235..b5717dc1e 100644 --- a/backend/protzilla/all_steps.py +++ b/backend/protzilla/all_steps.py @@ -15,6 +15,12 @@ importing.EvidenceImport, importing.ExampleDatasetImport, importing.FastaImport, + importing.AlphaFoldPredictionLoad, + importing.CrosslinkingImport, + importing.AlphaFoldQueryJsonGeneration, + importing.ImportMonomerStructurePredictionFromDisk, + importing.UploadMultimerPredictions, + importing.ImportMultimerStructurePredictionFromDisk, data_preprocessing.FilterProteinsBySamplesMissing, data_preprocessing.FilterProteinsByNumberOfValuesPerGroup, data_preprocessing.FilterProteinsByProteinIDs, @@ -74,6 +80,8 @@ data_analysis.PTMOverviewVisualization, data_analysis.PTMBarVisualization, data_analysis.PTMDetailsVisualization, + data_analysis.CrosslinkingValidationWithAngstromDeviation, + data_analysis.CrosslinkingValidationWithAngstromDeviationForMultimer, data_preprocessing.ImputationByMinPerSample, data_integration.EnrichmentAnalysisGOAnalysisWithString, data_integration.EnrichmentAnalysisGOAnalysisWithEnrichr, diff --git a/backend/protzilla/constants/cif_columns.py b/backend/protzilla/constants/cif_columns.py new file mode 100644 index 000000000..5715f2f32 --- /dev/null +++ b/backend/protzilla/constants/cif_columns.py @@ -0,0 +1,63 @@ +from enum import StrEnum + + +ATOM_SITE_PREFIX = "_atom_site." + + +class ATOM_SITE_COLUMNS(StrEnum): + """ + Enum containing all column names that should be present in + the _atom_site. table for mmCIF files from PDB or AFDB + """ + + ID = f"{ATOM_SITE_PREFIX}id" + TYPE_SYMBOL = f"{ATOM_SITE_PREFIX}type_symbol" + LABEL_ATOM_ID = f"{ATOM_SITE_PREFIX}label_atom_id" + LABEL_ALT_ID = f"{ATOM_SITE_PREFIX}label_alt_id" + LABEL_COMP_ID = f"{ATOM_SITE_PREFIX}label_comp_id" + LABEL_ASYM_ID = f"{ATOM_SITE_PREFIX}label_asym_id" + LABEL_ENTITY_ID = f"{ATOM_SITE_PREFIX}label_entity_id" + LABEL_SEQ_ID = f"{ATOM_SITE_PREFIX}label_seq_id" + PDBX_PDB_INS_CODE = f"{ATOM_SITE_PREFIX}pdbx_PDB_ins_code" + CARTN_X = f"{ATOM_SITE_PREFIX}Cartn_x" + CARTN_Y = f"{ATOM_SITE_PREFIX}Cartn_y" + CARTN_Z = f"{ATOM_SITE_PREFIX}Cartn_z" + OCCUPANCY = f"{ATOM_SITE_PREFIX}occupancy" + B_ISO_OR_EQUIV = f"{ATOM_SITE_PREFIX}B_iso_or_equiv" + PDBX_FORMAL_CHARGE = f"{ATOM_SITE_PREFIX}pdbx_formal_charge" + AUTH_SEQ_ID = f"{ATOM_SITE_PREFIX}auth_seq_id" + AUTH_COMP_ID = f"{ATOM_SITE_PREFIX}auth_comp_id" + AUTH_ASYM_ID = f"{ATOM_SITE_PREFIX}auth_asym_id" + AUTH_ATOM_ID = f"{ATOM_SITE_PREFIX}auth_atom_id" + PDBX_PDB_MODEL_NUM = f"{ATOM_SITE_PREFIX}pdbx_PDB_model_num" + + +ATOM_SITE_LABEL_COMP_ID = ATOM_SITE_COLUMNS.LABEL_COMP_ID + +ATOM_SITE_COLUMNS_NUMERIC = [ + ATOM_SITE_COLUMNS.ID, + ATOM_SITE_COLUMNS.LABEL_SEQ_ID, + ATOM_SITE_COLUMNS.CARTN_X, + ATOM_SITE_COLUMNS.CARTN_Y, + ATOM_SITE_COLUMNS.CARTN_Z, + ATOM_SITE_COLUMNS.OCCUPANCY, + ATOM_SITE_COLUMNS.B_ISO_OR_EQUIV, + ATOM_SITE_COLUMNS.AUTH_SEQ_ID, +] + +CHEM_COMP_PREFIX = "_chem_comp." + + +class CHEM_COMP_COLUMNS(StrEnum): + """ + Enum containing all column names that should be present in + the _chem_comp. table for mmCIF files from PDB or AFDB + """ + + ID = f"{CHEM_COMP_PREFIX}id" + TYPE = f"{CHEM_COMP_PREFIX}type" + MON_NSTD_FLAG = f"{CHEM_COMP_PREFIX}mon_nstd_flag" + NAME = f"{CHEM_COMP_PREFIX}name" + PDBX_SYNONYMS = f"{CHEM_COMP_PREFIX}pdbx_synonyms" + FORMULA = f"{CHEM_COMP_PREFIX}formula" + FORMULA_WEIGHT = f"{CHEM_COMP_PREFIX}formula_weight" diff --git a/backend/protzilla/constants/data_types.py b/backend/protzilla/constants/data_types.py index b035f6657..94f4db695 100644 --- a/backend/protzilla/constants/data_types.py +++ b/backend/protzilla/constants/data_types.py @@ -11,6 +11,7 @@ class DataKey(StrEnum): PEPTIDE_DF = "peptide_df" PSM_DF = "psm_df" # psm = peptide spectrum match METADATA_DF = "metadata_df" + STRUCTURE_METADATA_DF = "structure_metadata_df" FASTA_DF = "fasta_df" SIGNIFICANT_PROTEINS_DF = "significant_proteins_df" PTM_DF = "ptm_df" @@ -20,6 +21,14 @@ class DataKey(StrEnum): LOG2_FOLD_CHANGE_DF = "log2_fold_change_df" ENRICHMENT_DF = "enrichment_df" GENE_MAPPING_DF = "gene_mapping_df" + CIF_DF = "cif_df" + AMINO_ACID_SEQUENCES_DF = "amino_acid_sequences_df" + PAE_MATRIX = "pae_matrix" # pae = predicted aligned error + PLDDT_DF = "plddt_df" # plddt = predicted local distance difference test + CROSSLINKING_DF = "crosslinking_df" + CONFIDENCE_DF = "confidence_df" + FULL_DATA_DF = "full_data_df" + JOB_REQUEST_DF = "job_request_df" ProteinDf = NewType("ProteinDf", pd.DataFrame) diff --git a/backend/protzilla/constants/option_types.py b/backend/protzilla/constants/option_types.py index 678efe9cb..30b2a61bc 100644 --- a/backend/protzilla/constants/option_types.py +++ b/backend/protzilla/constants/option_types.py @@ -60,6 +60,13 @@ class PValueColumnName(StrEnum): ptm = "PTM" +class CrosslinkingValidationCriterion(Enum): + manual_bounds = "Manual Bounds (set below)" + max_pae = "CL length +/- maximum PAE between sites" + min_pae = "CL length +/- minimum PAE between sites" + plddt_adjusted = "plDDT adjusted" + + FC_SIGNIFICANCE_COLUMNS = ["Protein ID", "fc_z_score", "fc_significance"] CORRECTED_P_VALUES_COLUMNS = [ "Protein ID", diff --git a/backend/protzilla/constants/paths.py b/backend/protzilla/constants/paths.py index a2bdcde92..4c86569c0 100644 --- a/backend/protzilla/constants/paths.py +++ b/backend/protzilla/constants/paths.py @@ -11,6 +11,13 @@ SETTINGS_PATH = USER_DATA_PATH / "settings" EXTERNAL_DATA_PATH = USER_DATA_PATH / "external_data" UPLOAD_PATH = BACKEND_PATH / "uploads" +ALPHAFOLD_PATH = EXTERNAL_DATA_PATH / "alphafold" +ALPHAFOLD_MULTIMER_PATH = ALPHAFOLD_PATH / "multimer" +ALPHAFOLD_MONOMER_PATH = ALPHAFOLD_PATH / "monomer" +AF_MONOMER_METADATA_CSV_PATH = ALPHAFOLD_MONOMER_PATH / "alphafold_monomer_metadata.csv" +AF_MULTIMER_METADATA_CSV_PATH = ( + ALPHAFOLD_MULTIMER_PATH / "alphafold_multimer_metadata.csv" +) CUSTOM_PLOT_SETTINGS_FILE_STEM = "plots" DEFAULT_PLOT_SETTINGS_FILE_STEM = "plots_default" diff --git a/backend/protzilla/data_analysis/crosslinking_validation.py b/backend/protzilla/data_analysis/crosslinking_validation.py new file mode 100644 index 000000000..8d8b765df --- /dev/null +++ b/backend/protzilla/data_analysis/crosslinking_validation.py @@ -0,0 +1,1382 @@ +import itertools +import ast +import math + +from typing import Callable + +from backend.protzilla.constants.option_types import CrosslinkingValidationCriterion +import pandas as pd +import numpy as np +import re +import logging + +import plotly.graph_objects as go +from plotly.graph_objects import Figure + +from backend.protzilla.data_preprocessing.plots import ( + create_histograms, + create_bar_plot, +) +from backend.protzilla.constants.protzilla_logging import logger +from backend.protzilla.data_analysis.plots import ( + add_vertical_line_with_annotation_in_legend, +) +from backend.protzilla.steps import OutputItem, OutputType +from backend.protzilla.data_preprocessing.plots_helper import millify + +import textwrap + +from plotly.subplots import make_subplots + +from backend.protzilla.data_preprocessing.plots_helper import generate_tics +from backend.protzilla.utilities.utilities import default_intensity_column +from backend.protzilla.constants.colors import ( + PLOT_COLOR_SEQUENCE, + PLOT_PRIMARY_COLOR, + PLOT_SECONDARY_COLOR, +) + + +def get_reactive_atom_of_amino_acid_residue(amino_acid_type: str) -> str: + """ + Returns the atom of an amino acid residue that is considered reactive for + crosslinking. Currently, this always returns the central alpha carbon (CA). + + :param amino_acid_type: code of the amino acid + + :return: the atom identifier of the reactive atom as a string + """ + # right now we always return the central C atom + # later we might want to return the reactive atom of the amino acid residue of the specific amino acid type + # as soon as we change this, we will need to change the test test_validate_with_angstrom_deviation (and the visualization) + return "CA" + + +def get_coordinates_of_atom_crosslinker_bound_to( + amino_acid_position_where_crosslinker_bound: int, + amino_acid_type: str, + cif_df: pd.DataFrame, + chain_id: str, +) -> tuple[float, float, float]: + """ + Returns the Cartesian coordinates of the atom to which the crosslinker is + bound for a given amino acid residue in a protein structure. + + :param amino_acid_position_where_crosslinker_bound: 1-based position of the amino acid residue + :param amino_acid_type: amino acid type at the given position + :param cif_df: DataFrame containing CIF information (predicted coordinates of all the protein's atoms) + :param chain_id: ID of the chain of the atom the crosslinker bounds to + :return: a tuple (x, y, z) containing the Cartesian coordinates of the atom in Ångström + :raises ValueError: if the specified atom cannot be found in the CIF data + """ + + relevant_atom = get_reactive_atom_of_amino_acid_residue(amino_acid_type) + seq_ids = pd.to_numeric(cif_df["_atom_site.label_seq_id"], errors="coerce") + + # Filter to the exact reactive atom of the amino acid residue + # where the crosslinker is bound (e.g. CA at position 45) + cif_df = cif_df[ + (cif_df["_atom_site.label_atom_id"] == relevant_atom) + & (seq_ids == amino_acid_position_where_crosslinker_bound) + & (cif_df["_atom_site.auth_asym_id"] == chain_id) + ] + + if cif_df.empty: + raise ValueError( + f"No {relevant_atom} atom found for amino acid at position {amino_acid_position_where_crosslinker_bound} in chain {chain_id}." + ) + + row = cif_df.iloc[0] + + x = float(row["_atom_site.Cartn_x"]) + y = float(row["_atom_site.Cartn_y"]) + z = float(row["_atom_site.Cartn_z"]) + + return x, y, z + + +def get_distance_between_two_amino_acids_in_angstrom( + amino_acid_position1: int, + amino_acid_position2: int, + amino_acid_type1: str, + amino_acid_type2: str, + cif_df: pd.DataFrame, + chain_id1: str, + chain_id2: str, +) -> float: + """ + Calculates the Euclidean distance in Ångström between two amino acid residues + based on the coordinates of their reactive atoms in the AlphaFold/predicted structure. + + :param amino_acid_position1: 1-based position of the first amino acid residue + :param amino_acid_position2: 1-based position of the second amino acid residue + :param amino_acid_type1: amino acid type at the first position + :param amino_acid_type2: amino acid type at the second position + :param cif_df: DataFrame containing CIF information (predicted coordinates of all the protein's atoms) + :param chain_id1: ID of the chain of the first amino acid residue in which crosslinker binds + :param chain_id2: ID of the chain of the second amino acid residue in which crosslinker binds + :return: the distance between the two residues in Ångström + """ + + pos1 = np.array( + get_coordinates_of_atom_crosslinker_bound_to( + amino_acid_position1, + amino_acid_type1, + cif_df, + chain_id1, + ), + dtype=float, + ) + + pos2 = np.array( + get_coordinates_of_atom_crosslinker_bound_to( + amino_acid_position2, + amino_acid_type2, + cif_df, + chain_id2, + ), + dtype=float, + ) + + return float(np.linalg.norm(pos2 - pos1)) + + +def get_protein_sequence_from_df( + amino_acid_sequences_df: pd.DataFrame, protein_id: str +) -> str: + """ + Returns the amino acid sequence for a given protein ID from a DataFrame. + + If the provided protein ID does not contain an isoform suffix, the default suffix "-1" + is appended to match the format used in the DataFrame (e.g. "O43242" becomes "O43242-1"). + + :param amino_acid_sequences_df: DataFrame containing at least the columns + "Protein ID" and "Protein Sequence" + :param protein_id: UniProt protein identifier, with or without isoform suffix + :return: the corresponding amino acid sequence as a string, or an empty string + if the protein ID is not found + """ + # because protein ids like O43242 are saved as O43242-1 in amino_acid_sequences_df + if "-" not in protein_id: + protein_id = f"{protein_id}-1" + + matches = amino_acid_sequences_df.loc[ + amino_acid_sequences_df["Protein ID"] == protein_id, "Protein Sequence" + ] + + if matches.empty: + raise KeyError("Protein ID not found in the given fasta file.") + + return matches.iloc[0] + + +def add_protein_crosslink_positions_to_df( + input_crosslinking_df: pd.DataFrame, + amino_acid_sequences_df: pd.DataFrame, +) -> tuple[pd.DataFrame, list[dict]]: + """ + Add protein-level crosslink residue positions to a crosslinking DataFrame. + + For each row, this function finds the 1-based residue positions in the full protein + sequence(s) that correspond to the crosslinked residue within each peptide. The + protein-level positions are written to two new columns: + + - 'crosslinker_position1': 1-based residue position in Protein_id1 for Peptide1. + - 'crosslinker_position2': 1-based residue position in Protein_id2 for Peptide2. + + If a peptide occurs multiple times in the corresponding protein sequence, all + combinations of (position1, position2) are generated. The first combination is + kept in the original row and the row is duplicated for each additional combination. + + If either peptide cannot be matched in its corresponding protein sequence, the row + is removed and a warning message is recorded. + + :param input_crosslinking_df: DataFrame containing crosslinking data with at least the following columns: + - 'Peptide1': first peptide sequence + - 'Peptide2': second peptide sequence + - 'CL_position_within_peptide1': 0-based crosslinker position within Peptide1 + - 'CL_position_within_peptide2': 0-based crosslinker position within Peptide2 + :param amino_acid_sequences_df: Dataframe that contains all amino acid sequences + :return: tuple (updated_crosslinking_df, messages) + - updated_crosslinking_df: input DataFrame with two new columns: + - 'crosslinker_position1': 1-based crosslinker position in Peptide1 + - 'crosslinker_position2': 1-based crosslinker position in Peptide2 + Rows are duplicated for multiple peptide matches. + - messages: list of warning dictionaries if the peptide was not found or a row was duplicated + """ + crosslinking_df = input_crosslinking_df.copy() + crosslinking_df["crosslinker_position1"] = pd.Series(dtype="Int64") + crosslinking_df["crosslinker_position2"] = pd.Series(dtype="Int64") + rows_to_duplicate = {} + rows_to_delete = [] + messages = [] + + def get_crosslink_positions_in_protein( + peptide: str, protein_id: str, cl_position_within_peptide: int + ) -> list: + """ + Returns the 1-based positions of the crosslinked residue within the full + protein sequence for all occurrences of a given peptide. + + :param peptide: peptide sequence to search for in the protein + :param protein_id: UniProt protein identifier + :param cl_position_within_peptide: 1-based position of the crosslinked residue within the peptide + :return: list of 1-based residue positions in the protein sequence + """ + protein_sequence = get_protein_sequence_from_df( + amino_acid_sequences_df=amino_acid_sequences_df, protein_id=protein_id + ) + positions = [ + m.start() + cl_position_within_peptide + 1 + for m in re.finditer(f"(?={peptide})", protein_sequence) + ] + return positions + + for idx, crosslinker_row in crosslinking_df.iterrows(): + peptide_sequence1 = re.escape(crosslinker_row.Peptide1) + peptide_sequence2 = re.escape(crosslinker_row.Peptide2) + protein_id1 = crosslinker_row.Protein_id1 + protein_id2 = crosslinker_row.Protein_id2 + + peptide1_positions = get_crosslink_positions_in_protein( + peptide_sequence1, protein_id1, crosslinker_row.CL_position_within_peptide1 + ) + peptide2_positions = get_crosslink_positions_in_protein( + peptide_sequence2, protein_id2, crosslinker_row.CL_position_within_peptide2 + ) + + all_position_combinations = list( + itertools.product(peptide1_positions, peptide2_positions) + ) + if not all_position_combinations: + if not peptide1_positions and not peptide2_positions: + msg = f"Peptide sequences {peptide_sequence1} and {peptide_sequence2} of crosslink entry {idx} were not found in the protein sequences. The entry was deleted." + else: + msg = f"Peptide sequence {peptide_sequence1 if not peptide1_positions else peptide_sequence2} of crosslink entry {idx} was not found in the protein sequences. The entry was deleted." + messages.append(dict(level=logging.WARNING, msg=msg)) + rows_to_delete.append(idx) + continue + crosslinker_position1, crosslinker_position2 = all_position_combinations[0] + + crosslinking_df.at[idx, "crosslinker_position1"] = crosslinker_position1 + crosslinking_df.at[idx, "crosslinker_position2"] = crosslinker_position2 + if len(all_position_combinations) > 1: + rows_to_duplicate[idx] = all_position_combinations[1:] + + crosslinking_df.drop(rows_to_delete, inplace=True) + + if not rows_to_duplicate: + return crosslinking_df, messages + new_rows = [] + for row_to_duplicate_idx, potential_positions in rows_to_duplicate.items(): + for potential_cl_position1, potential_cl_position2 in potential_positions: + new_row = crosslinking_df.loc[row_to_duplicate_idx].copy() + new_row["crosslinker_position1"] = potential_cl_position1 + new_row["crosslinker_position2"] = potential_cl_position2 + new_rows.append(new_row) + messages.append( + dict( + level=logging.WARNING, + msg=f"Row {row_to_duplicate_idx} was duplicated {len(potential_positions)} times due to several matches between peptide sequence and protein sequence.", + ) + ) + if new_rows: + crosslinking_df = pd.concat( + [crosslinking_df, pd.DataFrame(new_rows)], ignore_index=True + ) + + return crosslinking_df, messages + + +def get_chains( + cif_df: pd.DataFrame, + valid_ids: dict, + protein_id: str, + id_column_name: str, +) -> list: + """ + Returns a list of unique chain IDs belonging to a given protein + in an mmCIF-derived DataFrame. + + :param cif_df: mmCIF data as a pandas DataFrame. + :param valid_ids: dictionary of valid IDs. + :param protein_id: identifier of the protein you want to query. + :param id_column_name: column name to check against valid_ids. + :return: list of unique chain IDs. + """ + target_ids = valid_ids.get(protein_id, []) + if not target_ids: + return [] + target_ids_as_strings = [str(i) for i in target_ids] + relevant_df = cif_df[cif_df[id_column_name].astype(str).isin(target_ids_as_strings)] + chain_ids = relevant_df["_atom_site.auth_asym_id"].dropna().unique().tolist() + return chain_ids + + +def _get_structure_entry_id(structure_metadata_df: pd.DataFrame) -> list[str]: + if "entry_id" in structure_metadata_df.columns: + return structure_metadata_df["entry_id"].iloc[0] + else: + raise ValueError("Metadata must contain 'entry_id'.") + + +def expand_crosslinks_to_chain_combinations( + relevant_crosslinks_df: pd.DataFrame, + chains_per_protein: dict[str, dict[str, int]], +) -> pd.DataFrame: + """ + Duplicate each crosslink row so that all possible chain combinations + are represented. + + + :param relevant_crosslinks_df: dataframe that contains information on the crosslinks between the proteins + :param chains_per_protein: dictionary that contains all chain_ids in a list for each protein id + return: crosslinks dataframe with the additional columns of Chain_id1 and Chain_id2 + """ + expanded_rows = [] + + for _, crosslink in relevant_crosslinks_df.iterrows(): + protein_id1 = crosslink["Protein_id1"] + protein_id2 = crosslink["Protein_id2"] + + chain_ids1 = chains_per_protein[protein_id1] + chain_ids2 = chains_per_protein[protein_id2] + + if not chain_ids1 or not chain_ids2: + continue + + # we do not want the same combination twice if the protein_ids are the same + # e.g.: protein 1 chain A - protein 1 chain B and protein 1 chain B - protein 1 chain A + if protein_id1 == protein_id2: + chain_pairs = itertools.combinations_with_replacement(chain_ids1, 2) + else: + chain_pairs = itertools.product(chain_ids1, chain_ids2) + + for chain_id1, chain_id2 in chain_pairs: + new_row = crosslink.copy() + new_row["Chain_id1"] = chain_id1 + new_row["Chain_id2"] = chain_id2 + expanded_rows.append(new_row) + + if not expanded_rows: + return pd.DataFrame( + columns=list(relevant_crosslinks_df.columns) + ["Chain_id1", "Chain_id2"] + ) + + return pd.DataFrame(expanded_rows).reset_index(drop=True) + + +def monomer_validation( + crosslinking_df: pd.DataFrame, + structure_metadata_df: pd.DataFrame, + crosslinker_information: dict[str, list[float]], + cif_df: pd.DataFrame, + amino_acid_sequences_df: pd.DataFrame, + pae_matrix: np.ndarray[tuple[int, int]], + plddt_df: pd.DataFrame, + validation_criterion: CrosslinkingValidationCriterion, +) -> dict: + """ + Validates crosslinking data for a monomeric protein structure by checking + distance deviations (in Angstroms). + + :param crosslinking_df: DataFrame containing the full set of crosslinks. + :param structure_metadata_df: DataFrame containing structural metadata. + :param crosslinker_information: Dictionary mapping crosslinker names to their + allowed distance boundaries (e.g., [min_dist, max_dist]). + :param cif_df: DataFrame containing mmCIF information. + :param amino_acid_sequences_df: DataFrame containing known amino acid sequences. + :param pae_matrix: NumPy 2D array containing AlphaFold PAE data. + :param plddt_df: DataFrame containing AlphaFold pLDDT data. + :return: A dictionary containing the validation results and distance metrics. + """ + protein_id = structure_metadata_df["uniprot_accession"].iloc[0] + valid_ids = {protein_id: [protein_id]} + return validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + crosslinker_information=crosslinker_information, + structure_metadata_df=structure_metadata_df, + cif_df=cif_df, + amino_acid_sequences_df=amino_acid_sequences_df, + pae_matrix=pae_matrix, + plddt_df=plddt_df, + valid_ids=valid_ids, + id_column_name="_atom_site.pdbx_sifts_xref_db_acc", + structures_to_validate=[protein_id], + validation_criterion=validation_criterion, + ) + + +def get_protein_id_from_sequence(amino_acid_sequences_df, target_sequence): + """ + Finds the Protein ID(s) for a given exact protein sequence. + + :param amino_acid_sequences_df: Dataframe that contains all amino acid sequences + :param target_sequence: The protein sequence you want to find the protein id of. + :return: the Protein ID that matches the sequence. (Should only be one, therefore we take the first one) + """ + matching_rows = amino_acid_sequences_df[ + amino_acid_sequences_df["Protein Sequence"] == target_sequence + ] + if not matching_rows.empty: + return matching_rows["Protein ID"].iloc[0] + else: + return None + + +def get_valid_ids_per_protein_id_from_job_request( + amino_acid_sequences_df: pd.DataFrame, job_request_df: pd.DataFrame +) -> dict: + """ + Extracts protein sequences from an AlphaFold Server job request and assigns + them their protein ID based on the given amino sequences df. It then checks the count + and collects all ids that will later be used in the cif file to identify the proteins + instead of their protein ids (because there are no protein ids in the cif file given) + + :param amino_acid_sequences_df: Dataframe that contains all amino acid sequences. + :param job_request_df: DataFrame containing the loaded AlphaFold job request JSON, + which must include a 'sequences' column. + :return: A dictionary mapping Protein IDs to a list of their assigned unique integer + chain IDs. Example: {'P12345': [1, 2], 'Q67890': [3]} + """ + valid_ids = {} + unique_id = 1 + + sequences_list = job_request_df["sequences"].iloc[0] + if isinstance(sequences_list, str): + sequences_list = ast.literal_eval(sequences_list) + + for item in sequences_list: + if "proteinChain" in item: + seq_string = item["proteinChain"]["sequence"] + count = item["proteinChain"]["count"] + protein_id = get_protein_id_from_sequence( + amino_acid_sequences_df, seq_string + ) + if protein_id is not None: + # Remove the specific isoform/variant suffix because we do not use it in the crosslinking df + protein_id = protein_id.replace("-1", "") + for _ in range(count): + valid_ids.setdefault(protein_id, []).append(unique_id) + unique_id += 1 + return valid_ids + + +def get_global_residue_index( + position_within_protein: int, # 1-based index + chain_id: str, + cif_df: pd.DataFrame, +): + """ + For multimer PAE lookup: For a position within a given protein in a chain, + get the global 0-based residue index used to find that position in the PAE matrix. + + Note: This assumes that the order of AAs in the _atom_site table corresponds + to the order of residues in the pae matrix and thus the other residue-based tables in + the cif. + + :param position_within_protein: index of the amino acid within the protein (1-based) + :param chain_id: the chain ID of the protein within the complex + :param cif_df: DataFrame containing the _atom_site table of the complex structure + """ + + # Get table with only unique chain and sequence IDs and infer global index + index_lookup_df = ( + cif_df[["_atom_site.label_asym_id", "_atom_site.label_seq_id"]] + .drop_duplicates() + .reset_index(drop=True) + ) + index_lookup_df.reset_index(inplace=True) + + index_lookup_df = index_lookup_df[ + index_lookup_df["_atom_site.label_asym_id"] == chain_id + ] + index_lookup_df = index_lookup_df[ + index_lookup_df["_atom_site.label_seq_id"] == position_within_protein + ] + + if len(index_lookup_df) != 1: + raise ValueError( + "Invalid input: CIF contains multiple atoms mapped to same chain/sequence ID pair!" + ) + + return index_lookup_df["index"].iloc[0] + + +def multimer_validation( + crosslinking_df: pd.DataFrame, + structure_metadata_df: pd.DataFrame, + crosslinker_information: dict[str, list[float]], + cif_df: pd.DataFrame, + amino_acid_sequences_df: pd.DataFrame, + job_request_df: pd.DataFrame, + plddt_df: pd.DataFrame, + pae_matrix: np.ndarray[tuple[int, int]], + validation_criterion: CrosslinkingValidationCriterion, +) -> dict: + """ + Validates crosslinking data for a multimeric protein complex by checking + distance deviations (in Angstroms). + + This function maps sequences from an AlphaFold Server job request to determine + valid chain IDs, filters the crosslinking dataset to include only interactions + between these valid structures, and delegates the structural distance calculation. + + :param crosslinking_df: DataFrame containing the full set of crosslinks. + :param structure_metadata_df: DataFrame containing structural metadata. + (Note: Passed for pipeline consistency, but unused in this step). + :param crosslinker_information: Dictionary mapping crosslinker names to their + allowed distance boundaries (e.g., [min_dist, max_dist]). + :param cif_df: DataFrame containing mmCIF information. + :param amino_acid_sequences_df: DataFrame containing known amino acid sequences. + :param job_request_df: DataFrame containing the loaded AlphaFold job request JSON. + :param plddt_df: DataFrame containing per-residue pLDDT values. + :param pae_matrix: NumPy 2D array containing the PAE values for each residue pair. + :return: A dictionary containing the validation results and distance metrics. + """ + valid_ids = get_valid_ids_per_protein_id_from_job_request( + amino_acid_sequences_df=amino_acid_sequences_df, job_request_df=job_request_df + ) + structures_to_validate = list(valid_ids.keys()) + + return validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + crosslinker_information=crosslinker_information, + structure_metadata_df=structure_metadata_df, + cif_df=cif_df, + amino_acid_sequences_df=amino_acid_sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.label_entity_id", + structures_to_validate=structures_to_validate, + pae_matrix=pae_matrix, + plddt_df=plddt_df, + validation_criterion=validation_criterion, + ) + + +def validate_with_angstrom_deviation( + crosslinking_df: pd.DataFrame, + crosslinker_information: dict[str, list[float]], + structure_metadata_df: pd.DataFrame, + cif_df: pd.DataFrame, + amino_acid_sequences_df: pd.DataFrame, + valid_ids: dict[str, list[int]], + id_column_name: str, + structures_to_validate: list, + validation_criterion: CrosslinkingValidationCriterion, + plddt_df: pd.DataFrame | None = None, + pae_matrix: np.ndarray[tuple[int, int]] | None = None, +) -> dict: + """ + Validates crosslinks by comparing the crosslinker lengths with the distances between the linked + amino acids in the AlphaFold protein structure. A crosslink is regarded as valid if it matches the AlphaFold data, + so if the distance between the connected amino acids in AlphaFold is less than (crosslinker length + the upper allowed deviation) + and more than (crosslinker length - the lower allowed deviation). If one of the bounds is zero only the other bound will be applied. + + :param crosslinking_df: DataFrame containing the crosslinking data to validate. + :param crosslinker_information: Dictionary mapping crosslinker names to a list of three floats: + [crosslinker_length, upper_accepted_deviation, lower_accepted_deviation]. + :param cif_df: DataFrame containing CIF information (predicted coordinates of all the protein's atoms). + :param plddt_df: DataFrame containing the local AlphaFold pLDDT values for each residue. + :param pae_matrix: NumPy 2D array containing the PAE values for each residue pair. + :param amino_acid_sequences_df: Dataframe that contains all known amino acid sequences. + :param valid_ids: Dictionary mapping protein IDs to their valid chain/entity identifiers in the CIF data. + :param id_column_name: The column name in the cif_df to use for matching against valid_ids. + :param structures_to_validate: List of protein IDs to validate. + :return: A dictionary containing: + - 'crosslinking_result_df': DataFrame containing the validated rows augmented with alphafold distances, + validation booleans, crosslinker positions, and link types (intra/inter). + - 'messages': List of dictionaries containing log levels and warning/info messages. + :raises KeyError: If a required crosslinker field is missing in crosslinker_information. + :raises ValueError: If peptide sequences cannot be matched to the protein sequence. + """ + + all_crosslinks_df = crosslinking_df.copy() + mask = (all_crosslinks_df["Protein_id1"].isin(structures_to_validate)) & ( + all_crosslinks_df["Protein_id2"].isin(structures_to_validate) + ) + relevant_crosslinks_df = all_crosslinks_df[mask] + + # Check if dataframe is empty + if relevant_crosslinks_df.empty: + msg = "There are no crosslinks between the structures to validate." + messages = [dict(level=logging.WARNING, msg=msg)] + logger.warning(msg) + return dict(crosslinking_result_df=pd.DataFrame(), messages=messages) + + chains_per_protein = {} + for protein_id in structures_to_validate: + chains_per_protein[protein_id] = get_chains( + cif_df=cif_df, + valid_ids=valid_ids, + protein_id=protein_id, + id_column_name=id_column_name, + ) + + relevant_crosslinks_df = expand_crosslinks_to_chain_combinations( + relevant_crosslinks_df=relevant_crosslinks_df, + chains_per_protein=chains_per_protein, + ) + + if relevant_crosslinks_df.empty: + msg = "There are no crosslinks between the structures to validate." + messages = [dict(level=logging.WARNING, msg=msg)] + logger.warning(msg) + return dict(crosslinking_result_df=pd.DataFrame(), messages=messages) + + relevant_crosslinks_df, messages = add_protein_crosslink_positions_to_df( + relevant_crosslinks_df, amino_acid_sequences_df + ) + + def check_crosslink(crosslink: pd.Series) -> pd.Series: + protein_id1 = crosslink.Protein_id1 + protein_id2 = crosslink.Protein_id2 + protein_sequence1 = get_protein_sequence_from_df( + amino_acid_sequences_df=amino_acid_sequences_df, protein_id=protein_id1 + ) + protein_sequence2 = get_protein_sequence_from_df( + amino_acid_sequences_df=amino_acid_sequences_df, protein_id=protein_id2 + ) + + def get_site_plddts(crosslink: pd.Series): + if plddt_df is None: + return np.nan, np.nan + + plddt_at_position1 = float( + plddt_df.query( + "residueNumber == @crosslink.crosslinker_position1 and " + + "chainID == @crosslink.Chain_id1" + ).iloc[0]["confidenceScore"] + ) + plddt_at_position2 = float( + plddt_df.query( + "residueNumber == @crosslink.crosslinker_position2 and " + + "chainID == @crosslink.Chain_id2" + ).iloc[0]["confidenceScore"] + ) + + return plddt_at_position1, plddt_at_position2 + + def get_paes(): + if pae_matrix is None: + return np.nan, np.nan + + pae_index_pos1 = get_global_residue_index( + crosslink.crosslinker_position1, crosslink.Chain_id1, cif_df + ) + pae_index_pos2 = get_global_residue_index( + crosslink.crosslinker_position2, crosslink.Chain_id2, cif_df + ) + pae_x_position1 = pae_matrix[ + pae_index_pos1, pae_index_pos2 + ] # Using position1 as scored residue + pae_x_position2 = pae_matrix[ + pae_index_pos2, pae_index_pos1 + ] # Using position2 as scored residue + + return pae_x_position1, pae_x_position2 + + plddt_at_position1, plddt_at_position2 = get_site_plddts(crosslink) + pae_x_position1, pae_x_position2 = get_paes() + + predicted_distance = get_distance_between_two_amino_acids_in_angstrom( + amino_acid_position1=crosslink.crosslinker_position1, + amino_acid_position2=crosslink.crosslinker_position2, + amino_acid_type1=protein_sequence1[crosslink.crosslinker_position1 - 1], + amino_acid_type2=protein_sequence2[crosslink.crosslinker_position2 - 1], + cif_df=cif_df, + chain_id1=crosslink.Chain_id1, + chain_id2=crosslink.Chain_id2, + ) + try: + ( + crosslinker_length, + accepted_deviation_upper_bound, + accepted_deviation_lower_bound, + ) = crosslinker_information[crosslink.Crosslinker] + except KeyError: + raise KeyError( + f"Missing required information regarding crosslinker length " + f"and/or accepted deviation for crosslinker '{crosslink.Crosslinker}'." + ) + + accepted_distance_lower_bound: float = 0.0 + accepted_distance_upper_bound: float = 0.0 + + match validation_criterion: + case CrosslinkingValidationCriterion.manual_bounds.value: + # Fallback to default deviation bounds when not explicitly provided + accepted_distance_lower_bound = crosslinker_length - ( + accepted_deviation_lower_bound or crosslinker_length + ) + accepted_distance_upper_bound = ( + accepted_deviation_upper_bound or float("inf") + ) + crosslinker_length + + case CrosslinkingValidationCriterion.max_pae.value: + if np.isnan(pae_x_position1) or np.isnan(pae_x_position2): + raise ValueError("No PAE data given.") + + pae_tolerance = max(pae_x_position1, pae_x_position2) + accepted_distance_lower_bound = float( + max(crosslinker_length - pae_tolerance, 0.0) + ) + accepted_distance_upper_bound = float( + crosslinker_length + pae_tolerance + ) + + case CrosslinkingValidationCriterion.min_pae.value: + if np.isnan(pae_x_position1) or np.isnan(pae_x_position2): + raise ValueError("No PAE data given.") + pae_x_position1, pae_x_position2 = get_paes() + pae_tolerance = min(pae_x_position1, pae_x_position2) + accepted_distance_lower_bound = float( + max(crosslinker_length - pae_tolerance, 0.0) + ) + accepted_distance_upper_bound = float( + crosslinker_length + pae_tolerance + ) + + case CrosslinkingValidationCriterion.plddt_adjusted.value: + if np.isnan(plddt_at_position1) or np.isnan(plddt_at_position2): + raise ValueError("No pLDDT data given.") + + get_plddt_factor: Callable[[float], float] = lambda plddt: 1 - ( + plddt / 100 + ) + + plddt_factor_pos1 = get_plddt_factor(plddt_at_position1) + plddt_factor_pos2 = get_plddt_factor(plddt_at_position2) + + max_half_tolerance = crosslinker_length # Note: This is quite lenient + tolerance_pos1 = plddt_factor_pos1 * max_half_tolerance + tolerance_pos2 = plddt_factor_pos2 * max_half_tolerance + + accepted_distance_lower_bound = max( + crosslinker_length - tolerance_pos1 - tolerance_pos2, 0 + ) + accepted_distance_upper_bound = ( + crosslinker_length + tolerance_pos1 + tolerance_pos2 + ) + + case _: + raise ValueError("Invalid validation strategy") + + valid = ( + accepted_distance_lower_bound + <= predicted_distance + <= accepted_distance_upper_bound + ) + + return pd.Series( + { + "alphafold_distance": predicted_distance, + "valid_crosslink": valid, + "crosslinker_position1": crosslink.crosslinker_position1, + "crosslinker_position2": crosslink.crosslinker_position2, + "plddt_at_position1": plddt_at_position1, + "plddt_at_position2": plddt_at_position2, + "pae_x_position1": pae_x_position1, + "pae_x_position2": pae_x_position2, + } + ) + + # adding the distance in alphafold, the result of the validation and the crosslinker positions to all relevant crosslinks + new_columns = [ + "alphafold_distance", + "valid_crosslink", + "crosslinker_position1", + "crosslinker_position2", + "plddt_at_position1", + "plddt_at_position2", + "pae_x_position1", + "pae_x_position2", + ] + + relevant_crosslinks_df["crosslinker_position1"] = relevant_crosslinks_df[ + "crosslinker_position1" + ].astype("Int64") + relevant_crosslinks_df["crosslinker_position2"] = relevant_crosslinks_df[ + "crosslinker_position2" + ].astype("Int64") + + relevant_crosslinks_df[new_columns] = relevant_crosslinks_df.apply( + check_crosslink, axis=1 + ) + + # removing all crosslinks that weren't checked from the df + checked_crosslinks_df = relevant_crosslinks_df[ + relevant_crosslinks_df["valid_crosslink"].notna() + ] + + checked_crosslinks_df["link_type"] = checked_crosslinks_df.apply( + lambda row: "intra" if row["Chain_id1"] == row["Chain_id2"] else "inter", + axis=1, + ) + + structure_entry_id = _get_structure_entry_id(structure_metadata_df) + data_for_visualization = { + "structure_entry_id": structure_entry_id, + "cif_df": cif_df, + "crosslinking_df": checked_crosslinks_df, + } + + return dict( + crosslinking_result_df=checked_crosslinks_df, + messages=messages, + visualization=OutputItem( + output_type=OutputType.VISUALIZATION, value=data_for_visualization + ), + ) + + +def _get_tick_values_with_lines(fig, min_value, max_value): + """ + Generates tick values and labels for a Plotly figure's x-axis, ensuring that + the x-positions of all vertical lines in the figure are included as additional ticks. + + Regular ticks are spaced evenly based on the range between min_value and max_value. + Vertical line positions that fall within the range and are not already covered by a regular tick + are appended and labeled with their rounded value. + + :param fig: Plotly Figure object whose shapes are inspected for vertical lines. + :param min_value: Lower bound of the x-axis range. + :param max_value: Upper bound of the x-axis range. + :return: Dictionary with tickmode, tickvals, and ticktext suitable for use in update_xaxes. + """ + line_x_values = [ + shape.x0 + for shape in fig.layout.shapes + if shape.type == "line" and shape.x0 == shape.x1 + ] + + step_size = ( + pow(10, math.floor(np.log10(max_value - min_value))) + if max_value - min_value > 0 + else 1 + ) + first_step = math.ceil(min_value / step_size) * step_size + last_step = math.ceil(max_value / step_size) * step_size + 3 * step_size + tick_values = list(np.arange(first_step, last_step, step_size)) + tick_text = list(np.vectorize(lambda x: millify(x))(tick_values)) + + for x in line_x_values: + if x not in tick_values and min_value <= x <= max_value: + tick_values.append(x) + tick_text.append(str(round(x, 2))) + + paired = sorted(zip(tick_values, tick_text)) + tick_values, tick_text = zip(*paired) + + return dict(tickmode="array", tickvals=list(tick_values), ticktext=list(tick_text)) + + +def diagrams_of_crosslinking_validation_data( + validated_df: pd.DataFrame, + structures_to_validate: list[str], + crosslinker_information: dict[str, list[float]], +) -> list[Figure]: + """ + Creates for each crosslinker histogram plots summarizing the distribution (AlphaFold-)predicted distances + matching or not matching the crosslinker lengths and allowed deviations. + + For each crosslinker, two histograms are generated: + - One covering the full distance range (combining a linear and a logarithmic axis). + - One restricted to the range of mean ± 2 standard deviations of the predicted distances. + + Both histograms include vertical reference lines indicating the + crosslinker length and, if applicable, the upper and/or lower accepted deviation bounds. + + Additionally, a bar plot is created summarizing the total number of crosslinks that match + or do not match the predicted structure across all analyzed crosslinkers. + + :param crosslinker_information: Contains for each Crosslinker: + - length_of_: float + - lower_accepted_deviation_for_: float + - upper_accepted_deviation_for_: float + :param validated_df: pd.DataFrame consisting of the crosslinker_df enriched with information like + the belonging AlphaFold predicted distance ('alphafold_distance') or whether the AlphaFold + prediction matches the crosslinker length ('valid_crosslink'). + :param structures_to_validate: List of protein names, the names of the proteins whose predictions we + validated. + :return: List of Plotly Figure objects. For each crosslinker, the list contains two histogram + figures (mean ± 2 standard deviations first, full range second), followed by a final + bar plot summarizing valid and invalid crosslinks across all crosslinkers. + :raises KeyError: If a required crosslinker entry is missing in crosslinker_information. + """ + if validated_df.empty: + return [] + validated_df = validated_df.dropna(subset=["valid_crosslink"]) + + figures = [] + + structures_to_validate_str = ", ".join(structures_to_validate) + + for crosslinker, crosslinker_df in validated_df.groupby("Crosslinker"): + distances_valid = crosslinker_df.loc[ + crosslinker_df["valid_crosslink"] == True, "alphafold_distance" + ] + distances_invalid = crosslinker_df.loc[ + crosslinker_df["valid_crosslink"] == False, "alphafold_distance" + ] + df_valid = pd.DataFrame({"alphafold_distance": distances_valid}) + df_invalid = pd.DataFrame({"alphafold_distance": distances_invalid}) + + # Count intra/inter for valid and invalid crosslinks + valid_mask = crosslinker_df["valid_crosslink"] + invalid_mask = ~crosslinker_df["valid_crosslink"] + valid_intra = ((valid_mask) & (crosslinker_df["link_type"] == "intra")).sum() + valid_inter = ((valid_mask) & (crosslinker_df["link_type"] == "inter")).sum() + invalid_intra = ( + (invalid_mask) & (crosslinker_df["link_type"] == "intra") + ).sum() + invalid_inter = ( + (invalid_mask) & (crosslinker_df["link_type"] == "inter") + ).sum() + + ( + crosslinker_length, + accepted_deviation_upper_bound, + accepted_deviation_lower_bound, + ) = crosslinker_information[crosslinker] + + histogram = create_cl_validation_histogram( + distances_valid=df_valid["alphafold_distance"], + distances_invalid=df_invalid["alphafold_distance"], + title_valid=f"Predictions matching CLs (intra: {valid_intra}, inter: {valid_inter})", + title_invalid=f"Predictions not matching CLs (intra: {invalid_intra}, inter: {invalid_inter})", + heading=f"Predicted distances for {structures_to_validate_str} with crosslinker {crosslinker}", + xaxis_label="Distance in Å", + yaxis_label="Count", + split_x_axis_at=( + crosslinker_length + if accepted_deviation_upper_bound is None + else crosslinker_length + accepted_deviation_upper_bound + ), + ) + add_vertical_line_with_annotation_in_legend( + fig=histogram, + dash="solid", + annotation=f"{crosslinker} length: {crosslinker_length}Å", + x_value=crosslinker_length, + column=1, + ) + if accepted_deviation_upper_bound == 0: + # also add rightmost line (upper_bound/CL length to right subplot) + histogram.add_vline( + x=np.log10(crosslinker_length), + line_color=PLOT_PRIMARY_COLOR, + line_dash="solid", + line_width=2, + col=2, + ) + + mean_of_predicted_lengths = crosslinker_df["alphafold_distance"].mean() + if len(crosslinker_df) == 1: + standard_deviation_predicted_lengths = ( + 0.0 # .std() would return nan if there is only one entry + ) + else: + standard_deviation_predicted_lengths = crosslinker_df[ + "alphafold_distance" + ].std() + mean_plus_two_std = ( + mean_of_predicted_lengths + 2 * standard_deviation_predicted_lengths + ) + mean_minus_two_std = np.maximum( + 0.0, mean_of_predicted_lengths - 2 * standard_deviation_predicted_lengths + ) + + histogram_two_standard_deviations = create_histograms( + dataframe_a=df_valid, + dataframe_b=df_invalid, + name_a=f"Predictions matching CLs (intra: {valid_intra}, inter: {valid_inter})", + name_b=f"Predictions not matching CLs (intra: {invalid_intra}, inter: {invalid_inter})", + heading=f"Predicted distances for {structures_to_validate_str} with crosslinker {crosslinker}, mean +/- 2 σ", + x_title="Distance in Å", + y_title="Count", + overlay=True, + visual_transformation="linear", + relevant_column_a="alphafold_distance", + relevant_column_b="alphafold_distance", + min_value=mean_minus_two_std, + max_value=mean_plus_two_std, + one_bin_per_int=True, + ) + add_vertical_line_with_annotation_in_legend( + fig=histogram_two_standard_deviations, + dash="solid", + annotation=f"{crosslinker} length: {crosslinker_length}Å", + x_value=crosslinker_length, + ) + histogram_two_standard_deviations.update_layout(width=900) + + if accepted_deviation_upper_bound != 0: + add_vertical_line_with_annotation_in_legend( + fig=histogram, + dash="dash", + annotation=f"allowed deviation upper bound: {accepted_deviation_upper_bound}Å", + x_value=crosslinker_length + accepted_deviation_upper_bound, + column=1, + ) + # also add rightmost line (upper_bound/CL length to right subplot) + histogram.add_vline( + x=np.log10(crosslinker_length + accepted_deviation_upper_bound), + line_color=PLOT_PRIMARY_COLOR, + line_dash="dash", + line_width=2, + col=2, + ) + if ( + math.floor(mean_minus_two_std) + <= crosslinker_length + accepted_deviation_upper_bound + <= math.ceil(mean_plus_two_std) + ): + add_vertical_line_with_annotation_in_legend( + fig=histogram_two_standard_deviations, + dash="dash", + annotation=f"allowed deviation upper bound: {accepted_deviation_upper_bound}Å", + x_value=crosslinker_length + accepted_deviation_upper_bound, + ) + if accepted_deviation_lower_bound != 0: + add_vertical_line_with_annotation_in_legend( + fig=histogram, + dash="dash", + annotation=f"allowed deviation lower bound: {accepted_deviation_lower_bound}Å", + x_value=crosslinker_length - accepted_deviation_lower_bound, + column=1, + ) + if ( + math.floor(mean_minus_two_std) + <= crosslinker_length - accepted_deviation_lower_bound + <= math.ceil(mean_plus_two_std) + ): + add_vertical_line_with_annotation_in_legend( + fig=histogram_two_standard_deviations, + dash="dash", + annotation=f"allowed deviation lower bound: {accepted_deviation_lower_bound}Å", + x_value=crosslinker_length - accepted_deviation_lower_bound, + ) + histogram_two_standard_deviations.update_xaxes( + **_get_tick_values_with_lines( + histogram_two_standard_deviations, mean_minus_two_std, mean_plus_two_std + ) + ) + figures.append(histogram_two_standard_deviations) + figures.append(histogram) + + bar_plot_over_all_checked_crosslinks = _create_summarizing_cl_validation_bar_plot( + validated_df, structures_to_validate_str + ) + figures.append(bar_plot_over_all_checked_crosslinks) + + return figures + + +def monomer_diagrams( + output_crosslinking_result_df: pd.DataFrame, + structure_metadata_df: pd.DataFrame, + crosslinker_information: dict[str, list[float]], + validation_criterion: CrosslinkingValidationCriterion, +) -> list[Figure]: + """ + Generates visual diagrams to evaluate crosslinking validation results + for a monomeric protein structure. + + :param output_crosslinking_result_df: DataFrame containing the CL validation results. + :param structure_metadata_df: DataFrame containing structural metadata; the + first row's 'uniprot_accession' is used as the target. + :param crosslinker_information: Dictionary mapping crosslinker names to a list of + three floats: [length, upper_bound, lower_bound]. + :param validation_criterion: The validation criterion used for validation. + :return: A list of Figure objects visualizing the crosslinking validation data. + """ + structures_to_validate = [structure_metadata_df["uniprot_accession"].iloc[0]] + + match validation_criterion: + case CrosslinkingValidationCriterion.manual_bounds.value: + return diagrams_of_crosslinking_validation_data( + validated_df=output_crosslinking_result_df, + structures_to_validate=structures_to_validate, + crosslinker_information=crosslinker_information, + ) + + # TODO: Separate Issue #429 + case ( + CrosslinkingValidationCriterion.max_pae.value + | CrosslinkingValidationCriterion.min_pae.value + ): + return diagrams_of_crosslinking_validation_data( + validated_df=output_crosslinking_result_df, + structures_to_validate=structures_to_validate, + crosslinker_information=crosslinker_information, + ) + + # TODO: Separate Issue #429 + case CrosslinkingValidationCriterion.plddt_adjusted.value: + return diagrams_of_crosslinking_validation_data( + validated_df=output_crosslinking_result_df, + structures_to_validate=structures_to_validate, + crosslinker_information=crosslinker_information, + ) + + case _: + return [] + + +def multimer_diagrams( + output_crosslinking_result_df: pd.DataFrame, + crosslinker_information: dict[str, list[float]], + amino_acid_sequences_df: pd.DataFrame, + job_request_df: pd.DataFrame, + validation_criterion: CrosslinkingValidationCriterion, +) -> list[Figure]: + """ + Generates visual diagrams to evaluate crosslinking validation results + for a multimeric protein complex. + + This function parses an AlphaFold job request to determine the valid chain + compositions and uses the passed result from the validation. + + :param output_crosslinking_result_df: DataFrame containing the CL validation results. + :param crosslinker_information: Dictionary mapping crosslinker names to a list of + three floats: [length, upper_bound, lower_bound]. + :param amino_acid_sequences_df: DataFrame containing known amino acid sequences. + :param job_request_df: DataFrame containing the loaded AlphaFold job request JSON. + :param validation_criterion: The validation criterion used for validation. + :return: A list of Figure objects visualizing the crosslinking validation data. + """ + valid_ids = get_valid_ids_per_protein_id_from_job_request( + amino_acid_sequences_df=amino_acid_sequences_df, job_request_df=job_request_df + ) + structures_to_validate = list(valid_ids.keys()) + + match validation_criterion: + case CrosslinkingValidationCriterion.manual_bounds.value: + return diagrams_of_crosslinking_validation_data( + validated_df=output_crosslinking_result_df, + structures_to_validate=structures_to_validate, + crosslinker_information=crosslinker_information, + ) + + # TODO: Separate Issue #429 + case ( + CrosslinkingValidationCriterion.max_pae.value + | CrosslinkingValidationCriterion.min_pae.value + ): + return diagrams_of_crosslinking_validation_data( + validated_df=output_crosslinking_result_df, + structures_to_validate=structures_to_validate, + crosslinker_information=crosslinker_information, + ) + + # TODO: Separate Issue #429 + case CrosslinkingValidationCriterion.plddt_adjusted.value: + return diagrams_of_crosslinking_validation_data( + validated_df=output_crosslinking_result_df, + structures_to_validate=structures_to_validate, + crosslinker_information=crosslinker_information, + ) + + case _: + return [] + + +# Warning: Mostly AI generated +def create_cl_validation_histogram( + distances_valid: pd.Series, + distances_invalid: pd.Series, + split_x_axis_at: float, + title_valid: str = "Predictions matching CLs", + title_invalid: str = "Predictions not matching CLs", + heading: str = "", + xaxis_label: str = "", + yaxis_label: str = "", +): + """ + Creates a split-axis histogram for displaying distances predicted by AlphaFold. + The left panel uses a linear axis, the right panel uses a logarithmic one. + + :param distances_valid: Pandas Series containing distances matching the crosslinker length. + :param distances_invalid: Pandas Series containing distances not matching the crosslinker length. + :param split_x_axis_at: Threshold distance at which the x-axis transitions from + linear (left panel) to logarithmic (right panel). + :param title_valid: Legend label for valid crosslinks. Defaults to "Predictions matching CLs". + :param title_invalid: Legend label for invalid crosslinks. Defaults to "Predictions not matching CLs". + :param heading: Title of the overall figure. Can be a long string and will be wrapped. + :param xaxis_label: Label for the x-axis (applied to both panels with scale annotations). + :param yaxis_label: Label for the shared y-axis. + :return: A Plotly Figure object containing the split histogram visualization. + """ + + if split_x_axis_at <= 0.0: + raise ValueError("x-axis split must be at x > 0") + + # It is good practice to drop NaNs before calculating bins/histograms + distances_valid.dropna(inplace=True) + distances_invalid.dropna(inplace=True) + + min_distance: float = np.nanmin([distances_valid.min(), distances_invalid.min()]) + max_distance: float = np.nanmax([distances_valid.max(), distances_invalid.max()]) + + fig = make_subplots( + rows=1, + cols=2, + shared_yaxes=True, + horizontal_spacing=0.1, + column_widths=[0.5, 0.5], + ) + fig.update_layout(width=900) + + # --- Pre-calculate shared bins for BOTH datasets --- + # 1. Linear Bins + lin_start = math.floor(min_distance) + lin_end = math.ceil(split_x_axis_at) + if lin_end <= lin_start: + lin_end = lin_start + 1 + lin_bins = np.arange(lin_start, lin_end + 1, 1) + + # 2. Log Bins + # We must ensure max_distance > split_x_axis_at to avoid math domain errors + safe_max = max(max_distance, split_x_axis_at * 1.01) + log_start = np.log10(split_x_axis_at) + log_end = np.log10(safe_max) + log_bins_transformed = np.arange(log_start, log_end + 0.1, 0.1) + + # Pre-compute the actual linear numbers of the log bins for the hover template + log_bins_linear = 10**log_bins_transformed + + def add_split_traces(values: pd.Series, name: str, color: str, show_legend: bool): + # Split data + v_lin = values[values <= split_x_axis_at] + v_log_raw = values[values > split_x_axis_at] + v_log_transformed = np.log10(v_log_raw) + + # --- Calculate Histogram for Linear Part --- + counts_lin, _ = np.histogram(v_lin, bins=lin_bins) + # Pair up the left and right edges for the hover box + customdata_lin = np.stack((lin_bins[:-1], lin_bins[1:]), axis=-1) + + _ = fig.add_trace( + go.Bar( + x=lin_bins[:-1], + y=counts_lin, + width=1, # Match the bin size in np.arange + offset=0, # Force bars to start exactly at the bin edge + name=name, + marker_color=color, + legendgroup=name, + showlegend=show_legend, + customdata=customdata_lin, + hovertemplate="%{data.name}
Range: %{customdata[0]:g} to %{customdata[1]:g}
Count: %{y}", + ), + row=1, + col=1, + ) + + # --- Calculate Histogram for Log Part --- + counts_log, _ = np.histogram(v_log_transformed, bins=log_bins_transformed) + customdata_log = np.stack((log_bins_linear[:-1], log_bins_linear[1:]), axis=-1) + + _ = fig.add_trace( + go.Bar( + x=log_bins_transformed[:-1], + y=counts_log, + width=0.1, # Match the bin size in np.arange + offset=0, + name=name, + marker_color=color, + legendgroup=name, + showlegend=False, # Legend handled by linear part + customdata=customdata_log, + # Format numbers cleanly with commas using `,.0f` or `g` + hovertemplate="%{data.name}
Range: %{customdata[0]:,.0f} to %{customdata[1]:,.0f}
Count: %{y}", + ), + row=1, + col=2, + ) + + add_split_traces(distances_valid, title_valid, PLOT_PRIMARY_COLOR, True) + add_split_traces(distances_invalid, title_invalid, PLOT_SECONDARY_COLOR, True) + + # Update Axes Formatting + _ = fig.update_xaxes( + title_text=f"{xaxis_label} (Linear)", + range=[math.floor(min_distance), split_x_axis_at], + row=1, + col=1, + showline=True, + mirror=False, + zeroline=False, + ) + + # Always include the split origin as the first tick + tick_vals = [np.log10(split_x_axis_at)] + tick_text = [str(split_x_axis_at)] + + # Calculate how many exponents we need to cover the maximum delta + max_delta = max_distance - split_x_axis_at + if max_delta > 0: + max_i = int(math.ceil(np.log10(max_delta))) + for i in range(1, max_i + 1): + val: float = split_x_axis_at + math.pow(10, i) + # Add the exact log position for the tick, and the formatted text + tick_vals.append(np.log10(val)) + tick_text.append(f"{(split_x_axis_at + 10**i):.4g}") + + _ = fig.update_xaxes( + title_text=f"{xaxis_label} (Log10)", + tickvals=tick_vals, + ticktext=tick_text, + row=1, + col=2, + showline=True, + mirror=False, + zeroline=False, + ) + _ = fig.update_layout(barmode="overlay", yaxis_title=yaxis_label) + fig.update_traces(opacity=0.75) + + wrapped_title = "
".join(textwrap.wrap(heading, width=60)) + _ = fig.update_layout(title={"text": f"{wrapped_title}"}) + + _ = fig.update_layout(margin_pad=10) + + # Disable toggling of the visibility of the traces by clicking on the legend + fig.update_layout( + legend=dict(itemclick=False, itemdoubleclick=False, xanchor="left", x=1.05) + ) + + return fig + + +def _create_summarizing_cl_validation_bar_plot( + validated_df: pd.DataFrame, structures_to_validate_str: str +) -> Figure: + valid_crosslinks = (validated_df["valid_crosslink"]).sum() + invalid_crosslinks = (~validated_df["valid_crosslink"]).sum() + valid_intra_total = ( + (validated_df["valid_crosslink"]) & (validated_df["link_type"] == "intra") + ).sum() + valid_inter_total = ( + (validated_df["valid_crosslink"]) & (validated_df["link_type"] == "inter") + ).sum() + invalid_intra_total = ( + (~validated_df["valid_crosslink"]) & (validated_df["link_type"] == "intra") + ).sum() + invalid_inter_total = ( + (~validated_df["valid_crosslink"]) & (validated_df["link_type"] == "inter") + ).sum() + + return create_bar_plot( + values_of_sectors=[ + valid_crosslinks, + invalid_crosslinks, + ], + names_of_sectors=[ + f"Crosslinks matching predicted data (intra: {valid_intra_total}, inter: {valid_inter_total})", + f"Crosslinks not matching predicted data (intra: {invalid_intra_total}, inter: {invalid_inter_total})", + ], + heading=f"All Crosslinks used for validation of {structures_to_validate_str}", + y_title="Number of Crosslinks", + ) diff --git a/backend/protzilla/data_analysis/plots.py b/backend/protzilla/data_analysis/plots.py index 4da7b66af..4371d7f74 100644 --- a/backend/protzilla/data_analysis/plots.py +++ b/backend/protzilla/data_analysis/plots.py @@ -10,6 +10,8 @@ import pandas as pd import plotly.express as px import plotly.graph_objects as go +from plotly import graph_objects as go +from plotly.graph_objs import Figure from scipy import stats from sklearn.metrics import precision_recall_curve, auc, roc_curve from sklearn.metrics.pairwise import cosine_similarity, euclidean_distances @@ -580,3 +582,36 @@ def roc_plot( ) return dict(plots=[fig]) + + +def add_vertical_line_with_annotation_in_legend( + fig: Figure, + dash: str, + annotation: str, + x_value: float, + color: str = PLOT_PRIMARY_COLOR, + column: int = None, +) -> None: + """ + Adds a vertical line to a Plotly figure and includes a corresponding entry in the legend + without displaying an additional visible trace in the plot. + + :param fig: Plotly Figure object to which the vertical line and legend entry are added. + :param dash: Line style for the vertical line (e.g., "solid", "dash", "dot"). + :param annotation: Text to display in the legend corresponding to the vertical line. + :param x_value: X-coordinate at which to draw the vertical line. + :param color: Color of the vertical line and legend entry (default is "blue"). + :return: None + """ + # add vertical line + fig.add_vline(x=x_value, line_color=color, line_dash=dash, line_width=2, col=column) + # add annotation of the line to the legend + fig.add_trace( + go.Scatter( + x=[None], + y=[None], + mode="lines", + name=annotation, + line=dict(color=color, width=2, dash=dash), + ) + ) diff --git a/backend/protzilla/data_preprocessing/plots.py b/backend/protzilla/data_preprocessing/plots.py index 282ed07dc..510fab79f 100644 --- a/backend/protzilla/data_preprocessing/plots.py +++ b/backend/protzilla/data_preprocessing/plots.py @@ -1,3 +1,6 @@ +import math +import textwrap + import numpy as np import pandas as pd import plotly.express as px @@ -175,6 +178,11 @@ def create_histograms( x_title: str = "", visual_transformation: str = "linear", overlay: bool = False, + relevant_column_a: str = None, + relevant_column_b: str = None, + min_value: float = None, + max_value: float = None, + one_bin_per_int: bool = False, ) -> Figure: """ A function to create a histogram for visualisation @@ -194,47 +202,68 @@ def create_histograms( :param x_title: Optional x axis title for graphs. :param overlay: Specifies whether to draw one Histogram with overlay or two separate histograms :param visual_transformation: Visual transformation of the y-axis data. - - :return: returns a pie or bar chart of the data + :param relevant_column_a: Which column of dataframe_a should be used for the histogram. If None, the default_intensity_column will be used. + :param relevant_column_b: Which column of dataframe_b should be used for the histogram. If None, the default_intensity_column will be used. + :param min_value: Where the first bin should start. If None, will be set to the minimum value of the two dataframes. + :param max_value: Where the last bin should end. If None, will be set to the maximum value of the two dataframes. + :param one_bin_per_int: If set to True, min_value will be rounded down to the next int and max_value will be rounded up to the next int and there will\ + be max_value-min_value many bins. + + :return: returns a histogram of the data """ if visual_transformation not in {"linear", "log10"}: raise ValueError( f"""visual_transformation parameter must be "linear" or "log10" but is {visual_transformation}""" ) + if relevant_column_a is None: + relevant_column_a = default_intensity_column(dataframe_a) + if relevant_column_b is None: + relevant_column_b = default_intensity_column(dataframe_b) - intensity_name_a = default_intensity_column(dataframe_a) - intensity_name_b = default_intensity_column(dataframe_b) - - intensities_a = dataframe_a[intensity_name_a] - intensities_b = dataframe_b[intensity_name_b] + values_a = dataframe_a[relevant_column_a] + values_b = dataframe_b[relevant_column_b] if visual_transformation == "log10": - intensities_a = intensities_a.apply(np.log10) - intensities_b = intensities_b.apply(np.log10) - - min_value = min(intensities_a.min(skipna=True), intensities_b.min(skipna=True)) - max_value = max(intensities_a.max(skipna=True), intensities_b.max(skipna=True)) - - number_of_bins = 100 - binsize_a = ( - intensities_a.max(skipna=True) - intensities_a.min(skipna=True) - ) / number_of_bins - binsize_b = ( - intensities_b.max(skipna=True) - intensities_b.min(skipna=True) - ) / number_of_bins - - if overlay: + values_a = values_a.apply(np.log10) + values_b = values_b.apply(np.log10) + + if min_value is None: + min_value = np.nanmin([values_a.min(), values_b.min()]) + if max_value is None: + max_value = np.nanmax([values_a.max(), values_b.max()]) + + if one_bin_per_int: + min_value = math.floor(min_value) + max_value = math.ceil(max_value) + binsize_a = 1 + binsize_b = 1 + else: + number_of_bins = 100 + if len(values_a) > 0: + binsize_a = ( + values_a.max(skipna=True) - values_a.min(skipna=True) + ) / number_of_bins + else: + binsize_a = 1 # default value of 1 in case that values_a is empty + if len(values_b) > 0: + binsize_b = ( + values_b.max(skipna=True) - values_b.min(skipna=True) + ) / number_of_bins + else: + binsize_b = 1 # default value of 1 in case that values_b is empty + + if overlay and len(values_a) > 0 and len(values_b) > 0: binsize_a = binsize_b = max(binsize_a, binsize_b) trace0 = go.Histogram( - x=intensities_a, + x=values_a, marker_color=PLOT_PRIMARY_COLOR, name=name_a, xbins=dict(start=min_value, end=max_value, size=binsize_a), ) trace1 = go.Histogram( - x=intensities_b, + x=values_b, marker_color=PLOT_SECONDARY_COLOR, name=name_b, xbins=dict(start=min_value, end=max_value, size=binsize_b), @@ -256,10 +285,16 @@ def create_histograms( fig.update_traces(opacity=0.75) if visual_transformation == "log10": fig.update_layout(xaxis=generate_tics(0, max_value, True)) + fig.update_xaxes(title=x_title) + fig.update_yaxes(title=y_title, rangemode="tozero") - fig.update_layout(title={"text": f"{heading}"}) - fig.update_xaxes(title=x_title) - fig.update_yaxes(title=y_title, rangemode="tozero") + wrapped_title = "
".join(textwrap.wrap(heading, width=60)) + fig.update_layout(title={"text": f"{wrapped_title}"}) + + fig.update_layout(margin_pad=20) + + # Disable toggling of the visibility of the traces by clicking on the legend + fig.update_layout(legend=dict(itemclick=False, itemdoubleclick=False)) return fig diff --git a/backend/protzilla/disk_operator.py b/backend/protzilla/disk_operator.py index 440b18a6b..41b9a2b4c 100644 --- a/backend/protzilla/disk_operator.py +++ b/backend/protzilla/disk_operator.py @@ -160,6 +160,50 @@ def write(file_path: Path, base64_string: bytes): file.write(data) +class DefaultsOperator: + def __init__(self): + self.yaml_operator = YamlOperator() + + def read_default(self, name: str) -> any: + with ErrorHandler(): + if not self.defaults_file.exists(): + return None + defaults = self.yaml_operator.read(self.defaults_file) or {} + return defaults.get(name) + + def write_default(self, name: str, value: any) -> None: + with ErrorHandler(): + if not self.defaults_file.parent.exists(): + self.defaults_file.parent.mkdir(parents=True, exist_ok=True) + defaults = {} + if self.defaults_file.exists(): + defaults = self.yaml_operator.read(self.defaults_file) or {} + defaults[name] = value + self.yaml_operator.write(self.defaults_file, defaults) + + def delete_default(self, name: str) -> None: + with ErrorHandler(): + if not self.defaults_file.exists(): + return + defaults = self.yaml_operator.read(self.defaults_file) or {} + if name in defaults: + del defaults[name] + self.yaml_operator.write(self.defaults_file, defaults) + + def get_all_defaults(self): + """Reads all default values from disk. Returns an empty dict if the file doesn't exist.""" + with ErrorHandler(): + if not self.defaults_file.exists(): + return {} + + defaults = self.yaml_operator.read(self.defaults_file) + return defaults or {} + + @property + def defaults_file(self) -> Path: + return paths.USER_DATA_PATH / "defaults.yaml" + + RUN_FILE = "run.yaml" @@ -190,6 +234,7 @@ def __init__(self, run_name: str, workflow_name: str): self.dataframe_operator = DataFrameOperator() self.artifact_operator = ArtifactOperator() self.base64_operator = Base64Operator() + self.defaults = DefaultsOperator() def read_run(self, file: Path | None = None) -> StepManager: with ErrorHandler(): @@ -440,6 +485,12 @@ def _read_outputs(self, _output: dict[str, OutputItem]) -> Output: output_type=OutputType.PNG_BASE64, value=self.base64_operator.read(self.run_dir / path), ) + case OutputType.VISUALIZATION: + path = Path(str(item.value)) + step_output[key] = OutputItem( + output_type=OutputType.VISUALIZATION, + value=self.artifact_operator.read(self.run_dir / path), + ) case _: step_output[key] = item @@ -492,6 +543,19 @@ def _write_output(self, step: Step) -> dict: output_type=OutputType.PNG_BASE64, value=str(file_path.relative_to(self.run_dir)), ) + case OutputType.VISUALIZATION: + file_path = ( + self.artifact_dir + / f"{step.instance_identifier}_{key}_visualization.joblib.gz" + ) + + if self._dump_is_outdated(step, "output"): + self.artifact_operator.write(file_path, item.value) + + output_data[key] = OutputItem( + output_type=OutputType.VISUALIZATION, + value=str(file_path.relative_to(self.run_dir)), + ) case _: output_data[key] = item diff --git a/backend/protzilla/form.py b/backend/protzilla/form.py index 26b125d4c..c373787aa 100644 --- a/backend/protzilla/form.py +++ b/backend/protzilla/form.py @@ -13,7 +13,7 @@ pass -FormInputType = str | int | float | bool | list[str] +FormInputType = str | int | float | bool | list[str] | dict # Backwards compatibility for older imports that expect `inputs` from this module. inputs = FormInputType @@ -202,6 +202,7 @@ class HeaderInfoField: | DropdownField | FileInput | ColorField + | FloatField ) StructuralField = FormDivider | InfoField | HeaderInfoField diff --git a/backend/protzilla/form_helper.py b/backend/protzilla/form_helper.py index 1367b3e8c..85c87028a 100644 --- a/backend/protzilla/form_helper.py +++ b/backend/protzilla/form_helper.py @@ -4,6 +4,7 @@ def to_choices(choices: list[str], required: bool = True) -> list[Option]: + """should probably only be used in modify_form and not when defining the form""" return sorted( [Option(str(el), str(el)) for el in choices] + [Option(None, "---------")] if not required diff --git a/backend/protzilla/importing/alphafold_protein_structure_load.py b/backend/protzilla/importing/alphafold_protein_structure_load.py new file mode 100644 index 000000000..38f4d1307 --- /dev/null +++ b/backend/protzilla/importing/alphafold_protein_structure_load.py @@ -0,0 +1,1223 @@ +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path +from textwrap import wrap +from typing import Any +import logging +import json + +from datetime import datetime, timezone +import gemmi +import pandas as pd +import numpy as np +import ast +import requests +import re + +from backend.protzilla.constants import paths +from backend.protzilla.constants.protzilla_logging import logger +from backend.protzilla.constants.cif_columns import ( + ATOM_SITE_PREFIX, + ATOM_SITE_COLUMNS, + ATOM_SITE_COLUMNS_NUMERIC, + CHEM_COMP_PREFIX, + CHEM_COMP_COLUMNS, +) +from backend.protzilla.importing.fasta_import import fasta_import +from backend.protzilla.networking import download_file_from_url +from backend.protzilla.utilities.utilities import copy_file_to_directory +from backend.protzilla.steps import Output, OutputItem, OutputType + + +def get_monomer_metadata_df() -> pd.DataFrame: + """ + Returns all data from alphafold_monomer_metadata.csv in form of a dataframe. If no such csv exist, it returns + a dataframe with the corresponding keys but no values and creates a csv with the expected column names. + """ + monomer_metadata_csv = paths.AF_MONOMER_METADATA_CSV_PATH + if not monomer_metadata_csv.exists(): + metadata_df = pd.DataFrame( + columns=[ + "entry_id", + "uniprot_accession", + "model_created_date", + "gene", + "model_used", + ] + ) + monomer_metadata_csv.parent.mkdir(parents=True, exist_ok=True) + metadata_df.to_csv(monomer_metadata_csv, index=False) + return metadata_df + return pd.read_csv(monomer_metadata_csv, dtype=str) + + +def get_multimer_metadata_df() -> pd.DataFrame: + """ + Returns all data from alphafold_multimer_metadata.csv in form of a dataframe. If no such csv exist, it returns + a dataframe with the corresponding keys but no values and creates a csv with the expected column names. + """ + multimer_metadata_csv = paths.AF_MULTIMER_METADATA_CSV_PATH + + if not multimer_metadata_csv.exists(): + metadata_df = pd.DataFrame( + columns=[ + "entry_id", + "uniprot_ids", + "model_created_date", + "model_used", + ] + ) + multimer_metadata_csv.parent.mkdir(parents=True, exist_ok=True) + metadata_df.to_csv(multimer_metadata_csv, index=False) + return metadata_df + return pd.read_csv(multimer_metadata_csv, dtype=str) + + +def to_fasta(seq: str, header: str = "protein_sequence", width: int = 60) -> str: + """ + Convert a protein sequence to FASTA format. + + :param seq: The protein sequence to convert + :param header: The header line for the FASTA record (default: "protein_sequence") + :param width: The maximum line width for sequence wrapping (default: 60) + :return: The sequence in FASTA format + :raises ValueError: If the sequence contains invalid characters or whitespace + """ + VALID_AMINO_ACIDS = set("ACDEFGHIKLMNPQRSTVWYBXZJUO*-") + if not seq or any(c.isspace() for c in seq): + raise ValueError("Sequence must be a single, whitespace-free string.") + seq = seq.upper() + bad = set(seq) - VALID_AMINO_ACIDS + if bad: + raise ValueError(f"Invalid characters in sequence: {''.join(sorted(bad))}") + joined = "\n".join(wrap(seq, width)) + return f">alpha|{header}\n{joined}\n" + + +def read_alphafold_mmcif(path: Path) -> pd.DataFrame: + """ + Parse an AlphaFold mmCIF (Macromolecular Crystallographic Information File) file. + + :param path: The path to the mmCIF file + :return: A DataFrame containing the atom site information from the CIF file + :raises FileNotFoundError: If the file does not exist + :raises IsADirectoryError: If the path points to a directory instead of a file + :raises ValueError: If no CIF blocks are found in the file + """ + if not path.exists(): + raise FileNotFoundError(f"File not found: {path}") + if path.is_dir(): + raise IsADirectoryError(f"Expected a file path, got a directory: {path}") + + doc = gemmi.cif.read_file(str(path)) + if len(doc) == 0: + raise ValueError(f"No CIF blocks found in file: {path}") + + block = doc.sole_block() + + if ATOM_SITE_PREFIX not in block.get_mmcif_category_names(): + return pd.DataFrame() + + atom_site_table = block.find_mmcif_category(ATOM_SITE_PREFIX) + + atom_site_df = pd.DataFrame( + list(atom_site_table), + columns=list(atom_site_table.tags), + dtype=pd.StringDtype(), + ) + + # convert to numeric dtype for numeric columns present in the dataframe + present_numeric_columns = [ + column for column in ATOM_SITE_COLUMNS_NUMERIC if column in atom_site_table.tags + ] + atom_site_df[present_numeric_columns] = atom_site_df[present_numeric_columns].apply( + pd.to_numeric, errors="coerce" + ) + + atom_site_df = atom_site_df.convert_dtypes() + + if CHEM_COMP_PREFIX not in block.get_mmcif_category_names(): + raise ValueError( + f"Required table with prefix {CHEM_COMP_PREFIX} not found in {path}" + ) + + chem_comp_table = block.find_mmcif_category(CHEM_COMP_PREFIX) + + chem_comp_df = pd.DataFrame( + list(chem_comp_table), + columns=list(chem_comp_table.tags), + dtype=pd.StringDtype(), + )[[CHEM_COMP_COLUMNS.ID, CHEM_COMP_COLUMNS.MON_NSTD_FLAG]] + + # convert flags to native booleans + bool_map = {"y": True, "n": False, ".": pd.NA} + + chem_comp_df[CHEM_COMP_COLUMNS.MON_NSTD_FLAG] = ( + chem_comp_df[CHEM_COMP_COLUMNS.MON_NSTD_FLAG].map(bool_map).astype("boolean") + ) + + # merge on the comp_id and drop the duplicate column + return atom_site_df.merge( + chem_comp_df, + how="left", + left_on=ATOM_SITE_COLUMNS.LABEL_COMP_ID, + right_on=CHEM_COMP_COLUMNS.ID, + ).drop(CHEM_COMP_COLUMNS.ID, axis=1) + + +def get_correct_af_directories( + entry_id: str, directory_name: Path, persist_upload: bool +) -> tuple[Path | None, Path]: + """ + Determine and prepare the appropriate working directory for an entry. + + If persist_upload is True, a persistent directory named after the + uppercased entry_id is created inside directory_name and used as the + working directory. If persist_upload is False, a temporary directory + is created and used instead. + + :param entry_id: Identifier of the entry. Used as the name of the + subdirectory (uppercased) when persist_upload is True. + :param directory_name: Base directory under which the persistent + entry-specific directory is created. + :param persist_upload: Whether to create and use a persistent + directory or a temporary one. + :return: A tuple containing the temporary directory (or None if not + created) and the working directory to use. + """ + + target_dir = directory_name / entry_id.upper() + temp_dir = None + + if persist_upload: + target_dir.mkdir(parents=True, exist_ok=True) + work_dir = target_dir + else: + temp_dir = Path(tempfile.mkdtemp()) + work_dir = temp_dir + + return temp_dir, work_dir + + +def extend_metadata_csv( + entry_id: str, + metadata_csv: Path, + existing_metadata_df: pd.DataFrame, + metadata_df: pd.DataFrame, + messages: list, +) -> None: + """ + Extend or update the AlphaFold metadata CSV with a new entry. + + If an entry with the given entry_id already exists in the provided + existing_metadata_df, it is removed and replaced with the data from + metadata_df. The combined DataFrame is then written to metadata_csv. + + Any warnings or errors encountered during processing are logged and + appended to the provided messages list. + + :param entry_id: The Entry ID used to identify and potentially + overwrite an existing row in the metadata. + :param metadata_csv: Path to the metadata CSV file that should be + updated. + :param exsisting_metadata_df: The current metadata DataFrame loaded + from the CSV file. + :param metadata_df: The new metadata DataFrame to append to the + existing data. + :param messages: A list used to collect structured log messages + with level and message content. + :return: None. + :raises Exception: Propagates unexpected errors that occur during + concatenation or writing to disk after logging them. + """ + try: + mask = ( + existing_metadata_df["entry_id"].astype(str).str.upper() == entry_id.upper() + ) + if mask.any(): + msg = f'Existing entry with Entry ID "{entry_id}" was overwritten. Entry IDs are compared case insensitively, so "ABC" and "abc" are treated as the same ID.' + logger.warning(msg) + messages.append(dict(level=logging.WARNING, msg=msg)) + existing_metadata_df = existing_metadata_df[~mask] + + combined = pd.concat([existing_metadata_df, metadata_df], ignore_index=True) + combined.to_csv(metadata_csv, index=False) + except Exception: + msg = f'Failed to write AlphaFold metadata CSV to "{metadata_csv}".' + logger.exception(msg) + messages.append(dict(level=logging.ERROR, msg=msg)) + + +def get_amino_acid_sequences_df(fasta_dest: Path, messages: list) -> pd.DataFrame: + """ + Load a FASTA file and return its amino acid sequence DataFrame. + + The function uses fasta_import to parse the FASTA file and extracts + the DataFrame stored under the key "fasta_df". If an error occurs + during parsing, the exception is logged, a message is appended to + the provided messages list, and an empty DataFrame is returned. + + :param fasta_dest: Path to the FASTA file to be imported. + :param messages: A list used to collect structured log messages + with level and message content. + :return: A DataFrame containing the amino acid sequence information, + or an empty DataFrame if parsing fails. + :raises Exception: Propagates unexpected errors from fasta_import + after logging them. + """ + try: + fasta_dict = fasta_import(str(fasta_dest)) + amino_acid_sequences_df = fasta_dict["fasta_df"] + return amino_acid_sequences_df + except Exception: + msg = "Failed to create sequence dataframe" + logger.exception(msg) + messages.append(dict(level=logging.ERROR, msg=msg)) + return pd.DataFrame() + + +def handle_alphafold_files( + files_urls: dict[str, Any], + uniprot: str, + seq: str, + monomer_metadata_df: pd.DataFrame, + entry_id: str, + persist_upload: bool = False, +) -> dict[str, pd.DataFrame | None]: + """ + Download AlphaFold structure files and convert them to DataFrames. + + Files can either be persistently saved to disk or only loaded into memory for the current run. + The function downloads CIF, PAE, and pLDDT files, converts them to DataFrames, and optionally + saves metadata to a CSV file. + + :param files_urls: Dictionary containing URLs for CIF, PAE, and pLDDT files + :param uniprot: The UniProt ID of the protein + :param seq: The protein sequence + :param monomer_metadata_df: DataFrame containing AlphaFold monomer metadata + :param entry_id: The entry_id (in the case of fetching from AF DB the same as uniprot id) (used for directory naming) + :param persist_upload: If True, files are saved persistently; if False, only loaded into memory + :return: A dictionary containing DataFrames for monomer metadata, CIF, PAE, pLDDT, sequence data or None values for + failed loads and messages such as warnings + """ + cif_df = pd.DataFrame() + pae_df = pd.DataFrame() + plddt_df = pd.DataFrame() + amino_acid_sequences_df = pd.DataFrame() + messages = [] + + temp_dir, work_dir = get_correct_af_directories( + entry_id=entry_id, + directory_name=paths.ALPHAFOLD_MONOMER_PATH, + persist_upload=persist_upload, + ) + + try: + if persist_upload: + paths.ALPHAFOLD_MONOMER_PATH.mkdir(parents=True, exist_ok=True) + existing_metadata_df = get_monomer_metadata_df() + extend_metadata_csv( + entry_id=entry_id, + metadata_csv=paths.AF_MONOMER_METADATA_CSV_PATH, + existing_metadata_df=existing_metadata_df, + metadata_df=monomer_metadata_df, + messages=messages, + ) + + for key in ("cifUrl", "paeDocUrl", "plddtDocUrl"): + urlval = files_urls.get(key) + if isinstance(urlval, str) and urlval: + fname = urlval.split("?")[0].rstrip("/").split("/")[-1] + dest = work_dir / fname + saved = download_file_from_url(urlval, dest) + if saved: + try: + if key == "cifUrl": + cif_df = read_alphafold_mmcif(saved) + elif key == "paeDocUrl": + pae_df = pd.read_json(saved) + elif key == "plddtDocUrl": + plddt_df = pd.read_json(saved) + except Exception: + msg = f'Failed to load "{key}" into dataframe' + logger.exception(msg) + messages.append(dict(level=logging.ERROR, msg=msg)) + fasta_dest: Path | None = None + try: + sequence = to_fasta(seq=seq, header=uniprot) + fasta_dest = work_dir / f"{entry_id}.fasta" + fasta_dest.parent.mkdir(parents=True, exist_ok=True) + with open(fasta_dest, "w") as f: + f.write(sequence) + except OSError: + msg = f'Failed to write FASTA file "{fasta_dest}"' + logger.exception(msg) + messages.append(dict(level=logging.ERROR, msg=msg)) + if fasta_dest is not None: + amino_acid_sequences_df = get_amino_acid_sequences_df( + fasta_dest=fasta_dest, + messages=messages, + ) + + finally: + if temp_dir is not None: + shutil.rmtree(temp_dir, ignore_errors=True) + + # For consistency with multimer pLDDT + plddt_df["chainID"] = "A" + + return { + "cif_df": cif_df, + "pae_df": pae_df, + "plddt_df": plddt_df, + "amino_acid_sequences_df": amino_acid_sequences_df, + "messages": messages, + } + + +def fetch_alphafold_protein_structure( + uniprot_id: str, persist_upload: bool +) -> dict[str, Any]: + """ + Fetch AlphaFold protein structure data from the AlphaFold Database API. + + Retrieves monomer metadata and structure files (CIF, PAE, pLDDT) from the AlphaFold Database + for the given UniProt ID. Optionally persists the downloaded files to disk. + + :param uniprot_id: The UniProt ID of the protein + :param persist_upload: If True, files are saved persistently; if False, only loaded into memory + :return: A dictionary containing DataFrames for monomer metadata, CIF, PAE, pLDDT, and sequence data + :raises RuntimeError: If the API request fails or returns invalid data + :raises ValueError: If no predictions are found for the given UniProt ID + """ + url = f"https://alphafold.ebi.ac.uk/api/prediction/{uniprot_id}" + with requests.Session() as session: + try: + resp = session.get(url, timeout=30) + resp.raise_for_status() + records = resp.json() + except requests.RequestException as e: + raise RuntimeError(f"AlphaFold request failed for {uniprot_id}: {e}") from e + except ValueError as e: + raise RuntimeError( + f"AlphaFold returned non-JSON for {uniprot_id}: {e}" + ) from e + + if not isinstance(records, list) or not records: + raise ValueError(f"No AlphaFold DB predictions for {uniprot_id}") + + r = records[0] + if not isinstance(r, dict): + raise RuntimeError(f"Unexpected AlphaFold payload for {uniprot_id}") + + data: dict[str, Any] = { + "entry_id": r.get("uniprotAccession"), + "uniprot_accession": r.get("uniprotAccession"), + "model_created_date": r.get("modelCreatedDate"), + "gene": r.get("gene"), + "model_used": r.get("toolUsed"), + } + + seq_tmp = r.get("sequence") + if not isinstance(seq_tmp, str) or not seq_tmp.strip(): + raise RuntimeError( + f"AlphaFold payload for {uniprot_id} does not contain a valid protein sequence." + ) + + files_urls: dict[str, Any] = {} + + for key in ("cifUrl", "paeDocUrl", "plddtDocUrl"): + if isinstance(r.get(key), str) and r.get(key): + files_urls[key] = r[key] + + monomer_metadata_df = pd.DataFrame([data]) + + alpha_dfs = handle_alphafold_files( + files_urls=files_urls, + uniprot=uniprot_id, + seq=seq_tmp, + monomer_metadata_df=monomer_metadata_df, + entry_id=uniprot_id, + persist_upload=persist_upload, + ) + df_dict = { + "structure_metadata_df": monomer_metadata_df, + "cif_df": alpha_dfs["cif_df"], + "pae_df": alpha_dfs["pae_df"], + "plddt_df": alpha_dfs["plddt_df"], + "amino_acid_sequences_df": alpha_dfs["amino_acid_sequences_df"], + } + messages = alpha_dfs["messages"] + if not any(df.empty for df in df_dict.values()): + success_msg = f"Successfully loaded AlphaFold data for protein with Protein ID '{uniprot_id}'" + logger.info(success_msg) + messages.append(dict(level=logging.INFO, msg=success_msg)) + data_for_visualization = { + "structure_entry_id": uniprot_id, + "cif_df": alpha_dfs["cif_df"], + } + else: + message = ( + f"Could not load AlphaFold data for protein with Protein ID '{uniprot_id}'" + ) + logger.warning(message) + messages.append(dict(level=logging.WARNING, msg=message)) + data_for_visualization = None + + pae_string = str(df_dict["pae_df"]["predicted_aligned_error"].iloc[0]) + pae_matrix = np.array(ast.literal_eval(pae_string)) + del df_dict["pae_df"] + + return dict( + **df_dict, + pae_matrix=OutputItem(output_type=OutputType.JOBLIB_ARTIFACT, value=pae_matrix), + messages=messages, + visualization=OutputItem( + output_type=OutputType.VISUALIZATION, value=data_for_visualization + ), + ) + + +def reduce_pae_to_per_amino_acid( + pae_matrix: np.ndarray, + token_res_ids: list[int], + cif_df: pd.DataFrame, +): + """ + Reduces AlphaFold3 PAE matrices (per-token) to AlphaFold2 PAE matrices (per-amino acid). + If the number of tokens mapping to one AA equals the number of atoms (common for predicted PTMs), + the CA token gets used. Otherwise, the first token gets used. + Required for predictions with PTMs! + + :param pae_matrix: the per-token PAE matrix + :param token_res_ids: the token_res_ids table from the AF3 full_data json + :param cif_df: the atom_site table as a dataframe + + :return: the per-AA/per-residue PAE matrix + """ + + indices_to_delete = [] + + current_idx = 0 + runs = [] + + current_chain_idx = 0 + # Get all runs (start_token_idx, len, chain_idx, res_id) of same res ids into one list + while current_idx < len(token_res_ids): + start_token_idx = current_idx + res_id = token_res_ids[start_token_idx] + length = 1 + + if res_id == 1: + current_chain_idx += 1 + + while True: + current_idx += 1 + if ( + current_idx < len(token_res_ids) + and token_res_ids[current_idx] == res_id + ): + length += 1 + else: + break + + runs.append((start_token_idx, length, current_chain_idx, res_id)) + + for start_token_idx, length, chain_idx, res_id in runs: + if length == 1: + continue + + # Get corresponding entries of _atom_site table for the token + relevant_cif_df = cif_df[cif_df["_atom_site.label_entity_id"] == str(chain_idx)] + relevant_cif_df = relevant_cif_df[ + relevant_cif_df["_atom_site.label_seq_id"] == res_id + ] + + keep_offset = 0 # Relative index to keep within duplicate tokens for one amino acid. Default: first token + + # If we have one token per atom, we try to take the CA atom + if len(relevant_cif_df) == length: + # Reset index twice to get 0..length enumeration for atoms in index + relevant_cif_df.reset_index(drop=True, inplace=True) + relevant_cif_df.reset_index(inplace=True) + + relevant_cif_df = relevant_cif_df[ + relevant_cif_df["_atom_site.label_atom_id"] == "CA" + ] + # 0 or 2+ CA atoms -> default + if len(relevant_cif_df) == 1: + keep_offset = int(relevant_cif_df.iloc[0]["index"]) + + for duplicate_idx in range(0, length): + if duplicate_idx != keep_offset: + indices_to_delete.append(start_token_idx + duplicate_idx) + + # Apply deletion + mask = np.ones(len(pae_matrix), dtype=bool) + mask[indices_to_delete] = False + pae_matrix = pae_matrix[np.ix_(mask, mask)] + + return pae_matrix + + +def get_all_available_entry_ids_of_monomer_metadata() -> list[str]: + """ " + Get the entry ids of all the protein structure predictions that can be found on disk. + """ + df = get_monomer_metadata_df() + return df["entry_id"].tolist() + + +def get_all_available_entry_ids_of_multimer_metadata() -> list[str]: + """ " + Get the entry ids of all the protein structure predictions that can be found on disk. + """ + df = get_multimer_metadata_df() + return df["entry_id"].tolist() + + +def check_and_get_metadata_df( + entry_id: str, all_metadata_df: pd.DataFrame, csv_file: Path +) -> pd.DataFrame: + """ + Retrieve the metadata row for a given Entry ID from a DataFrame. + + The function filters all_metadata_df for rows matching the provided + entry_id. If no matching metadata is found, an error is logged and + a ValueError is raised. + + :param entry_id: The Entry ID used to filter the metadata DataFrame. + :param all_metadata_df: The complete metadata DataFrame containing + all entries. + :param csv_file: Path to the CSV file from which the metadata was + loaded. Used for error reporting. + :return: A DataFrame containing the metadata for the specified + Entry ID. + :raises ValueError: If no metadata for the given Entry ID is found. + """ + metadata_df = all_metadata_df[ + all_metadata_df["entry_id"].astype(str).str.upper() == entry_id.upper() + ] + if metadata_df.empty: + msg = f"No metadata for Entry ID '{entry_id}' in {csv_file}" + logger.error(msg) + raise ValueError(msg) + return metadata_df + + +def check_dir(entry_id: str, dir: Path) -> None: + """ + Validate that the given directory exists and is a directory. + + :param entry_id: The Entry ID used for error reporting. + :param dir: Path to the expected AlphaFold data directory. + :return: None. + :raises FileNotFoundError: If the directory does not exist or is + not a valid directory. + """ + if not dir.exists() or not dir.is_dir(): + msg = f"AlphaFold data directory not found for entry '{entry_id}': {dir}" + logger.error(msg) + raise FileNotFoundError(msg) + + +def get_cif_df_from_disk( + entry_id: str, structure_dir: Path, messages: list +) -> pd.DataFrame: + """ + Load the AlphaFold mmCIF file from disk and return it as a DataFrame. + + The function searches the given structure directory for files with + the .cif extension. If multiple CIF files are found, only the first + one is read and a warning message is logged and appended to the + messages list. If no CIF file is found, a FileNotFoundError is raised. + + :param entry_id: The Entry ID used for error reporting. + :param structure_dir: Path to the directory containing the structure + files. + :param messages: A list used to collect structured log messages + with level and message content. + :return: A DataFrame containing the parsed mmCIF data. + :raises FileNotFoundError: If no CIF file is found in the directory. + :raises RuntimeError: If reading the CIF file fails. + """ + cif_files = list(structure_dir.glob("*.cif")) + if not cif_files: + msg = f"No CIF file found in {structure_dir} for entry '{entry_id}'" + logger.error(msg) + raise FileNotFoundError(msg) + + if len(cif_files) > 1: + message = "There are several CIF files for this protein structure prediction. The first one will be read, all others will be ignored." + logger.info(message) + messages.append(dict(level=logging.WARNING, msg=message)) + + cif_file = cif_files[0] + try: + cif_df = read_alphafold_mmcif(cif_file) + return cif_df + except Exception as e: + msg = f"Failed to read CIF file '{cif_file}': {e}" + logger.exception(msg) + raise RuntimeError(msg) from e + + +def get_amino_acid_sequences_df_from_disk( + entry_id: str, structure_dir: Path +) -> pd.DataFrame: + """ + Load the amino acid sequence DataFrame from a FASTA file on disk. + + The function searches the given structure directory for files with + the .fasta or .fa extension. The first matching file is parsed using + fasta_import and the DataFrame stored under the key "fasta_df" is + returned. + + :param entry_id: The Entry ID used for error reporting. + :param structure_dir: Path to the directory containing the FASTA file. + :return: A DataFrame containing the amino acid sequence data. + :raises FileNotFoundError: If no FASTA file is found in the directory. + :raises RuntimeError: If loading the FASTA file fails or if the + importer does not return a "fasta_df" entry. + """ + fasta_files = list(structure_dir.glob("*.fasta")) + list(structure_dir.glob("*.fa")) + if not fasta_files: + msg = f"No FASTA file found in {structure_dir} for entry '{entry_id}'" + logger.error(msg) + raise FileNotFoundError(msg) + + fasta_file = fasta_files[0] + try: + fasta_dict = fasta_import(str(fasta_file)) + amino_acid_sequences_df = fasta_dict.get("fasta_df") + if amino_acid_sequences_df is None: + msg = f"FASTA importer did not return 'fasta_df' for {fasta_file}" + logger.error(msg) + raise RuntimeError(msg) + except Exception as e: + msg = f"Failed to load FASTA '{fasta_file}': {e}" + logger.exception(msg) + raise RuntimeError(msg) from e + return amino_acid_sequences_df + + +def get_json_files_in_dir(entry_id: str, structure_dir: Path) -> list: + """ + Retrieve all JSON files from a given structure directory. + + The function searches the specified directory for files with the + .json extension and returns them as a list. If no JSON files are + found, an error is logged and a FileNotFoundError is raised. + + :param entry_id: The Entry ID used for error reporting. + :param structure_dir: Path to the directory to search for JSON files. + :return: A list of Path objects representing the JSON files found + in the directory. + :raises FileNotFoundError: If no JSON files are found in the directory. + """ + json_files = list(structure_dir.glob("*.json")) + if not json_files: + msg = f"No JSON files found in {structure_dir} for entry '{entry_id}'" + logger.error(msg) + raise FileNotFoundError(msg) + return json_files + + +def check_success_of_get_df(entry_id: str, df_dict: dict, messages: list) -> None: + """ + Evaluate whether all retrieved DataFrames contain data and log the result. + + The function checks if any DataFrame in df_dict is empty. If none are + empty, a success message is logged and appended to the messages list. + If at least one DataFrame is empty, a warning message is logged and + appended instead. + + :param entry_id: The Entry ID used for logging the result. + :param df_dict: A dictionary containing DataFrames that were loaded + for the given entry. + :param messages: A list used to collect structured log messages + with level and message content. + :return: None. + """ + if not any(df.empty for df in df_dict.values()): + success_msg = f"Successfully loaded AlphaFold data for entry '{entry_id}'" + logger.info(success_msg) + messages.append(dict(level=logging.INFO, msg=success_msg)) + else: + message = f"Could not load AlphaFold data for entry '{entry_id}'" + logger.warning(message) + messages.append(dict(level=logging.WARNING, msg=message)) + + +def get_monomer_structure_dfs(entry_id: str) -> dict[str, Any]: + """ + Writes monomer structure data from disk of a specific entry ID into dataframes. + + :param entry_id: entry_id of the uploaded monomer structure + :return: A dictionary containing DataFrames for monomer metadata, CIF, PAE, pLDDT, and sequence data + """ + messages: list[dict[str, str | int]] = [] + all_metadata_df = get_monomer_metadata_df() + + monomer_metadata_df = check_and_get_metadata_df( + entry_id=entry_id, + all_metadata_df=all_metadata_df, + csv_file=paths.AF_MONOMER_METADATA_CSV_PATH, + ) + + structure_dir = paths.ALPHAFOLD_MONOMER_PATH / entry_id.upper() + check_dir(entry_id=entry_id, dir=structure_dir) + + # get cif file + cif_df = get_cif_df_from_disk( + entry_id=entry_id, structure_dir=structure_dir, messages=messages + ) + + # get fasta file + amino_acid_sequences_df = get_amino_acid_sequences_df_from_disk( + entry_id=entry_id, structure_dir=structure_dir + ) + + # get jsons (PAE and pLDDT) + json_files = list(structure_dir.glob("*.json")) + if not json_files: + msg = ( + f"No JSON files (PAE/pLDDT) found in {structure_dir} for entry '{entry_id}'" + ) + logger.error(msg) + raise FileNotFoundError(msg) + + try: + if len(json_files) == 1: + msg = f"Only one json file found in {structure_dir} for entry '{entry_id}'. Two json files are expected" + logger.error(msg) + raise RuntimeError() + else: + json1 = pd.read_json(json_files[0]) + json2 = pd.read_json(json_files[1]) + if ( + "predicted_aligned_error" in json1.columns + and "residueNumber" in json2.columns + ): + pae_df = json1 + plddt_df = json2 + elif ( + "predicted_aligned_error" in json2.columns + and "residueNumber" in json1.columns + ): + pae_df = json2 + plddt_df = json1 + else: + # Fallback: assign and warn + pae_df = json1 + plddt_df = json2 + warn = f"Could not detect PAE/pLDDT in JSON files for entry '{entry_id}'; ''{json_files[0]} is read as PAE, {json_files[1]} is read as pLDDT." + logger.warning(warn) + messages.append(dict(level=logging.WARNING, msg=warn)) + except Exception as e: + msg = f"Failed to read JSON files in {structure_dir}: {e}" + logger.exception(msg) + raise RuntimeError(msg) from e + + # For consistency with multimer pLDDT + plddt_df["chainID"] = "A" + + df_dict = { + "structure_metadata_df": monomer_metadata_df, + "cif_df": cif_df, + "pae_df": pae_df, + "plddt_df": plddt_df, + "amino_acid_sequences_df": amino_acid_sequences_df, + } + check_success_of_get_df(entry_id=entry_id, df_dict=df_dict, messages=messages) + + data_for_visualization = { + "structure_entry_id": entry_id, + "cif_df": cif_df, + } + + pae_string = str(df_dict["pae_df"]["predicted_aligned_error"].iloc[0]) + pae_matrix = np.array(ast.literal_eval(pae_string)) + del df_dict["pae_df"] + + return dict( + **df_dict, + pae_matrix=OutputItem(output_type=OutputType.JOBLIB_ARTIFACT, value=pae_matrix), + messages=messages, + visualization=OutputItem( + output_type=OutputType.VISUALIZATION, value=data_for_visualization + ), + ) + + +def unwrap_full_data_df(full_data_df: pd.DataFrame) -> dict[str, Any]: + """ + Extracts certain data from a full_data_df, deletes the extracted columns + and returns the "remaining" full_data_df as well as the extracted data. + + :param full_data_df: The AlphaFold3 full_data_df + :return dict: + - "full_data_df": The updated reduced full_data_df + - "pae_matrix": Numpy matrix with the PAE values for each residue pair + - "token_res_ids": List with the token -> AA mappings + """ + + try: + pae_matrix = np.array(full_data_df["pae"].iloc[0]) + full_data_df = full_data_df.drop(columns=["pae"]) + except KeyError: + pae_matrix = None + + try: + token_res_ids = np.array(full_data_df["token_res_ids"].iloc[0]) + full_data_df = full_data_df.drop(columns=["token_res_ids"]) + except KeyError as e: + raise KeyError( + "Prediction data does not contain required prediction token to amino acid mapping." + ) from e + + return dict( + full_data_df=full_data_df, + pae_matrix=pae_matrix, + token_res_ids=token_res_ids, + ) + + +def get_plddt_from_cif(cif_df: pd.DataFrame) -> pd.DataFrame | None: + """ + For use with multimers predicted using Alphafold3. + Returns per-residue pLDDT values for the predicted structure. + Note that sine AlphaFold3 uses per-atom pLDDT, we use the pLDDT for the CA atom. + See also https://github.com/google-deepmind/alphafold3/issues/330 + + :param cif_df: the cif_df holding the _atom_site table. + :return: DataFrame containing columns + "chainID", "residueNumber", "confidenceScore", "confidenceCategory" + """ + + try: + filtered_cif_df = cif_df[cif_df["_atom_site.label_atom_id"] == "CA"] + filtered_cif_df = filtered_cif_df[ + [ + "_atom_site.auth_asym_id", + "_atom_site.label_seq_id", + "_atom_site.B_iso_or_equiv", + ] + ] + filtered_cif_df = filtered_cif_df.rename( + columns={ + "_atom_site.auth_asym_id": "chainID", + "_atom_site.label_seq_id": "residueNumber", + "_atom_site.B_iso_or_equiv": "confidenceScore", + } + ) + return filtered_cif_df + + except KeyError: + return None + + +def get_multimer_structure_dfs(entry_id: str) -> dict[str, Any]: + """ + Writes multimer structure data from disk of a specific entry ID into dataframes. + + :param entry_id: entry_id of the uploaded monomer structure + :return: A dictionary containing DataFrames for multimer metadata, CIF, confidence, full data, and sequence data + """ + messages: list[dict[str, str | int]] = [] + all_metadata_df = get_multimer_metadata_df() + + multimer_metadata_df = check_and_get_metadata_df( + entry_id=entry_id, + all_metadata_df=all_metadata_df, + csv_file=paths.AF_MULTIMER_METADATA_CSV_PATH, + ) + + structure_dir = paths.ALPHAFOLD_MULTIMER_PATH / entry_id.upper() + check_dir(entry_id=entry_id, dir=structure_dir) + + # get cif file + cif_df = get_cif_df_from_disk( + entry_id=entry_id, structure_dir=structure_dir, messages=messages + ) + + # get fasta file + amino_acid_sequences_df = get_amino_acid_sequences_df_from_disk( + entry_id=entry_id, structure_dir=structure_dir + ) + + # get jsons (full data and confidence and job requests) + json_files = get_json_files_in_dir(entry_id=entry_id, structure_dir=structure_dir) + + try: + if len(json_files) == 1: + msg = f"Only one json file found in {structure_dir} for entry '{entry_id}'. Two json files are expected" + logger.error(msg) + raise RuntimeError() + elif len(json_files) == 2: + msg = f"Only two json file found in {structure_dir} for entry '{entry_id}'. Three json files are expected" + logger.error(msg) + raise RuntimeError() + else: + with open(json_files[0], "r") as f: + obj1 = json.load(f) + with open(json_files[1], "r") as f: + obj2 = json.load(f) + with open(json_files[2], "r") as f: + obj3 = json.load(f) + + json1 = pd.json_normalize(obj1) + json2 = pd.json_normalize(obj2) + json3 = pd.json_normalize(obj3) + # iptm stands for interface predicted TM score + + confidence_df, full_data_df, job_request_df = None, None, None + for json_df in [json1, json2, json3]: + if "chain_iptm" in json_df.columns: + confidence_df = json_df + elif "pae" in json_df.columns: + full_data_df = json_df + elif "sequences" in json_df.columns: + job_request_df = json_df + if confidence_df is None or full_data_df is None or job_request_df is None: + msg = f"Could not detect confidence scores/full data/job request in JSON files for entry '{entry_id}'." + logger.exception(msg) + raise RuntimeError(msg) + except Exception as e: + msg = f"Failed to read JSON files in {structure_dir}: {e}" + logger.exception(msg) + raise RuntimeError(msg) from e + df_dict = { + "structure_metadata_df": multimer_metadata_df, + "amino_acid_sequences_df": amino_acid_sequences_df, + "cif_df": cif_df, + "confidence_df": confidence_df, + "full_data_df": full_data_df, + "job_request_df": job_request_df, + } + check_success_of_get_df(entry_id=entry_id, df_dict=df_dict, messages=messages) + data_for_visualization = { + "structure_entry_id": entry_id, + "cif_df": cif_df, + } + + unwrapped_full_data = unwrap_full_data_df(df_dict["full_data_df"]) + df_dict["full_data_df"] = unwrapped_full_data["full_data_df"] + + pae_matrix = unwrapped_full_data["pae_matrix"] + token_res_ids = unwrapped_full_data["token_res_ids"] + plddt_df = get_plddt_from_cif(df_dict["cif_df"]) + + pae_matrix = reduce_pae_to_per_amino_acid( + pae_matrix, token_res_ids, df_dict["cif_df"] + ) + + if plddt_df is None: + messages.append( + dict( + level=logging.WARNING, + msg=f"Could not parse pLDDT values from CIF file. File is likely malformed!", + ) + ) + + return dict( + **df_dict, + messages=messages, + plddt_df=plddt_df, + pae_matrix=OutputItem(output_type=OutputType.JOBLIB_ARTIFACT, value=pae_matrix), + visualization=OutputItem( + output_type=OutputType.VISUALIZATION, value=data_for_visualization + ), + ) + + +def upload_multimer_prediction( + entry_id: str, + uniprot_ids: str, + model_used: str, + amino_acid_sequences: Path, + cif_file: Path, + confidence_file: Path, + full_data_file: Path, + job_request_file: Path, + persist_upload: bool, +) -> dict[str, Any]: + """ + Process an AlphaFold multimer prediction and return its parsed data as DataFrames. + + The function assembles multimer metadata for the prediction, optionally persists both + multimer metadata and input files to the configured multimer storage directory, and + parses the provided files into DataFrames: + - FASTA sequences via fasta_import, key "fasta_df". + - mmCIF structure via read_alphafold_mmcif. + - Confidence JSON via pandas.read_json. + - Full data JSON via json.load and pandas.json_normalize if it is a dict. + + The returned dictionary contains the DataFrames and a "messages" list with + structured log entries describing warnings or errors encountered. + + Temporary working directories created when persist_upload is False are + removed in a finally block. + + :param entry_id: Unique identifier for the prediction entry. Used for + directory naming and mulitmer metadata. + :param uniprot_ids: UniProt identifiers associated with the multimer + prediction. + :param model_used: Name or identifier of the AlphaFold model used to + create the prediction. + :param amino_acid_sequences: Path to the FASTA file containing the amino + acid sequences. + :param cif_file: Path to the mmCIF structure file. + :param confidence_file: Path to the confidence JSON file. + :param full_data_file: Path to the full data JSON file. If the JSON + content is a dict it is normalized into a single-row DataFrame. + Otherwise, an empty DataFrame is returned and a warning is recorded. + :param persist_upload: If True, persist multimer metadata and copy input files into + the configured multimer directory. If False, use a temporary directory + and do not persist multimer metadata. + :return: A dictionary containing: + - "structure_metadata_df": DataFrame with entry multimer metadata. + - "cif_df": DataFrame parsed from the mmCIF file. + - "confidence_df": DataFrame loaded from the confidence JSON. + - "full_data_df": Normalized DataFrame from the full data JSON or empty. + - "amino_acid_sequences_df": DataFrame from the FASTA import. + - "messages": List of structured log messages with level and msg. + :raises Exception: Any exception raised during parsing or file operations + will propagate after cleanup of any temporary directory. + """ + + if not entry_id: + msg = "The entry Id cannot be empty or None." + logger.error(msg) + raise ValueError(msg) + + if not uniprot_ids: + msg = "Uniprot Ids cannot be empty or None." + logger.error(msg) + raise ValueError(msg) + + messages = [] + + temp_dir, work_dir = get_correct_af_directories( + entry_id=entry_id, + directory_name=paths.ALPHAFOLD_MULTIMER_PATH, + persist_upload=persist_upload, + ) + + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + uniprot_ids_as_list = re.split(r"\s*,\s*", uniprot_ids.strip()) + + data: dict[str, Any] = { + "entry_id": entry_id, + "uniprot_ids": uniprot_ids_as_list, + "model_created_date": timestamp, + "model_used": "" if model_used is None else model_used, + } + + try: + multimer_metadata_df = pd.DataFrame([data]) + if persist_upload: + exsisting_metadata_df = get_multimer_metadata_df() + extend_metadata_csv( + entry_id=entry_id, + metadata_csv=paths.AF_MULTIMER_METADATA_CSV_PATH, + existing_metadata_df=exsisting_metadata_df, + metadata_df=multimer_metadata_df, + messages=messages, + ) + for file_name in [ + amino_acid_sequences, + cif_file, + confidence_file, + full_data_file, + job_request_file, + ]: + success, msg = copy_file_to_directory(file_name, work_dir) + if not success: + logger.error(msg) + messages.append(dict(level=logging.ERROR, msg=msg)) + + fasta_dict = fasta_import(str(amino_acid_sequences)) + amino_acid_sequences_df = fasta_dict["fasta_df"] + + confidence_df = pd.read_json(confidence_file) + job_request_df = pd.read_json(job_request_file) + + # full_data json has arrays of unequal lengths so we need to normalize + full_data_df = pd.DataFrame() + with open(full_data_file, "r") as f: + full_data = json.load(f) + if isinstance(full_data, dict): + full_data_df = pd.json_normalize(full_data) + else: + messages.append( + { + "level": logging.WARNING, + "msg": "Could not load full data Json", + } + ) + + cif_df = read_alphafold_mmcif(cif_file) + + df_dict = { + "structure_metadata_df": multimer_metadata_df, + "cif_df": cif_df, + "confidence_df": confidence_df, + "full_data_df": full_data_df, + "amino_acid_sequences_df": amino_acid_sequences_df, + "job_request_df": job_request_df, + } + + if not any(df.empty for df in df_dict.values()): + + unwrapped_full_data = unwrap_full_data_df(df_dict["full_data_df"]) + df_dict["full_data_df"] = unwrapped_full_data["full_data_df"] + + pae_matrix = reduce_pae_to_per_amino_acid( + unwrapped_full_data["pae_matrix"], + unwrapped_full_data["token_res_ids"], + df_dict["cif_df"], + ) + + pae_matrix = OutputItem( + output_type=OutputType.JOBLIB_ARTIFACT, + value=pae_matrix, + ) + df_dict["pae_matrix"] = pae_matrix + df_dict["plddt_df"] = get_plddt_from_cif(df_dict["cif_df"]) + + if df_dict["plddt_df"] is None: + messages.append( + dict( + level=logging.WARNING, + msg=f"Could not parse pLDDT values from CIF file. File is likely malformed!", + ) + ) + data_for_visualization = { + "structure_entry_id": entry_id, + "cif_df": cif_df, + } + + success_msg = f"Successfully loaded AlphaFold data for entry '{entry_id}'" + logger.info(success_msg) + messages.append(dict(level=logging.INFO, msg=success_msg)) + else: + message = f"Could not load AlphaFold data for entry '{entry_id}'" + logger.warning(message) + messages.append(dict(level=logging.WARNING, msg=message)) + data_for_visualization = None + + finally: + if temp_dir is not None: + shutil.rmtree(temp_dir, ignore_errors=True) + + return dict( + **df_dict, + messages=messages, + visualization=OutputItem( + output_type=OutputType.VISUALIZATION, value=data_for_visualization + ), + ) diff --git a/backend/protzilla/importing/crosslinking_import.py b/backend/protzilla/importing/crosslinking_import.py new file mode 100644 index 000000000..8b1ae718e --- /dev/null +++ b/backend/protzilla/importing/crosslinking_import.py @@ -0,0 +1,816 @@ +""" +This module contains the code to parse a file containing crosslinking data. +""" + +import logging +from pathlib import Path +import pandas as pd +import traceback +import requests +import re +from io import StringIO +from itertools import islice +from functools import partial +from typing import Callable, Optional +from enum import Enum + +from backend.protzilla.utilities.utilities import format_trace +from backend.protzilla.importing.import_utils import ( + columns_in_crosslinking_df, + rename_columns_csm_format, + rename_columns_proteomediscoverer_xlinkx_format, +) + + +class ProteinLookupError(Enum): + NOT_A_VALID_PROTEIN_ID = "NOT_A_VALID_PROTEIN_ID" + IS_DECOY_PROTEIN = "IS_DECOY_PROTEIN" + NO_PROTEIN_ID_FOUND = "NO_PROTEIN_ID_FOUND" + NO_GENE_NAME_FOUND = "NO_GENE_NAME_FOUND" + TIMEOUT = "TIMEOUT" + HTTP_ERROR = "HTTP_ERROR" + REQUEST_ERROR = "REQUEST_ERROR" + NOT_LOOKED_UP = "NOT_LOOKED_UP" + + +class ProteinDesignationLookupMode(Enum): + gene_name_to_id = "gene_name_to_id" + id_to_gene_name = "id_to_gene_name" + + +def aggregate_data(df: pd.DataFrame, column: str) -> set: + """ + Extract unique values from two DataFrame columns and return them as a set. + + :param df: Input DataFrame + :type df: pd.DataFrame + :param column: Column name + :type column: str + :return: Unique values from the column + :rtype: set + """ + return set( + df[[column + "1", column + "2"]].stack().dropna().astype(str).str.strip() + ) + + +def validate_data_before_lookup( + data_for_lookup: set[str], + validator_function: Callable[[str], bool], + error_code: str, +) -> tuple[set[str], dict[str, tuple[bool, None, str]]]: + """ + Split input values into valid and invalid ones. + Invalid values are directly written to the results with the given error code. + + :param data_for_lookup: Set of input values to be validated + :type data_for_lookup: set[str] + :param validator_function: Validation function applied to each value. + Must accept a single string and return ``True`` if valid, + otherwise ``False``. + :type validator_function: Callable[[str], bool] + :param error_code: Error code assigned to invalid values + :type error_code: str + + :return: Tuple containing valid data and validation results + :rtype: tuple[set[str], dict[str, tuple[bool, None, str]]] + + :returns valid_data: Set of values that passed validation + :returns results: Mapping of invalid values to ``(False, None, error_code)`` + """ + valid_data = set() + results = {} + + if not data_for_lookup: + return valid_data, results + + for data in data_for_lookup: + if validator_function(data): + valid_data.add(data) + else: + results[data] = (False, None, error_code) + + return valid_data, results + + +def split_data_in_batches(data: "iterable") -> "iterable": + """ + Split an iterable into consecutive batches of fixed maximum size. + + The function yields lists containing up to 25 elements from the input iterable. + The final batch may contain fewer elements. + + :param data: Iterable containing input elements to be batched + :type data: iterable + + :return: Iterator yielding batches of input elements as lists + :rtype: iterable + + :yields: Lists of at most 25 elements + :yield type: list + """ + max_allowed_uniprot_batch_size = 25 + iterable = iter(data) + while batch := list(islice(iterable, max_allowed_uniprot_batch_size)): + yield batch + + +def build_uniprot_search_params( + data_for_lookup: set[str], + field_of_existing_data: str, + extra_query: str | None = None, + extra_fields: str | None = None, +) -> tuple[str, dict[str, str]]: + """ + Build the UniProt search URL and query parameters for a batch of identifiers. + + :param data_for_lookup: Set of values to look up (e.g., UniProt IDs or gene names) + :type data_for_lookup: set[str] + :param field_of_existing_data: Field name in UniProt to search for (e.g., "accession" or "gene_exact") + :type field_of_existing_data: str + :param extra_query: Optional additional query string to filter results + :type extra_query: str or None + :param extra_fields: Comma-separated list of additional fields to return (e.g., "id,protein_name") + :type extra_fields: str or None + + :return: Tuple containing the UniProt search URL and the query parameters dictionary + :rtype: tuple[str, dict[str, str]] + + :returns uniprot_search_url: Base URL for UniProt REST API search + :returns params: Dictionary of query parameters for the request + """ + uniprot_search_url = "https://rest.uniprot.org/uniprotkb/search" + + base_query = " OR ".join( + f"{field_of_existing_data}:{data}" for data in data_for_lookup + ) + if extra_query: + base_query = f"({base_query}) AND {extra_query}" + + fields = "accession,gene_primary" + if extra_fields: + fields = fields + "," + extra_fields + + params = { + "query": base_query, + "format": "tsv", + "fields": fields, + } + + return uniprot_search_url, params + + +def execute_uniprot_request( + url: str, + params: dict[str, str], + valid_data: set[str], + results: dict[str, tuple[bool, None, str]], +) -> Optional[requests.Response]: + """ + Execute a UniProt HTTP request with error handling and update the results for failed queries. + + :param url: UniProt REST API URL to send the request to + :type url: str + :param params: Dictionary of query parameters for the request + :type params: dict[str, str] + :param valid_data: Set of input values that were intended to be queried + :type valid_data: set[str] + :param results: Dictionary to store lookup results; failed lookups are updated here + as ``data -> (False, None, error_code)`` + :type results: dict[str, tuple[bool, None, str]] + + :return: The HTTP response object if the request succeeded, otherwise None + :rtype: requests.Response or None + + :raises requests.exceptions.Timeout: If the request times out + :raises requests.exceptions.HTTPError: If the server returns an HTTP error + :raises requests.exceptions.RequestException: For other request-related errors + + :note: On failure, all entries in `valid_data` are updated in `results` with the + corresponding error code: + - "TIMEOUT" for a timeout + - "HTTP_" for HTTP errors + - "REQUEST_ERROR" for other request failures + """ + try: + response = requests.get(url, params=params, timeout=15) + response.raise_for_status() + return response + + except requests.exceptions.Timeout: + error = ProteinLookupError.TIMEOUT.value + except requests.exceptions.HTTPError as e: + error = f"{ProteinLookupError.HTTP_ERROR.value}_{e.response.status_code}" + except requests.exceptions.RequestException: + error = ProteinLookupError.REQUEST_ERROR.value + + for data in valid_data: + results[data] = (False, None, error) + return None + + +def process_uniprot_response( + response: requests.Response, + results: dict[str, tuple[bool, str | None, None | str]], + input_data: set[str], + mode: ProteinDesignationLookupMode, +) -> None: + """ + Process a UniProt API response and update the results dictionary. + + The function reads a TSV response from UniProt, extracts the requested data + (gene name or protein ID depending on mode), and updates the results dictionary + with valid lookups. In `gene_name_to_id` mode, it also checks alternative gene names. + + :param response: The HTTP response object returned from a UniProt request + :type response: requests.Response + :param results: Dictionary to store lookup results; updated in place + as ``existing_data -> (True, requested_data, None)`` + :type results: dict[str, tuple[bool, str | None, str | None]] + :param input_data: Set of input values that were originally queried + :type input_data: set[str] + :param mode: Lookup mode, either mapping IDs to gene names or gene names to IDs + :type mode: ProteinDesignationLookupMode + + :return: None (results dictionary is updated in place) + :rtype: None + """ + df = pd.read_csv(StringIO(response.text), sep="\t") + + for _, row in df.iterrows(): + protein_id = row.get("Entry") + primary_gene_name = row.get("Gene Names (primary)") + + if mode == ProteinDesignationLookupMode.id_to_gene_name.value: + existing_data = protein_id + requested_data = primary_gene_name + elif mode == ProteinDesignationLookupMode.gene_name_to_id.value: + existing_data = primary_gene_name + requested_data = protein_id + + if pd.notna(requested_data) and requested_data != "": + if existing_data in input_data: + results[existing_data] = (True, requested_data, None) + elif mode == ProteinDesignationLookupMode.gene_name_to_id.value: + alternative_gene_names = str(row.get("Gene Names", "")).split() + for gene_name in alternative_gene_names: + if gene_name in input_data: + results[gene_name] = (True, requested_data, None) + break + + +def uniprot_lookup( + input_data: set[str], + mode: ProteinDesignationLookupMode, + results: dict[str, tuple[bool, Optional[str], Optional[str]]], + organism_id: Optional[str] = None, +) -> None: + """ + Perform a UniProt lookup for a batch of input data, updating the results dictionary. + + Depending on the mode, the function either maps protein IDs to gene names + or gene names to protein IDs. The function handles batching, requests, and + response processing. Any input values that do not return results are marked + as failed in the results dictionary with an appropriate error code. + + :param input_data: Set of input values to look up (protein IDs or gene names) + :type input_data: set[str] + :param mode: Lookup mode, either "id_to_gene_name" or "gene_name_to_id" + :type mode: ProteinDesignationLookupMode + :param results: Dictionary to store lookup results; updated in place + with ``existing_data -> (success, value, error_code)`` + :type results: dict[str, tuple[bool, str | None, str | None]] + :param organism_id: Required only for 'gene_name_to_id' mode to filter queries + :type organism_id: str, optional + + :return: None (results dictionary is updated in place) + :rtype: None + """ + if mode == ProteinDesignationLookupMode.id_to_gene_name.value: + field_of_existing_data = "accession" + extra_query = None + extra_fields = None + elif mode == ProteinDesignationLookupMode.gene_name_to_id.value: + field_of_existing_data = "gene_exact" + extra_query = f"organism_id:{organism_id} AND reviewed:true" + extra_fields = "gene_names" + + for batch in split_data_in_batches(data=input_data): + + url, params = build_uniprot_search_params( + data_for_lookup=batch, + field_of_existing_data=field_of_existing_data, + extra_query=extra_query, + extra_fields=extra_fields, + ) + + response = execute_uniprot_request( + url=url, params=params, valid_data=batch, results=results + ) + if response is None: + continue + + process_uniprot_response( + response=response, results=results, input_data=batch, mode=mode + ) + + +def get_gene_name_from_protein_ids( + protein_ids: set[str], +) -> dict[str, tuple[bool, Optional[str], Optional[str]]]: + """ + Retrieve the gene names for a given set of Protein IDs in a batch from UniProt. + + :param protein_ids: Set of UniProt accession IDs (e.g., {"Q92878", "P51587"}) + :type protein_ids: set[str] + + :return: Mapping of protein_id to a tuple containing lookup result, gene name, and error + :rtype: dict[str, tuple[bool, str | None, str | None]] + + :returns success: True if the lookup for that protein_id succeeded, False otherwise + :returns gene_name: Official gene name if successful, else None + :returns error: Error code or message if the lookup failed, else None + """ + # Regex for valid accession input directly from UniProt + # (extended to include isoforms) + # A batch request containing an id that doesn't match this regex, + # leads to an http 400 for the whole request. + valid_id_pattern = re.compile( + r"^(?:" + r"[OPQ][0-9][A-Z0-9]{3}[0-9]" + r"|" + r"[A-NR-Z][0-9](?:[A-Z][A-Z0-9]{2}[0-9]){1,2}" + r")" + r"(?:-.+)?$" + ) + + valid_ids, results = validate_data_before_lookup( + data_for_lookup=protein_ids, + validator_function=lambda pid: bool(valid_id_pattern.match(pid)), + error_code=ProteinLookupError.NOT_A_VALID_PROTEIN_ID.value, + ) + + if not valid_ids: + return results + + valid_ids_without_isoform = {x.split("-", 1)[0] for x in valid_ids} + + uniprot_lookup( + input_data=valid_ids_without_isoform, + mode=ProteinDesignationLookupMode.id_to_gene_name.value, + results=results, + organism_id=None, + ) + + for protein_id in valid_ids_without_isoform: + if protein_id not in results: + results[protein_id] = ( + False, + None, + ProteinLookupError.NO_GENE_NAME_FOUND.value, + ) + + return results + + +def get_protein_ids_from_gene_name( + gene_names: set[str], organism_ids: list[str] +) -> dict[str, tuple[bool, Optional[str], Optional[str]]]: + """ + Retrieve UniProt protein IDs for a given set of human gene names as a batch query. + + :param gene_names: Set of gene symbols to look up (e.g., {"RAD50", "MRE11"}) + :type gene_names: set[str] + :param organism_ids: list of organism identifiers for filtering UniProt queries (e.g., "9606" for human) + :type organism_ids: list[str] + + :return: Dictionary mapping each gene name to a tuple of (success, protein_id, error) + :rtype: dict[str, tuple[bool, str | None, str | None]] + + :returns success: True if the lookup for this gene name succeeded, False otherwise + :returns protein_id: The first valid protein ID found for the gene, or None if lookup failed + :returns error: Error code or message if the lookup failed, else None + """ + # Filter decoy Proteins, because we cannot process them decently + valid_gene_names, results = validate_data_before_lookup( + data_for_lookup=gene_names, + validator_function=lambda name: not name.startswith("decoy:"), + error_code=ProteinLookupError.IS_DECOY_PROTEIN.value, + ) + + if not valid_gene_names: + return results + + remaining_gene_names = set(valid_gene_names) + for single_organism_id in organism_ids: + + if not remaining_gene_names: + continue + + uniprot_lookup( + input_data=remaining_gene_names, + mode=ProteinDesignationLookupMode.gene_name_to_id.value, + results=results, + organism_id=single_organism_id, + ) + + for gene_name in list(remaining_gene_names): + if gene_name in results: + remaining_gene_names.discard(gene_name) + + for gene_name in valid_gene_names: + if gene_name not in results: + results[gene_name] = ( + False, + None, + ProteinLookupError.NO_GENE_NAME_FOUND.value, + ) + + return results + + +def iterate_for_protein_designation( + df: pd.DataFrame, + existing_designation: str, + new_designation: str, + uniprot_lookup_results: dict[str, tuple[bool, Optional[str], Optional[str]]], +) -> tuple[pd.DataFrame, pd.DataFrame]: + """ + Iterate over a DataFrame and add missing protein designations using precomputed lookup results. + Either protein IDs or gene names are included in the DataFrame, and the other is added + to the DataFrame in this function. + + :param df: Input DataFrame + :type df: pandas.DataFrame + :param existing_designation: Column name in `df` containing existing protein designation + (e.g., "Protein_id" or "Protein") + :type existing_designation: str + :param new_designation: Column name to store the newly added protein designation + :type new_designation: str + :param uniprot_lookup_results: Mapping of key -> (success, data, error) + Contains precomputed lookup results + :type uniprot_lookup_results: dict + + :return: Tuple containing rows with successful lookups and rows with lookup errors + :rtype: tuple[pandas.DataFrame, pandas.DataFrame] + + :returns good_df: Rows with successful lookups + :returns failed_df: Rows with lookup errors + """ + good_rows = [] + failed_rows = [] + + for _, row in df.iterrows(): + row_dict = row.to_dict() + + protein_id1 = row[existing_designation + "1"].split("-", 1)[0] + protein_id2 = row[existing_designation + "2"].split("-", 1)[0] + + success1, data1, error1 = uniprot_lookup_results.get( + protein_id1, (False, None, ProteinLookupError.NOT_LOOKED_UP.value) + ) + success2, data2, error2 = uniprot_lookup_results.get( + protein_id2, (False, None, ProteinLookupError.NOT_LOOKED_UP.value) + ) + + errors_occurred = {} + if not success1: + errors_occurred["Protein1_error"] = error1 + if not success2: + errors_occurred["Protein2_error"] = error2 + + if errors_occurred: + failed_row = row_dict.copy() + failed_row.update(errors_occurred) + failed_rows.append(failed_row) + else: + row_dict[new_designation + "1"] = data1 + row_dict[new_designation + "2"] = data2 + good_rows.append(row_dict) + + good_df = pd.DataFrame(good_rows) + failed_df = pd.DataFrame(failed_rows) + + return good_df, failed_df + + +def get_missing_protein_designation( + df: pd.DataFrame, + existing_column: str, + missing_column: str, + uniprot_lookup_function: Callable[ + [set[str]], dict[str, tuple[bool, str | None, str | None]] + ], +) -> tuple[pd.DataFrame, pd.DataFrame]: + """ + Fill missing protein designations in a DataFrame using a UniProt lookup function. + + This function aggregates unique values from the existing column, performs a batch + lookup using `uniprot_lookup_function`, and populates the missing column. The resulting + rows are split into successful and failed lookups. + + :param df: Input DataFrame containing existing protein designations + :type df: pandas.DataFrame + :param existing_column: Name of the column with existing protein designations + :type existing_column: str + :param missing_column: Name of the column to populate with missing designations + :type missing_column: str + :param uniprot_lookup_function: Function that performs a batch UniProt lookup. + Should accept a set of values and return results + as a dictionary ``key -> (success, data, error_code)`` + :type uniprot_lookup_function: Callable[[set[str]], dict[str, tuple[bool, Any, str | None]]] + + :return: Tuple of DataFrames containing rows with successful lookups and rows with errors + :rtype: tuple[pandas.DataFrame, pandas.DataFrame] + + :returns good_df: Rows where missing protein designations were successfully populated + :returns failed_df: Rows where the lookup failed + """ + unique_existing_designations = aggregate_data(df=df, column=existing_column) + uniprot_lookup_results = uniprot_lookup_function(unique_existing_designations) + good_df, failed_df = iterate_for_protein_designation( + df=df, + existing_designation=existing_column, + new_designation=missing_column, + uniprot_lookup_results=uniprot_lookup_results, + ) + + if not good_df.empty: + good_df = normalize_crosslinking_df(good_df) + + return good_df, failed_df + + +def remove_brackets_from_peptide(peptide: str) -> str: + return peptide.replace("[", "").replace("]", "") + + +def get_amino_acid_where_crosslink_is_connected_proteomediscoverer_xlinkx_format( + peptide: str, +) -> int: + return peptide.find("[") + 1 # 1-based index + + +def read_ProteomeDiscoverer_XlinkX_file( + file_path: Path, +) -> tuple[pd.DataFrame, pd.DataFrame]: + """ + Read and process a ProteomeDiscoverer XlinkX Excel file: + 1. Reads the Excel file and renames columns to a standard format. + 2. Extracts crosslink positions for both peptides. + 3. Cleans peptide sequences by removing brackets and converting to string type. + 4. Converts intra-crosslink annotations to boolean. + 5. Removes isoform suffixes from protein IDs. + 6. Fills missing protein designations using UniProt gene name lookup. + 7. Splits the resulting DataFrame into successful and failed lookups. + + :param file_path: Path to the ProteomeDiscoverer XlinkX Excel file + :type file_path: pathlib.Path + + :return: Tuple of DataFrames containing rows with successfully mapped proteins and rows where lookup failed + :rtype: tuple[pandas.DataFrame, pandas.DataFrame] + + :returns good_df: Rows where missing protein designations were successfully populated + :returns failed_df: Rows where protein lookup failed + """ + df = pd.read_excel(file_path).rename( + columns=rename_columns_proteomediscoverer_xlinkx_format + ) + + df["CL_position_within_peptide1"] = df["Peptide1"].apply( + get_amino_acid_where_crosslink_is_connected_proteomediscoverer_xlinkx_format + ) + df["CL_position_within_peptide2"] = df["Peptide2"].apply( + get_amino_acid_where_crosslink_is_connected_proteomediscoverer_xlinkx_format + ) + + df["Peptide1"] = df["Peptide1"].apply(remove_brackets_from_peptide).astype("string") + df["Peptide2"] = df["Peptide2"].apply(remove_brackets_from_peptide).astype("string") + + df["Is_intra_crosslink"] = df["Is_intra_crosslink"].eq("Intra") + + good_df, failed_df = get_missing_protein_designation( + df=df, + existing_column="Protein_id", + missing_column="Protein", + uniprot_lookup_function=get_gene_name_from_protein_ids, + ) + + return good_df, failed_df + + +def read_csm_file( + file_path: Path, organism_ids: list[str] +) -> tuple[pd.DataFrame, pd.DataFrame]: + """ + Read and process a CSM CSV file: + 1. Reads the CSV file and renames columns to a standard format. + 2. Determines intra-crosslinks by comparing Protein1 and Protein2. + 3. Normalizes gene names in the specified protein columns. + 4. Uses UniProt lookups to fill missing protein IDs, storing only the first protein ID + for each gene. + 5. Splits the resulting DataFrame into successful and failed lookups. + + :param file_path: Path to the CSM CSV file + :type file_path: pathlib.Path + :param organism_ids: list of organism identifiers used for UniProt lookups (e.g., "9606" for human) + :type organism_id: list[str] + + :return: Tuple of DataFrames containing rows with successfully mapped protein IDs + and rows where lookup failed + :rtype: tuple[pandas.DataFrame, pandas.DataFrame] + + :returns good_df: Rows where missing protein IDs were successfully populated + :returns failed_df: Rows where the UniProt lookup failed, including error messages + """ + df = pd.read_csv(file_path, low_memory=False).rename( + columns=rename_columns_csm_format + ) + + df["Is_intra_crosslink"] = df["Protein1"].eq(df["Protein2"]) + + uniprot_lookup_function_with_organism_ids = partial( + get_protein_ids_from_gene_name, organism_ids=organism_ids + ) + good_df, failed_df = get_missing_protein_designation( + df=df, + existing_column="Protein", + missing_column="Protein_id", + uniprot_lookup_function=uniprot_lookup_function_with_organism_ids, + ) + + return good_df, failed_df + + +def normalize_crosslinking_df(df: pd.DataFrame) -> pd.DataFrame: + df = df.astype( + { + "Protein1": "string", + "Protein2": "string", + "Protein_id1": "string", + "Protein_id2": "string", + "Is_intra_crosslink": "bool", + "Crosslinker": "string", + "Peptide1": "string", + "Peptide2": "string", + "CL_position_within_peptide1": "int", + "CL_position_within_peptide2": "int", + "Q_value": "Float64", + } + ) + return df.loc[:, columns_in_crosslinking_df] + + +def process_organism_id_from_text_field( + organism_ids: str, +) -> tuple[bool, Optional[list[str]], Optional[list[str]], Optional[str]]: + """ + Validates a comma-separated string of NCBI Taxonomy IDs. + Returns False immediately if any ID is invalid. + Otherwise returns True, the list of cleaned IDs, and the corresponding scientific names. + + param organism_ids: Comma-separated string of NCBI Taxonomy IDs (e.g., "9606,10090,10116") + :type organism_ids: str + + :return: A tuple containing: + - success (bool): True if all IDs are valid, False if any ID is invalid + - ids (list[str] | None): List of cleaned IDs in input order if successful, None if failed or the cause of the fail + - names (list[str] | None): List of scientific names corresponding to IDs if successful, None if failed + """ + organism_ids_list: list[str] = [ + id.strip() for id in organism_ids.split(",") if id.strip() + ] + if not organism_ids_list: + return False, None, None, "EMPTY_INPUT" + + organism_ids_for_request = ",".join(organism_ids_list) + url = f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=taxonomy&id={organism_ids_for_request}&retmode=json" + try: + response = requests.get(url, timeout=15) + if response.status_code != 200: + return False, None, None, "NCBI_TAXONOMY_REQUEST_FAILED" + data = response.json() + except requests.Timeout: + return False, None, None, "NCBI_TAXONOMY_TIMEOUT" + except Exception: + return False, None, None, "NCBI_TAXONOMY_SERVICE_UNAVAILABLE" + + result = data.get("result", {}) + if not result or "uids" not in result: + return False, None, None, "NCBI_TAXONOMY_RESPONSE_INVALID" + valid_organism_ids = result.get("uids", []) + organism_names = [] + + for id in organism_ids_list: + if id not in valid_organism_ids: + # Abort at the first invalid id + return False, id, None, "ORGANISM_ID_NOT_FOUND" + name = result[id].get("scientificname") + if not name: + return False, id, None, "MISSING_SCIENTIFIC_NAME" + organism_names.append(name) + + return True, organism_ids_list, organism_names, None + + +def aggregate_failed_proteins_for_display(failed_df: pd.DataFrame) -> str: + """ + Aggregate failed protein lookups into a human-readable string. + + For each row in `failed_df`, this function pairs protein values with their + corresponding error codes and returns a sorted, newline-separated string. + + The function checks for the presence of either "Protein1"/"Protein2" columns + or "Protein_id1"/"Protein_id2" columns to determine which values to process. + + :param failed_df: DataFrame containing rows with failed protein lookups + Must include columns for proteins and their error codes + :type failed_df: pandas.DataFrame + + :return: String summarizing all failed protein lookups in the format + "Protein_value -> ERROR_CODE", sorted alphabetically and separated by newlines + :rtype: str + """ + protein_with_error_set = set() + + if "Protein1" in failed_df.columns and "Protein2" in failed_df.columns: + protein_columns = ["Protein1", "Protein2"] + elif "Protein_id1" in failed_df.columns and "Protein_id2" in failed_df.columns: + protein_columns = ["Protein_id1", "Protein_id2"] + + error_columns = ["Protein1_error", "Protein2_error"] + + for protein_col, error_col in zip(protein_columns, error_columns): + for protein_val, error_val in zip(failed_df[protein_col], failed_df[error_col]): + if pd.notna(error_val): + protein_with_error_set.add(f"{protein_val} -> {error_val}") + + return "\n".join(sorted(protein_with_error_set)) + + +def error_output(msg, trace: str | None = None) -> dict: + return dict( + crosslinking_df=pd.DataFrame(), + imported_rows_with_errors_df=pd.DataFrame(), + messages=[ + dict( + level=logging.ERROR, + msg=msg, + trace=trace, + ) + ], + ) + + +def crosslinking_import(file_path: Path, organism_ids: str) -> dict: + file_type = file_path.suffix.lower() + try: + scientific_organism_names: list[str] = None + if file_type == ".csv": + success, organism_ids_list, scientific_organism_names, error = ( + process_organism_id_from_text_field(organism_ids) + ) + if not success: + if organism_ids_list: + msg = f"Unsupported organism id: {organism_ids_list}. \nOrganism id validation failed with error: {error}. \nPlease provide all valid taxonomy ids." + else: + msg = f"An error occurred while reading the organism ids: {error}. \nPlease provide all valid taxonomy ids, separated by a comma." + return error_output(msg) + good_df, failed_df = read_csm_file(file_path, organism_ids_list) + elif file_type == ".xlsx": + good_df, failed_df = read_ProteomeDiscoverer_XlinkX_file(file_path) + else: + raise ValueError(f"Unsupported file type: {file_path.suffix}") + except Exception as e: + msg = f"An error occurred while reading the file: {e.__class__.__name__} {e}. Please provide a valid crosslinking file." + return error_output(msg, trace=format_trace(traceback.format_exception(e))) + + def base_message(): + if file_type == ".csv": + organism_names_string = ", ".join(scientific_organism_names) + return ( + f"{len(good_df)} crosslinks for the {organism_names_string} organism(s)" + ) + return f"{len(good_df)} crosslinks" + + if good_df.empty: + msg = f"No crosslinks could be processed from this file. File was read successfully, but the data of {base_message()} could be imported." + messages = [dict(level=logging.ERROR, msg=msg)] + elif failed_df.empty: + msg = f"Successfully imported data of {base_message()}." + messages = [dict(level=logging.INFO, msg=msg)] + else: + msg = f"Warning: {len(failed_df)} rows failed to import, however {base_message()} were successfully imported." + messages = [ + dict(level=logging.WARNING, msg=msg), + dict( + level=logging.WARNING, + msg=f"Failed proteins:\n{aggregate_failed_proteins_for_display(failed_df)}", + ), + ] + + return dict( + crosslinking_df=good_df, + imported_rows_with_errors_df=failed_df, + messages=messages, + ) diff --git a/backend/protzilla/importing/import_utils.py b/backend/protzilla/importing/import_utils.py index 6b9fce095..b73cc135f 100644 --- a/backend/protzilla/importing/import_utils.py +++ b/backend/protzilla/importing/import_utils.py @@ -14,3 +14,36 @@ class AggregationMethods(Enum): sum = "Sum" median = "Median" mean = "Mean" + + +rename_columns_csm_format = { + "Crosslink Type": "Is_intra_crosslink", + "PepSeq1": "Peptide1", + "PepSeq2": "Peptide2", + "LinkPos1": "CL_position_within_peptide1", + "LinkPos2": "CL_position_within_peptide2", + "PEP": "Q_value", +} + +rename_columns_proteomediscoverer_xlinkx_format = { + "Accession A": "Protein_id1", + "Accession B": "Protein_id2", + "Crosslink Type": "Is_intra_crosslink", + "Sequence A": "Peptide1", + "Sequence B": "Peptide2", + "Q-value": "Q_value", +} + +columns_in_crosslinking_df = [ + "Protein1", + "Protein2", + "Protein_id1", + "Protein_id2", + "Is_intra_crosslink", + "Crosslinker", + "Peptide1", + "Peptide2", + "CL_position_within_peptide1", + "CL_position_within_peptide2", + "Q_value", +] diff --git a/backend/protzilla/importing/query_generation.py b/backend/protzilla/importing/query_generation.py new file mode 100644 index 000000000..32e43c755 --- /dev/null +++ b/backend/protzilla/importing/query_generation.py @@ -0,0 +1,107 @@ +import json +import logging +import requests + +from backend.protzilla.steps import OutputItem, OutputType + + +def generate_alphafold_query_json( + protein_ids: str, number_copies: str, model_seed: int, name: str +) -> dict: + """ + Generates an AlphaFold JSON query for a set of UniProt protein IDs. + For each provided UniProt ID, the corresponding amino acid sequence is fetched + from the UniProt REST API and added to the query with the specified copy number. + Format of the json is as defined here: https://github.com/google-deepmind/alphafold/blob/main/server/README.md + + Protein IDs and copy numbers must be provided as space- or comma-separated strings and + must have the same length. If an invalid copy number is provided or if the + lengths do not match, an error message is generated and an exception may be raised. + + :param protein_ids: Space- or comma-separated list of UniProt protein IDs (e.g. "P69905 P68871"). + :param number_copies: Space- or comma-separated list of integers specifying the number of copies + for each protein ID (e.g. "2 2"). + :param model_seed: Model seed for the AlphaFold query. If -1 we want AlphaFold to use a random seed. + :param name: How the AlphaFold job and the generated file should be named. + :return: dict (messages, downloads), downloads contains a dictionary mapping a generated filename + to the AlphaFold query JSON string (wrapped in square brackets as required by AlphaFold server) + :raises ValueError: If the number of copies or the model seeds cannot be parsed as integers. + :raises requests.exceptions.HTTPError: If fetching a UniProt FASTA sequence fails. + """ + messages = [] + + # extract protein_ids and number of copies per id and make sure they have the same length + uniprot_ids = protein_ids.replace(",", " ").split() + try: + copies_per_id = [ + int(input) for input in number_copies.replace(",", " ").split() + ] + except ValueError as e: + msg = f"Invalid list of number of copies per id: please provide space-separated integers" + messages.append( + dict( + level=logging.ERROR, + msg=msg, + ) + ) + raise ValueError(msg) + if len(uniprot_ids) != len(copies_per_id): + msg = f"There are {len(uniprot_ids)} ids. However, there are {len(copies_per_id)} entries for number of copies. Please make sure that these numbers match." + messages.append( + dict( + level=logging.ERROR, + msg=msg, + ) + ) + raise ValueError(msg) + if min(copies_per_id) < 1: + msg = f"There can't be a non-positive number of copies." + messages.append( + dict( + level=logging.ERROR, + msg=msg, + ) + ) + raise ValueError(msg) + + # create the json query for alphafold + query = { + "name": name, + "modelSeeds": [], + "sequences": [], + "dialect": "alphafoldserver", + "version": 1, + } + + if model_seed != -1: + query["modelSeeds"] = [model_seed] + + for uniprot_id, copies in zip(uniprot_ids, copies_per_id): + url = f"https://rest.uniprot.org/uniprotkb/{uniprot_id}.fasta" + + response = requests.get(url, timeout=20) + response.raise_for_status() + + fasta = response.text + amino_acid_sequence = "".join( + line.strip() for line in fasta.splitlines() if not line.startswith(">") + ) + query["sequences"].append( + { + "proteinChain": { + "sequence": amino_acid_sequence, + "count": copies, + } + } + ) + messages.append( + dict( + level=logging.INFO, msg=f"Successfully generated a json file for AlphaFold." + ) + ) + return dict( + messages=messages, + downloads=OutputItem( + output_type=OutputType.DOWNLOAD, value={f"{name}.json": [query]} + ), + ) diff --git a/backend/protzilla/methods/data_analysis.py b/backend/protzilla/methods/data_analysis.py index 3943bb850..bab8043f1 100644 --- a/backend/protzilla/methods/data_analysis.py +++ b/backend/protzilla/methods/data_analysis.py @@ -1,7 +1,9 @@ from abc import ABC from typing_extensions import override +import ast from backend.protzilla.constants.option_types import ( + CrosslinkingValidationCriterion, LogBaseWithNoneType, SimpleImputerStrategyType, ) @@ -69,6 +71,7 @@ MultiSelectField, NumberField, TextField, + FormDivider, ) from backend.protzilla.steps import Step, Section, StepOperation from backend.protzilla.step_manager import StepManager @@ -88,6 +91,12 @@ create_overview_ptm_visualization, get_detected_modifications, ) +from backend.protzilla.data_analysis.crosslinking_validation import ( + monomer_diagrams, + multimer_diagrams, + monomer_validation, + multimer_validation, +) class TTestType(Enum): @@ -2350,3 +2359,146 @@ def create_form(self): label="PTM Details Visualization", input_fields=_PTMVisualizationWithGroups.get_form_fields(), ) + + +class CrosslinkingValidationWithAngstromStep(DataAnalysisStep): + output_keys = ["crosslinking_result_df"] + internal_inputs = {"crosslinker_information"} + + def _get_crosslinker_names_from_crosslinker_df( + self, steps: StepManager + ) -> list[str]: + df = self.get_input(steps, DataKey.CROSSLINKING_DF) + if df is None or "Crosslinker" not in df.columns: + return [] + crosslinkers = df["Crosslinker"].dropna().unique() + return list(crosslinkers) + + def create_crosslink_input_fields(self, form: Form, run: Run): + crosslinkers = self._get_crosslinker_names_from_crosslinker_df(run.steps) + for crosslinker in crosslinkers: + field_name = f"{crosslinker}_length" + if field_name not in form: + cl_defaults = ( + run.disk_operator.defaults.read_default("crosslinker_lengths") or {} + ) + specific_cl_defaults = cl_defaults.get(crosslinker, {}) + if specific_cl_defaults: + length_default = specific_cl_defaults.get("cl_length") + upper_deviation_default = specific_cl_defaults.get( + "cl_upper_deviation" + ) + lower_deviation_default = specific_cl_defaults.get( + "cl_lower_deviation" + ) + else: + length_default = 0 + upper_deviation_default = 0 + lower_deviation_default = 0 + crosslinker_length_field = FloatField( + name=field_name, + label=f"Length of {crosslinker} in Ångström", + min=0, + value=length_default, + ) + upper_bound_length_deviation_field = FloatField( + name=f"{crosslinker}_upper_accepted_deviation", + label=f"Upper bound on the accepted deviation for {crosslinker} Crosslinks in Ångström (0 equals no bound)", + min=0, + value=upper_deviation_default, + ) + lower_bound_length_deviation_field = FloatField( + name=f"{crosslinker}_lower_accepted_deviation", + label=f"Lower bound on the accepted deviation for {crosslinker} Crosslinks in Ångström (0 equals no bound)", + min=0, + value=lower_deviation_default, + ) + form.add_field(crosslinker_length_field) + form.add_field(upper_bound_length_deviation_field) + form.add_field(lower_bound_length_deviation_field) + + bounds_visible = ( + form["validation_criterion"].value + == CrosslinkingValidationCriterion.manual_bounds.value + ) + form[f"{crosslinker}_upper_accepted_deviation"].isVisible = bounds_visible + form[f"{crosslinker}_lower_accepted_deviation"].isVisible = bounds_visible + + def collect_crosslinking_information(self, steps: StepManager, inputs) -> dict: + # although crosslinker_information is not a dataframe we need to insert the user information regarding the crosslinks as a dictionary into the inputs + crosslinker_to_length_and_deviation = {} + for crosslinker in self._get_crosslinker_names_from_crosslinker_df(steps): + crosslinker_to_length_and_deviation[crosslinker] = [ + inputs.get(f"{crosslinker}_length"), + inputs.get(f"{crosslinker}_upper_accepted_deviation"), + inputs.get(f"{crosslinker}_lower_accepted_deviation"), + ] + return crosslinker_to_length_and_deviation + + def modify_form(self, run: Run) -> None: + # create input fields for every crosslink + self.create_crosslink_input_fields(form=self.form, run=run) + + def insert_dataframes(self, steps: StepManager) -> None: + super().insert_dataframes(steps) + self.inputs["crosslinker_information"] = self.collect_crosslinking_information( + steps=steps, inputs=self.form_inputs + ) + + +class CrosslinkingValidationWithAngstromDeviation( + CrosslinkingValidationWithAngstromStep +): + display_name = "Ångström Deviation For Monomer Structures" + operation = "Crosslinking Validation" + method_description = "Validates crosslinks within the one protein structure based on the difference between the length of the crosslinker and the distance between the amino acids which were connected by the crosslinker. (in Ångström)" + calc_method = staticmethod(monomer_validation) + plot_method = staticmethod(monomer_diagrams) + + def create_form(self): + return Form( + label="Ångström Deviation - Monomer", + input_fields=[ + DropdownField( + name="validation_criterion", + label="Validation criterion", + options=CrosslinkingValidationCriterion, + value=CrosslinkingValidationCriterion.manual_bounds, + ), + FormDivider( + label="Crosslinker lengths and bounds", + ), + InfoField( + label="Set default crosslink lengths and their upper/lower deviations in settings under 'Crosslinks Defaults'.", + ), + ], + ) + + +class CrosslinkingValidationWithAngstromDeviationForMultimer( + CrosslinkingValidationWithAngstromStep +): + display_name = "Ångström Deviation For Multimer Structures" + operation = "Crosslinking Validation" + method_description = "Validates crosslinks between proteins based on the difference between the length of the crosslinker and the distance between the amino acids which were connected by the crosslinker. (in Ångström)" + calc_method = staticmethod(multimer_validation) + plot_method = staticmethod(multimer_diagrams) + + def create_form(self): + return Form( + label="Ångström Deviation - Multimer", + input_fields=[ + DropdownField( + name="validation_criterion", + label="Validation criterion", + options=CrosslinkingValidationCriterion, + value=CrosslinkingValidationCriterion.manual_bounds, + ), + FormDivider( + label="Crosslinker lengths and bounds", + ), + InfoField( + label="Set default crosslink lengths and their upper/lower deviations in settings under 'Crosslinks Defaults'.", + ), + ], + ) diff --git a/backend/protzilla/methods/importing.py b/backend/protzilla/methods/importing.py index 3e3ee51fb..c3f9593cb 100644 --- a/backend/protzilla/methods/importing.py +++ b/backend/protzilla/methods/importing.py @@ -2,6 +2,10 @@ from abc import ABC from typing_extensions import override +import pandas as pd + +from backend.protzilla.form import * +from backend.protzilla import form_helper from backend.protzilla.constants.data_types import DataKey from backend.protzilla.form import ( CheckboxField, @@ -22,9 +26,18 @@ max_quant_import, ms_fragger_import, ) +from backend.protzilla.importing.alphafold_protein_structure_load import ( + fetch_alphafold_protein_structure, + get_all_available_entry_ids_of_monomer_metadata, + get_all_available_entry_ids_of_multimer_metadata, + get_monomer_structure_dfs, + upload_multimer_prediction, + get_multimer_structure_dfs, +) from backend.protzilla.importing.peptide_import import peptide_import, evidence_import -from backend.protzilla.run import Run from backend.protzilla.steps import Step, Section, StepOperation +from backend.protzilla.importing.crosslinking_import import crosslinking_import +from backend.protzilla.run import Run from backend.protzilla.importing.example_dataset_import import example_dataset_import from backend.protzilla.importing.fasta_import import fasta_import from backend.protzilla.importing.import_utils import ( @@ -32,6 +45,7 @@ FeatureOrientationType, ) from backend.protzilla.constants.intensity_types import IntensityType, IntensityNameType +from backend.protzilla.importing.query_generation import generate_alphafold_query_json class ImportingStep(Step, ABC): @@ -39,14 +53,9 @@ class ImportingStep(Step, ABC): def modify_form(self, run: Run): if run.steps.current_step.calculation_status == "complete": - self.form.input_fields[self.index_of_file_input()].value = None - - def index_of_file_input(self): - """ - Returns the index of the FileInput that should be reset by modify_form. This method - must be overridden if the FileInput is not index 0. - """ - return 0 + for field in self.form.input_fields: + if isinstance(field, FileInput): + field.value = None class ArbitraryCSVImport(ImportingStep): @@ -419,3 +428,265 @@ def modify_form(self, run: Run) -> None: if import_peptide_data_field.value else [DataKey.METADATA_DF, DataKey.PROTEIN_DF] ) + + +class AlphaFoldPredictionLoad(ImportingStep): + display_name = "AlphaFold DB Monomer Prediction Load" + operation = "Monomer Structure Import" + method_description = "Loads the predicted structure of the monomer with the given protein ID out of the AlphaFold DB." + + output_keys = [ + DataKey.STRUCTURE_METADATA_DF, + DataKey.CIF_DF, + DataKey.PLDDT_DF, + DataKey.AMINO_ACID_SEQUENCES_DF, + DataKey.PAE_MATRIX, + ] + + plot_method = None + + def create_form(self): + return Form( + label="AlphaFold DB Monomer Prediction Load", + input_fields=[ + TextField( + name="uniprot_id", + label="Protein ID", + ), + CheckboxField( + name="persist_upload", + label="Upload should be saved persistently across runs", + value=True, + ), + ], + ) + + calc_method = staticmethod(fetch_alphafold_protein_structure) + + +class CrosslinkingImport(ImportingStep): + display_name = "Crosslinking Data Import" + operation = "Crosslinking Data Import" + method_description = "Import a file containing crosslinking data" + + output_keys = [DataKey.CROSSLINKING_DF] + + def create_form(self): + return Form( + label="Crosslinking Data Import", + input_fields=[ + FileInput( + name="file_path", + label="Crosslinking Data file (.xlsx or .csv)", + value=None, + accept=".xlsx,.csv", + ), + TextField( + name="organism_ids", + label="Organism IDs \n(only required when importing a CSM file)", + value="", + ), + InfoField( + label="Please list them in the order in which they should be applied, separated by a comma \n e.g.: 9606, 10090, 10116" + ), + ], + ) + + calc_method = staticmethod(crosslinking_import) + + +class ImportMonomerStructurePredictionFromDisk(ImportingStep): + display_name = "Monomer Structure Prediction Import from Disk" + operation = "Monomer Structure Import" + method_description = "Load an already uploaded monomer structure prediction from disk into current run" + + output_keys = [ + DataKey.STRUCTURE_METADATA_DF, + DataKey.CIF_DF, + DataKey.PAE_MATRIX, + DataKey.PLDDT_DF, + DataKey.AMINO_ACID_SEQUENCES_DF, + ] + + def create_form(self): + return Form( + label="Monomer Structure Predictions Import from Disk", + input_fields=[ + DropdownField( + name="entry_id", + label="Entry ID of the monomer prediction to be loaded into the run. (Unless specified otherwise this is the Protein ID)", + ) + ], + ) + + def modify_form(self, run: Run): + entry_id_field = self.form["entry_id"] + entry_id_field.set_options( + form_helper.to_choices(get_all_available_entry_ids_of_monomer_metadata()) + ) + + calc_method = staticmethod(get_monomer_structure_dfs) + + +class UploadMultimerPredictions(ImportingStep): + display_name = "Multimer Structure Prediction Upload" + operation = "Multimer Structure Import" + method_description = "Upload a multimer protein prediction" + + output_keys = [ + DataKey.STRUCTURE_METADATA_DF, + DataKey.CIF_DF, + DataKey.CONFIDENCE_DF, + DataKey.FULL_DATA_DF, + DataKey.JOB_REQUEST_DF, + DataKey.AMINO_ACID_SEQUENCES_DF, + DataKey.PAE_MATRIX, + DataKey.PLDDT_DF, + ] + + def create_form(self): + return Form( + label="Multimer Structure Prediction Upload", + input_fields=[ + TextField( + name="entry_id", + label="Entry ID of the prediction to be loaded into the run. (required)", + ), + InfoField( + label="The entry ID should be a unique name given to the uploaded prediction.", + ), + TextField( + name="uniprot_ids", + label="Protein IDs of all proteins used in the sequence. (required)", + ), + InfoField( + label="Please provide a list of Protein IDs separated by a comma \n e.g.: P68871, P69905, Q5VSL9." + ), + TextField( + name="model_used", + label="The AlphaFold Model used to predict the structure.", + ), + FileInput( + name="amino_acid_sequences", + label="Amino acid sequences of proteins in the prediction (required)", + value=None, + accept=".fasta,.fa,.faa", + ), + FileInput( + name="cif_file", + label="CIF file (required)", + value=None, + accept=".cif,.mmcif", + ), + FileInput( + name="confidence_file", + label="Confidence summary json file (required)", + value=None, + accept=".json", + ), + FileInput( + name="full_data_file", + label="Full data json file (required)", + value=None, + accept=".json", + ), + FileInput( + name="job_request_file", + label="Job request json file (required)", + value=None, + accept=".json", + ), + CheckboxField( + name="persist_upload", + label="Upload should be saved persistently across runs", + value=True, + ), + ], + ) + + calc_method = staticmethod(upload_multimer_prediction) + + +class ImportMultimerStructurePredictionFromDisk(ImportingStep): + display_name = "Multimer Structure Prediction Import from Disk" + operation = "Multimer Structure Import" + method_description = "Load an already uploaded multimer structure prediction from disk into current run" + + output_keys = [ + DataKey.STRUCTURE_METADATA_DF, + DataKey.CIF_DF, + DataKey.CONFIDENCE_DF, + DataKey.FULL_DATA_DF, + DataKey.JOB_REQUEST_DF, + DataKey.AMINO_ACID_SEQUENCES_DF, + DataKey.PAE_MATRIX, + DataKey.PLDDT_DF, + ] + + def create_form(self): + return Form( + label="Multimer Structure Predictions Import from Disk", + input_fields=[ + DropdownField( + name="entry_id", + label="Entry ID of the multimer prediction to be loaded into the run.", + ) + ], + ) + + def modify_form(self, run: Run): + entry_id_field = self.form["entry_id"] + entry_id_field.set_options( + form_helper.to_choices(get_all_available_entry_ids_of_multimer_metadata()) + ) + + calc_method = staticmethod(get_multimer_structure_dfs) + + +class AlphaFoldQueryJsonGeneration(Step): + section = "importing" + display_name = "AlphaFold Query JSON Generation" + operation = "Query Generation" + method_description = ( + "Generate a JSON to upload to AlphaFold-Server to generate a prediction." + ) + + def create_form(self): + return Form( + label="AlphaFold Query JSON Generation", + input_fields=[ + TextField( + name="name", + label="File name and AlphaFold job name for generated query", + ), + InfoField( + label="Only enter file stem, '.json' will be added automatically." + ), + TextField( + name="protein_ids", + label="UniProt Protein IDs", + ), + InfoField(label="IDs should be space- or comma-separated."), + TextField( + name="number_copies", + label="Number of copies of each protein monomer", + ), + InfoField( + label="For each entered ID a number should be entered.\n" + "Numbers should be space- or comma-separated." + ), + NumberField( + name="model_seed", + label="Model seed for AlphaFold", + min=-1, + max=4294967295, + value=-1, + ), + InfoField( + label="Leave -1 if you want to use a random seed.\n" + "Otherwise enter a seed (integer between 0 and 4294967295)" + ), + ], + ) + + calc_method = staticmethod(generate_alphafold_query_json) diff --git a/backend/protzilla/networking.py b/backend/protzilla/networking.py new file mode 100644 index 000000000..b91eb8d11 --- /dev/null +++ b/backend/protzilla/networking.py @@ -0,0 +1,32 @@ +import requests +from pathlib import Path +from backend.protzilla.constants.protzilla_logging import logger + + +def download_file_from_url(url: str, dest: Path) -> Path | None: + """ + Download a file from a URL and save it to the specified destination path. + + :param url: The URL of the file to download + :param dest: The destination path where the file should be saved + :return: The destination path if successful, None otherwise + """ + with requests.Session() as session: + + r = session.get(url, timeout=30) + r.raise_for_status() + + try: + dest.parent.mkdir(parents=True, exist_ok=True) + with open(dest, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + logger.info("Downloaded %s -> %s", url, dest) + return dest + except requests.RequestException: + logger.exception("Failed to download %s", url) + return None + except OSError: + logger.exception("Failed to write file %s", dest) + return None diff --git a/backend/protzilla/steps.py b/backend/protzilla/steps.py index 8132be0fd..a37c593a8 100644 --- a/backend/protzilla/steps.py +++ b/backend/protzilla/steps.py @@ -4,8 +4,10 @@ import inspect import logging import traceback -from enum import StrEnum -from typing import Any, Literal +from enum import Enum, StrEnum +from pathlib import Path +from types import MethodType +from typing import Any, Literal, Callable import pandas as pd import yaml @@ -115,7 +117,7 @@ def __init__( self, instance_identifier: StepID | None = None, ): - self.inputs: dict[DataKey, pd.DataFrame | FormInputType] = {} + self.inputs: dict[DataKey | str, pd.DataFrame | FormInputType] = {} self.output: Output = Output() self.visual_data = {"node_position": {"x": 0, "y": 0}} self.plots: Plots = Plots() @@ -154,7 +156,7 @@ def __eq__(self, other): ) def get_form_values(self) -> None: - self.inputs = self.form_inputs.copy() + self.inputs |= self.form_inputs.copy() @classmethod def to_dict(cls): @@ -399,49 +401,46 @@ def handle_messages(self, outputs: dict) -> None: calc_method = None plot_method = None # if the plot method uses the output of the calculation method, it should be prefixed with "output_" - @property - def calculation_input(self) -> dict: - input_parameters = inspect.signature(self.calc_method).parameters + def _get_input_parameters( + self, function: Callable[..., Any], relevant_inputs: dict | None = None + ) -> dict: + if relevant_inputs is None: + relevant_inputs = self.inputs + input_parameters = inspect.signature(function).parameters required_keys = [ key for key, param in input_parameters.items() if param.default == inspect.Parameter.empty ] for key in required_keys: - if key not in self.inputs: + if key not in relevant_inputs: raise ValueError( - f"Missing required input '{key}' for the calculation method" + f"Missing required input '{key}' for the '{function.__name__}' method" ) return { # if there is a default value, we want to use it key: ( - self.inputs.get(key, param.default) + relevant_inputs.get(key, param.default) if param.default != inspect.Parameter.empty - else self.inputs.get(key) + else relevant_inputs.get(key) ) for key, param in input_parameters.items() + if key in relevant_inputs } + @property + def calculation_input(self) -> dict: + return self._get_input_parameters(self.calc_method) + @property def plot_input(self) -> dict: # if the plot method uses the output of the calculation method, it should be prefixed with "output_" prefixed_output = {"output_" + key: item.value for key, item in self.output} plot_input = self.inputs | prefixed_output - - input_parameters = inspect.signature(self.plot_method).parameters - required_keys = [ - key - for key, param in input_parameters.items() - if param.default == inspect.Parameter.empty - ] - for key in required_keys: - if key not in plot_input: - raise ValueError(f"Missing required input '{key}' for the plot method") - - return { - key: plot_input[key] for key in input_parameters.keys() if key in plot_input - } + return self._get_input_parameters( + function=self.plot_method, relevant_inputs=plot_input + ) def validate_outputs(self, soft_check: bool = False) -> bool: """ @@ -534,6 +533,8 @@ class OutputType(StrEnum): FLOAT = "float" INT = "int" PNG_BASE64 = "png_base64" + DOWNLOAD = "download" # right now only JSONs are supported, value should be dict(filename, json content) + VISUALIZATION = "visualization" # for every data type that is not yaml serializable JOBLIB_ARTIFACT = "joblib_artifact" diff --git a/backend/protzilla/utilities/utilities.py b/backend/protzilla/utilities/utilities.py index 58d7f4966..4244b0506 100644 --- a/backend/protzilla/utilities/utilities.py +++ b/backend/protzilla/utilities/utilities.py @@ -3,6 +3,7 @@ import operator import os import re +import shutil from itertools import groupby from pathlib import Path from random import choices @@ -12,6 +13,7 @@ import psutil from backend.protzilla.constants.intensity_types import IntensityType, IntensityNameType +from backend.protzilla.constants.protzilla_logging import logger # recipie from https://docs.python.org/3/library/itertools.html @@ -153,3 +155,39 @@ def lerp(start: float, end: float, interpolation_factor: float) -> float: :param interpolation_factor: interpolation factor (between 0 and 1) """ return (1 - interpolation_factor) * start + interpolation_factor * end + + +def copy_file_to_directory(source_file: Path, dest_dir: Path) -> tuple[bool, str]: + """ + Copy a single file to a destination directory. + Creates the destination directory if it doesn't exist. + + :param source_file: Path to the source file + :param dest_dir: Path to the destination directory + :return: Tuple of (success: bool, message: str) + """ + + if not source_file.exists(): + message = f"Source file does not exist: {source_file}" + logger.error(message) + return False, message + + if not source_file.is_file(): + message = f"Source path is not a file: {source_file}" + logger.error(message) + return False, message + + try: + dest_dir.mkdir(parents=True, exist_ok=True) + dest_file = dest_dir / source_file.name + + shutil.copy2(source_file, dest_file) + + message = f"Successfully copied file {source_file} to {dest_dir}" + logger.info(message) + return True, message + + except OSError as e: + message = f"Failed to copy file: {str(e)}" + logger.error(message) + return False, message diff --git a/backend/tests/main/test_views_helper.py b/backend/tests/main/test_views_helper.py index 0cee43cdb..b8e38d9cd 100644 --- a/backend/tests/main/test_views_helper.py +++ b/backend/tests/main/test_views_helper.py @@ -12,6 +12,12 @@ def test_get_all_possible_step_names(): "EvidenceImport", "ExampleDatasetImport", "FastaImport", + "AlphaFoldPredictionLoad", + "CrosslinkingImport", + "AlphaFoldQueryJsonGeneration", + "ImportMonomerStructurePredictionFromDisk", + "UploadMultimerPredictions", + "ImportMultimerStructurePredictionFromDisk", "FilterProteinsBySamplesMissing", "FilterProteinsByNumberOfValuesPerGroup", "FilterProteinsByProteinIDs", @@ -84,6 +90,8 @@ def test_get_all_possible_step_names(): "PlotGSEADotPlot", "PlotGSEAEnrichmentPlot", "ArbitraryCSVImport", + "CrosslinkingValidationWithAngstromDeviation", + "CrosslinkingValidationWithAngstromDeviationForMultimer", } steps = get_all_possible_steps() diff --git a/backend/tests/main/test_views_settings.py b/backend/tests/main/test_views_settings.py index e69de29bb..b8eca6449 100644 --- a/backend/tests/main/test_views_settings.py +++ b/backend/tests/main/test_views_settings.py @@ -0,0 +1,137 @@ +import json +import pytest +from unittest import mock +from django.http import JsonResponse + +from backend.main.views_settings import ( + get_cl_defaults, + update_cl_default, + delete_cl_default, +) + +import os +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.main.settings") +if not django.apps.apps.ready: + django.setup() + +PATCH_PATH = "backend.main.views_settings.DefaultsOperator" + + +def test_get_cl_defaults(monkeypatch): + request = mock.Mock() + request.method = "GET" + + mock_defaults_operator = mock.Mock() + mock_defaults_operator.read_default.return_value = { + "DSSO": { + "cl_length": 10.3, + "cl_upper_deviation": 1.0, + "cl_lower_deviation": 1.0, + } + } + monkeypatch.setattr(PATCH_PATH, lambda: mock_defaults_operator) + + response = get_cl_defaults(request) + + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == { + "DSSO": { + "cl_length": 10.3, + "cl_upper_deviation": 1.0, + "cl_lower_deviation": 1.0, + } + } + + +def test_update_cl_default_success(monkeypatch): + payload = { + "cl_name": "DSSO", + "cl_length": 10.3, + "cl_upper_deviation": 1.0, + "cl_lower_deviation": 1.2, + } + + request = mock.Mock() + request.method = "POST" + request.body = json.dumps(payload).encode("utf-8") + + mock_defaults_operator = mock.Mock() + mock_defaults_operator.read_default.return_value = {} + monkeypatch.setattr(PATCH_PATH, lambda: mock_defaults_operator) + + response = update_cl_default(request) + + mock_defaults_operator.write_default.assert_called_once_with( + name="crosslinker_lengths", + value={ + "DSSO": { + "cl_length": 10.3, + "cl_upper_deviation": 1.0, + "cl_lower_deviation": 1.2, + } + }, + ) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["success"] is True + + +def test_update_cl_default_exception(monkeypatch): + payload = {"cl_name": "DSSO", "cl_length": 10.3} + + request = mock.Mock() + request.method = "POST" + request.body = json.dumps(payload).encode("utf-8") + + mock_defaults_operator = mock.Mock() + mock_defaults_operator.write_default.side_effect = Exception("Disk write failed") + monkeypatch.setattr(PATCH_PATH, lambda: mock_defaults_operator) + + response = update_cl_default(request) + + assert response.status_code == 405 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["success"] is False + + +def test_delete_cl_default_success(monkeypatch): + payload = {"cl_name": "DSSO"} + + request = mock.Mock() + request.method = "POST" + request.body = json.dumps(payload).encode("utf-8") + + mock_defaults_operator = mock.Mock() + mock_defaults_operator.read_default.return_value = {"DSSO": {"cl_length": 10.3}} + monkeypatch.setattr(PATCH_PATH, lambda: mock_defaults_operator) + + response = delete_cl_default(request) + + mock_defaults_operator.write_default.assert_called_once_with( + name="crosslinker_lengths", value={} + ) + + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["success"] is True + + +def test_delete_cl_default_exception(monkeypatch): + payload = {"cl_name": "DSSO"} + + request = mock.Mock() + request.method = "POST" + request.body = json.dumps(payload).encode("utf-8") + + mock_defaults_operator = mock.Mock() + mock_defaults_operator.delete_default.side_effect = Exception("Disk delete failed") + monkeypatch.setattr(PATCH_PATH, lambda: mock_defaults_operator) + + response = delete_cl_default(request) + + assert response.status_code == 405 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["success"] is False diff --git a/backend/tests/protzilla/data_analysis/ptm_visualization/test_ptm_visualization.py b/backend/tests/protzilla/data_analysis/ptm_visualization/test_ptm_visualization.py index 47d9f6e5e..ac686f8dd 100644 --- a/backend/tests/protzilla/data_analysis/ptm_visualization/test_ptm_visualization.py +++ b/backend/tests/protzilla/data_analysis/ptm_visualization/test_ptm_visualization.py @@ -9,19 +9,19 @@ get_detected_modifications, create_overview_ptm_visualization, ) -from protzilla.data_analysis.ptm_visualization.ptm_bar_plot import ( +from backend.protzilla.data_analysis.ptm_visualization.ptm_bar_plot import ( create_bar_ptm_visualization, ) -from protzilla.data_analysis.ptm_visualization.ptm_details_plot import ( +from backend.protzilla.data_analysis.ptm_visualization.ptm_details_plot import ( create_details_ptm_visualization, ) -from tests.paths import ( +from backend.tests.paths import ( TEST_PTM_VISUALIZATION_PATH, TEST_FASTA_PATH, TEST_PEPTIDES_PATH, TEST_METADATA_PATH, ) -from tests.protzilla.data_analysis.ptm_visualization.ptm_vis_test_utils import ( +from backend.tests.protzilla.data_analysis.ptm_visualization.ptm_vis_test_utils import ( get_evidence_df, get_metadata_df, mock_settings_file, diff --git a/backend/tests/protzilla/data_analysis/test_clustergram.py b/backend/tests/protzilla/data_analysis/test_clustergram.py index 8c7008cfa..33739f7e2 100644 --- a/backend/tests/protzilla/data_analysis/test_clustergram.py +++ b/backend/tests/protzilla/data_analysis/test_clustergram.py @@ -1,5 +1,6 @@ from backend.protzilla.data_analysis.plots import * from backend.tests.protzilla.data_analysis.test_clustering import * +from backend.protzilla.data_preprocessing.plots import create_histograms @pytest.fixture diff --git a/backend/tests/protzilla/data_analysis/test_crosslinking_validation.py b/backend/tests/protzilla/data_analysis/test_crosslinking_validation.py new file mode 100644 index 000000000..0e815a17d --- /dev/null +++ b/backend/tests/protzilla/data_analysis/test_crosslinking_validation.py @@ -0,0 +1,1353 @@ +import pandas as pd +from backend.protzilla.constants.option_types import CrosslinkingValidationCriterion +import pytest +import logging +from unittest.mock import patch, MagicMock +import plotly.graph_objects as go +from plotly.graph_objects import Figure +import pandas.testing as pdt +import numpy as np + + +from backend.protzilla.data_analysis.crosslinking_validation import ( + validate_with_angstrom_deviation, + get_distance_between_two_amino_acids_in_angstrom, + add_protein_crosslink_positions_to_df, + diagrams_of_crosslinking_validation_data, + expand_crosslinks_to_chain_combinations, + get_chains, +) +from backend.protzilla.constants.colors import PLOT_PRIMARY_COLOR +from backend.protzilla.data_analysis.plots import ( + add_vertical_line_with_annotation_in_legend, +) +from backend.protzilla.form import Form + +from backend.protzilla.methods.data_analysis import ( + CrosslinkingValidationWithAngstromDeviation, +) + + +@pytest.mark.parametrize( + "distance, expected", + [ + (3.99, False), # outside bounds + (4.0, True), # lower bound + (6.0, True), # upper bound + (6.01, False), # outside bounds + ], +) +def test_monomer_validation_baseline_manual_bounds(distance, expected): + crosslinker_information = {"DSS": [5.0, 1.0, 1.0]} # Length 5 Å ± 1 Å + + # Fake AlphaFold Data with chain IDs + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA", "CA"], + "_atom_site.label_asym_id": ["A", "A"], + "_atom_site.label_seq_id": [1, 2], + "_atom_site.Cartn_x": [0, distance], + "_atom_site.Cartn_y": [0, 0], + "_atom_site.Cartn_z": [0, 0], + "_atom_site.auth_asym_id": ["A", "A"], + "_atom_site.pdbx_sifts_xref_db_acc": ["P12345", "P12345"], + } + ) + + amino_acid_sequences_df = pd.DataFrame( + {"Protein ID": ["P12345-1"], "Protein Sequence": ["AB"]} + ) + + # Fake Crosslink Data + crosslinking_df = pd.DataFrame( + { + "Protein_id1": ["P12345"], + "Protein_id2": ["P12345"], + "Peptide1": ["A"], + "Peptide2": ["B"], + "CL_position_within_peptide1": [0], + "CL_position_within_peptide2": [0], + "Crosslinker": ["DSS"], + } + ) + + structure_metadata_df = pd.DataFrame( + {"entry_id": ["test"], "uniprot_accession": ["P12345"]} + ) + + valid_ids = {"P12345": ["P12345"]} + structures_to_validate = ["P12345"] + + result = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + structure_metadata_df=structure_metadata_df, + crosslinker_information=crosslinker_information, + cif_df=cif_df, + amino_acid_sequences_df=amino_acid_sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.pdbx_sifts_xref_db_acc", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.manual_bounds.value, + ) + + df: pd.DataFrame = result["crosslinking_result_df"] + + assert "alphafold_distance" in df.columns + assert "valid_crosslink" in df.columns + assert "link_type" in df.columns + assert "Chain_id1" in df.columns + assert "Chain_id2" in df.columns + assert df.loc[0, "alphafold_distance"] == distance + assert df.loc[0, "valid_crosslink"] == expected + assert df.loc[0, "link_type"] == "intra" + + +@pytest.mark.parametrize( + "distance, expected", + [ + (4.99, False), + (5.0, True), + (5.1, False), + ], +) +def test_cl_validation_pae_noerrror(distance, expected): + crosslinker_information = {"DSS": [5.0, 1.0, 1.0]} # Length 5 Å (± 1 Å) + pae_matrix = np.array([[np.nan, 0], [0, np.nan]]) + + # Fake AlphaFold Data with chain IDs + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA", "CA"], + "_atom_site.label_asym_id": ["A", "A"], + "_atom_site.label_seq_id": [1, 2], + "_atom_site.Cartn_x": [0, distance], + "_atom_site.Cartn_y": [0, 0], + "_atom_site.Cartn_z": [0, 0], + "_atom_site.auth_asym_id": ["A", "A"], + "_atom_site.pdbx_sifts_xref_db_acc": ["P12345", "P12345"], + } + ) + + amino_acid_sequences_df = pd.DataFrame( + {"Protein ID": ["P12345-1"], "Protein Sequence": ["AB"]} + ) + + # Fake Crosslink Data + crosslinking_df = pd.DataFrame( + { + "Protein_id1": ["P12345"], + "Protein_id2": ["P12345"], + "Peptide1": ["A"], + "Peptide2": ["B"], + "CL_position_within_peptide1": [0], + "CL_position_within_peptide2": [0], + "Crosslinker": ["DSS"], + } + ) + + structure_metadata_df = pd.DataFrame( + {"entry_id": ["test"], "uniprot_accession": ["P12345"]} + ) + + valid_ids = {"P12345": ["P12345"]} + structures_to_validate = ["P12345"] + + result = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + structure_metadata_df=structure_metadata_df, + crosslinker_information=crosslinker_information, + cif_df=cif_df, + amino_acid_sequences_df=amino_acid_sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.pdbx_sifts_xref_db_acc", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.min_pae.value, + pae_matrix=pae_matrix, + ) + + df: pd.DataFrame = result["crosslinking_result_df"] + assert df.loc[0, "valid_crosslink"] == expected + + +@pytest.mark.parametrize( + "distance, expected_min, expected_max", + [ + (2.0, False, False), + (3.0, False, True), + (4.0, True, True), + (5.0, True, True), + (6.0, True, True), + (7.0, False, True), + (8.0, False, False), + ], +) +def test_cl_validation_pae_haserror(distance, expected_min, expected_max): + crosslinker_information = {"DSS": [5.0, 1.0, 1.0]} # Length 5 Å (± 1 Å) + pae_matrix = np.array([[np.nan, 1], [2, np.nan]]) + + # Fake AlphaFold Data with chain IDs + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA", "CA"], + "_atom_site.label_asym_id": ["A", "A"], + "_atom_site.label_seq_id": [1, 2], + "_atom_site.Cartn_x": [0, distance], + "_atom_site.Cartn_y": [0, 0], + "_atom_site.Cartn_z": [0, 0], + "_atom_site.auth_asym_id": ["A", "A"], + "_atom_site.pdbx_sifts_xref_db_acc": ["P12345", "P12345"], + } + ) + + amino_acid_sequences_df = pd.DataFrame( + {"Protein ID": ["P12345-1"], "Protein Sequence": ["AB"]} + ) + + # Fake Crosslink Data + crosslinking_df = pd.DataFrame( + { + "Protein_id1": ["P12345"], + "Protein_id2": ["P12345"], + "Peptide1": ["A"], + "Peptide2": ["B"], + "CL_position_within_peptide1": [0], + "CL_position_within_peptide2": [0], + "Crosslinker": ["DSS"], + } + ) + + structure_metadata_df = pd.DataFrame( + {"entry_id": ["test"], "uniprot_accession": ["P12345"]} + ) + + valid_ids = {"P12345": ["P12345"]} + structures_to_validate = ["P12345"] + + result_min = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + structure_metadata_df=structure_metadata_df, + crosslinker_information=crosslinker_information, + cif_df=cif_df, + amino_acid_sequences_df=amino_acid_sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.pdbx_sifts_xref_db_acc", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.min_pae.value, + pae_matrix=pae_matrix, + ) + + df: pd.DataFrame = result_min["crosslinking_result_df"] + assert df.loc[0, "valid_crosslink"] == expected_min + + result_max = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + structure_metadata_df=structure_metadata_df, + crosslinker_information=crosslinker_information, + cif_df=cif_df, + amino_acid_sequences_df=amino_acid_sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.pdbx_sifts_xref_db_acc", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.max_pae.value, + pae_matrix=pae_matrix, + ) + + df: pd.DataFrame = result_max["crosslinking_result_df"] + assert df.loc[0, "valid_crosslink"] == expected_max + + +@pytest.mark.parametrize( + "distance, expected", + [ + (4.99, False), + (5.0, True), + (5.1, False), + ], +) +def test_cl_validation_plddt_noerrror(distance, expected): + crosslinker_information = {"DSS": [5.0, 1.0, 1.0]} # Length 5 Å (± 1 Å) + + plddt_df_noerror = pd.DataFrame( + { + "chainID": ["A", "A"], + "residueNumber": [1, 2], + "confidenceScore": [100, 100], + # confidenceCategory is not required + } + ) + + # Fake AlphaFold Data with chain IDs + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA", "CA"], + "_atom_site.label_asym_id": ["A", "A"], + "_atom_site.label_seq_id": [1, 2], + "_atom_site.Cartn_x": [0, distance], + "_atom_site.Cartn_y": [0, 0], + "_atom_site.Cartn_z": [0, 0], + "_atom_site.auth_asym_id": ["A", "A"], + "_atom_site.pdbx_sifts_xref_db_acc": ["P12345", "P12345"], + } + ) + + amino_acid_sequences_df = pd.DataFrame( + {"Protein ID": ["P12345-1"], "Protein Sequence": ["AB"]} + ) + + # Fake Crosslink Data + crosslinking_df = pd.DataFrame( + { + "Protein_id1": ["P12345"], + "Protein_id2": ["P12345"], + "Peptide1": ["A"], + "Peptide2": ["B"], + "CL_position_within_peptide1": [0], + "CL_position_within_peptide2": [0], + "Crosslinker": ["DSS"], + } + ) + + structure_metadata_df = pd.DataFrame( + {"entry_id": ["test"], "uniprot_accession": ["P12345"]} + ) + + valid_ids = {"P12345": ["P12345"]} + structures_to_validate = ["P12345"] + + result = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + structure_metadata_df=structure_metadata_df, + crosslinker_information=crosslinker_information, + cif_df=cif_df, + amino_acid_sequences_df=amino_acid_sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.pdbx_sifts_xref_db_acc", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.plddt_adjusted.value, + plddt_df=plddt_df_noerror, + ) + + df: pd.DataFrame = result["crosslinking_result_df"] + assert df.loc[0, "valid_crosslink"] == expected + + +# l_cl = 5, t_x = 1.25, t_y = 3.5. +# So range is 0.25 <= d <= 9.75 +@pytest.mark.parametrize( + "distance, expected", + [ + (0.0, False), + (0.24, False), + (0.25, True), + (5.0, True), + (9.0, True), + (9.74, True), + (9.75, True), + (9.76, False), + (10.0, False), + ], +) +def test_cl_validation_plddt_witherror(distance, expected): + crosslinker_information = {"DSS": [5.0, 1.0, 1.0]} # Length 5 Å (± 1 Å) + + plddt_df_noerror = pd.DataFrame( + { + "chainID": ["A", "A"], + "residueNumber": [1, 2], + "confidenceScore": [75, 30], + # confidenceCategory is not required + } + ) + + # Fake AlphaFold Data with chain IDs + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA", "CA"], + "_atom_site.label_asym_id": ["A", "A"], + "_atom_site.label_seq_id": [1, 2], + "_atom_site.Cartn_x": [0, distance], + "_atom_site.Cartn_y": [0, 0], + "_atom_site.Cartn_z": [0, 0], + "_atom_site.auth_asym_id": ["A", "A"], + "_atom_site.pdbx_sifts_xref_db_acc": ["P12345", "P12345"], + } + ) + + amino_acid_sequences_df = pd.DataFrame( + {"Protein ID": ["P12345-1"], "Protein Sequence": ["AB"]} + ) + + # Fake Crosslink Data + crosslinking_df = pd.DataFrame( + { + "Protein_id1": ["P12345"], + "Protein_id2": ["P12345"], + "Peptide1": ["A"], + "Peptide2": ["B"], + "CL_position_within_peptide1": [0], + "CL_position_within_peptide2": [0], + "Crosslinker": ["DSS"], + } + ) + + structure_metadata_df = pd.DataFrame( + {"entry_id": ["test"], "uniprot_accession": ["P12345"]} + ) + + valid_ids = {"P12345": ["P12345"]} + structures_to_validate = ["P12345"] + + result = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + structure_metadata_df=structure_metadata_df, + crosslinker_information=crosslinker_information, + cif_df=cif_df, + amino_acid_sequences_df=amino_acid_sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.pdbx_sifts_xref_db_acc", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.plddt_adjusted.value, + plddt_df=plddt_df_noerror, + ) + + df: pd.DataFrame = result["crosslinking_result_df"] + assert df.loc[0, "valid_crosslink"] == expected + + +def test_modify_form_creates_crosslinker_fields(): + crosslinking_df = pd.DataFrame({"Crosslinker": ["DSS", "BS3", "DSS"]}) + + steps = MagicMock() + steps.get_step_output.return_value = crosslinking_df + + run = MagicMock() + run.steps = steps + + step = CrosslinkingValidationWithAngstromDeviation() + step.form = step.create_form() + + step.input_source = MagicMock(return_value=("dummy_step", "dummy_handle")) + + step.modify_form(run) + + assert "DSS_length" in step.form + assert "DSS_upper_accepted_deviation" in step.form + assert "DSS_lower_accepted_deviation" in step.form + + assert "BS3_length" in step.form + assert "BS3_upper_accepted_deviation" in step.form + assert "BS3_lower_accepted_deviation" in step.form + + +def test_get_distance_between_two_amino_acids_in_angstrom(): + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA", "CA"], + "_atom_site.label_seq_id": [1, 2], + "_atom_site.Cartn_x": [0, 3], + "_atom_site.Cartn_y": [0, 4], + "_atom_site.Cartn_z": [0, 0], + "_atom_site.auth_asym_id": ["A", "A"], + } + ) + + dist = get_distance_between_two_amino_acids_in_angstrom( + 1, 2, "A", "B", cif_df, chain_id1="A", chain_id2="A" + ) + + assert dist == 5.0 + + +def test_add_crosslinker_positions_with_exactly_one_possible_position(): + df = pd.DataFrame( + { + "Protein_id1": ["P1"], + "Protein_id2": ["P1"], + "Peptide1": ["ABC"], + "Peptide2": ["DEF"], + "CL_position_within_peptide1": [1], + "CL_position_within_peptide2": [2], + } + ) + + amino_acid_sequences_df = pd.DataFrame( + {"Protein ID": ["P1-1"], "Protein Sequence": ["XXABCYYYDEFZZ"]} + ) + + df, messages = add_protein_crosslink_positions_to_df(df, amino_acid_sequences_df) + + assert messages == [] + + assert df.loc[0, "crosslinker_position1"] == 2 + 1 + 1 # 1-based + assert df.loc[0, "crosslinker_position2"] == 8 + 2 + 1 # 1-based + + assert str(df["crosslinker_position1"].dtype) == "Int64" + assert str(df["crosslinker_position2"].dtype) == "Int64" + + +def test_add_crosslinker_positions_with_more_than_one_possible_position(): + df = pd.DataFrame( + { + "Protein_id1": ["P1"], + "Protein_id2": ["P1"], + "Peptide1": ["AA"], + "Peptide2": ["BB"], + "CL_position_within_peptide1": [0], + "CL_position_within_peptide2": [0], + } + ) + + amino_acid_sequences_df = pd.DataFrame( + {"Protein ID": ["P1-1"], "Protein Sequence": ["AAXXAAZZBBYYBB"]} + ) + + df, messages = add_protein_crosslink_positions_to_df(df, amino_acid_sequences_df) + + # 2 AA matches × 2 BB matches = 4 combinations + assert len(df) == 4 + + # One warning about duplication + assert len(messages) == 1 + assert messages[0]["level"] == logging.WARNING + assert "duplicated" in messages[0]["msg"] + + # All rows should have valid positions + assert df["crosslinker_position1"].notna().all() + assert df["crosslinker_position2"].notna().all() + + +def test_add_crosslinker_positions_but_one_peptide_not_found_deletes_row(): + df = pd.DataFrame( + { + "Protein_id1": ["P1"], + "Protein_id2": ["P1"], + "Peptide1": ["ABC"], + "Peptide2": ["DEF"], + "CL_position_within_peptide1": [0], + "CL_position_within_peptide2": [0], + } + ) + + amino_acid_sequences_df = pd.DataFrame( + {"Protein ID": ["P1-1"], "Protein Sequence": ["XXXXXXXX"]} + ) + + df, messages = add_protein_crosslink_positions_to_df(df, amino_acid_sequences_df) + + assert len(messages) == 1 + assert messages[0]["level"] == logging.WARNING + assert "not found" in messages[0]["msg"] + + # row should be deleted + assert df.empty + + +def test_add_crosslinker_positions_with_valid_and_invalid_rows_mixed(): + df = pd.DataFrame( + { + "Protein_id1": ["P1", "P1", "P1"], + "Protein_id2": ["P1", "P1", "P1"], + "Peptide1": ["ABC", "XXX", "ABC"], + "Peptide2": ["DEF", "DEF", "YYY"], + "CL_position_within_peptide1": [0, 0, 0], + "CL_position_within_peptide2": [0, 0, 0], + } + ) + + amino_acid_sequences_df = pd.DataFrame( + {"Protein ID": ["P1-1"], "Protein Sequence": ["ABCDEF"]} + ) + + df, messages = add_protein_crosslink_positions_to_df(df, amino_acid_sequences_df) + + assert len(messages) == 2 + assert messages[0]["level"] == logging.WARNING + + # First row valid + assert df.loc[0, "crosslinker_position1"] == 1 + assert df.loc[0, "crosslinker_position2"] == 4 + + # Second and third row invalid -> df should only have one row + assert len(df) == 1 + + +def test_add_crosslinker_positions_with_overlapping_peptide_matches(): + df = pd.DataFrame( + { + "Protein_id1": ["P1"], + "Protein_id2": ["P1"], + "Peptide1": ["AAA"], + "Peptide2": ["B"], + "CL_position_within_peptide1": [0], + "CL_position_within_peptide2": [0], + } + ) + + amino_acid_sequences_df = pd.DataFrame( + {"Protein ID": ["P1-1"], "Protein Sequence": ["AAAAB"]} + ) + + df, messages = add_protein_crosslink_positions_to_df(df, amino_acid_sequences_df) + + # AAA -> positions 0, 1 + # B -> position 4 + # => 2 * 1 = 2 combinations + assert len(df) == 2 + + # One warning about duplication + assert len(messages) == 1 + assert messages[0]["level"] == logging.WARNING + assert "duplicated" in messages[0]["msg"] + + observed_positions = set( + zip( + df["crosslinker_position1"].astype(int), + df["crosslinker_position2"].astype(int), + ) + ) + + expected_positions = {(1, 5), (2, 5)} + + assert observed_positions == expected_positions + + +def test_validate_multimer_filters_only_pairs_within_structures_to_validate(): + rows = [ + ("P1-1", "ABCDE"), + ("P2-1", "VWXYZ"), + ("P3-1", "KLMNO"), + ] + sequences_df = pd.DataFrame(rows, columns=["Protein ID", "Protein Sequence"]) + + crosslinking_df = pd.DataFrame( + [ + # within set: P1-P2 (should be kept when validating ["P1","P2"]) + ("P1", "P2", "BC", "WX", 0, 0, "XL"), + # within set: P2-P2 + ("P2", "P2", "WX", "WX", 0, 0, "XL"), + # outside set: P1-P3 (should be filtered out) + ("P1", "P3", "BC", "LM", 0, 0, "XL"), + ], + columns=[ + "Protein_id1", + "Protein_id2", + "Peptide1", + "Peptide2", + "CL_position_within_peptide1", + "CL_position_within_peptide2", + "Crosslinker", + ], + ) + + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA"] * 5, + "_atom_site.label_seq_id": list(range(1, 6)), + "_atom_site.Cartn_x": [float(i) for i in range(1, 6)], + "_atom_site.Cartn_y": [0.0] * 5, + "_atom_site.Cartn_z": [0.0] * 5, + "_atom_site.auth_asym_id": ["A"] * 5, + "_atom_site.label_entity_id": [1, 1, 2, 2, 3], + } + ) + + # Very permissive bounds: always valid as long as distance is defined. + # Format is [length, upper_deviation, lower_deviation]. + crosslinker_information = {"XL": [0.0, 0.0, 0.0]} + valid_ids = {"P1": [1], "P2": [2]} + structures_to_validate = ["P1", "P2"] + + structure_metadata_df = pd.DataFrame( + {"entry_id": ["test"], "uniprot_ids": [["P1", "P2"]]} + ) + + out = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + structure_metadata_df=structure_metadata_df, + crosslinker_information=crosslinker_information, + cif_df=cif_df, + amino_acid_sequences_df=sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.label_entity_id", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.manual_bounds.value, + ) + + result_df = out["crosslinking_result_df"] + assert isinstance(result_df, pd.DataFrame) + assert not result_df.empty + + # Only the first two rows should remain after filtering. + assert len(result_df) == 2 + + assert set(result_df["Protein_id1"].unique()).issubset({"P1", "P2"}) + assert set(result_df["Protein_id2"].unique()).issubset({"P1", "P2"}) + + assert "alphafold_distance" in result_df.columns + assert "valid_crosslink" in result_df.columns + assert "crosslinker_position1" in result_df.columns + assert "crosslinker_position2" in result_df.columns + assert "link_type" in result_df.columns + assert "Chain_id1" in result_df.columns + assert "Chain_id2" in result_df.columns + + +def test_validate_multimer_no_links_between_structures_returns_empty_and_warning(): + sequences_df = pd.DataFrame( + [ + ("P1-1", "ABCDE"), + ("P2-1", "VWXYZ"), + ("P3-1", "KLMNO"), + ], + columns=["Protein ID", "Protein Sequence"], + ) + + crosslinking_df = pd.DataFrame( + [ + ("P1", "P3", "BC", "LM", 0, 0, "XL"), + ("P3", "P2", "LM", "WX", 0, 0, "XL"), + ], + columns=[ + "Protein_id1", + "Protein_id2", + "Peptide1", + "Peptide2", + "CL_position_within_peptide1", + "CL_position_within_peptide2", + "Crosslinker", + ], + ) + + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA"] * 5, + "_atom_site.label_seq_id": list(range(1, 6)), + "_atom_site.Cartn_x": [float(i) for i in range(1, 6)], + "_atom_site.Cartn_y": [0.0] * 5, + "_atom_site.Cartn_z": [0.0] * 5, + "_atom_site.auth_asym_id": ["A"] * 5, + "_atom_site.label_entity_id": [1, 1, 2, 3, 3], + } + ) + crosslinker_information = {"XL": [0.0, 0.0, 0.0]} + valid_ids = {"P1": [1], "P2": [2]} + structures_to_validate = ["P1", "P2"] + + structure_metadata_df = pd.DataFrame( + {"entry_id": ["test"], "uniprot_ids": [["P1", "P2"]]} + ) + + out = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + structure_metadata_df=structure_metadata_df, + crosslinker_information=crosslinker_information, + cif_df=cif_df, + amino_acid_sequences_df=sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.label_entity_id", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.manual_bounds.value, + ) + + result_df = out["crosslinking_result_df"] + messages = out["messages"] + + assert isinstance(result_df, pd.DataFrame) + assert result_df.empty + + assert isinstance(messages, list) + assert len(messages) >= 1 + assert messages[0].get("level") is not None + assert "There are no crosslinks between the structures to validate." in messages[ + 0 + ].get("msg", "") + + +def test_validate_multimer_duplicates_rows_for_multiple_peptide_matches_and_validates_all(): + # AB occurs twice in ABAB: at positions 1 and 3 (1-based). + sequences_df = pd.DataFrame( + [ + ("P1-1", "ABAB"), + ("P2-1", "ABAB"), + ], + columns=["Protein ID", "Protein Sequence"], + ) + + crosslinking_df = pd.DataFrame( + [ + ("P1", "P2", "AB", "AB", 0, 0, "XL"), + ], + columns=[ + "Protein_id1", + "Protein_id2", + "Peptide1", + "Peptide2", + "CL_position_within_peptide1", + "CL_position_within_peptide2", + "Crosslinker", + ], + ) + + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA"] * 8, + "_atom_site.label_seq_id": [1, 2, 3, 4, 1, 2, 3, 4], + "_atom_site.Cartn_x": [1.0, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0, 4.0], + "_atom_site.Cartn_y": [0.0] * 8, + "_atom_site.Cartn_z": [0.0] * 8, + "_atom_site.auth_asym_id": ["A", "A", "A", "A", "B", "B", "B", "B"], + "_atom_site.label_entity_id": [1, 1, 1, 1, 2, 2, 2, 2], + } + ) + + # Always-valid bounds so we focus on duplication and distance computation. + crosslinker_information = {"XL": [0.0, 0.0, 0.0]} + valid_ids = {"P1": [1], "P2": [2]} + structures_to_validate = ["P1", "P2"] + + structure_metadata_df = pd.DataFrame( + {"entry_id": ["test"], "uniprot_ids": [["P1", "P2"]]} + ) + + out = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + structure_metadata_df=structure_metadata_df, + crosslinker_information=crosslinker_information, + cif_df=cif_df, + amino_acid_sequences_df=sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.label_entity_id", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.manual_bounds.value, + ) + + result_df = out["crosslinking_result_df"] + messages = out["messages"] + + assert isinstance(result_df, pd.DataFrame) + assert len(result_df) == 4 + + # Crosslinker positions should cover the product of {1,3} x {1,3}. + combos = set( + zip( + result_df["crosslinker_position1"].astype(int).tolist(), + result_df["crosslinker_position2"].astype(int).tolist(), + ) + ) + assert combos == {(1, 1), (1, 3), (3, 1), (3, 3)} + + # Distances in our 1D coordinate system are abs(pos2 - pos1). + distances = sorted(result_df["alphafold_distance"].astype(float).tolist()) + assert distances == [0.0, 0.0, 2.0, 2.0] + + # With permissive bounds, all should be valid. + assert result_df["valid_crosslink"].dropna().all() + + # Check link_type column + assert "link_type" in result_df.columns + assert result_df["link_type"].isin(["intra", "inter"]).all() + # All links should be inter because they are between different chains + assert all(result_df["link_type"] == "inter") + + # Expect a duplication warning message. + assert any( + ("duplicated" in str(m.get("msg", "")).lower()) and (m.get("level") is not None) + for m in messages + ) + + +def test_add_vertical_line_with_annotation_in_legend_adds_line_and_legend(): + fig = go.Figure() + add_vertical_line_with_annotation_in_legend( + fig=fig, dash="dash", annotation="Test Line", x_value=5.0 + ) + + # add_vline internally adds a shape to layout.shapes + assert len(fig.layout.shapes) == 1 + vline = fig.layout.shapes[0] + assert vline["x0"] == 5.0 + assert vline["line"]["dash"] == "dash" + assert vline["line"]["color"] == PLOT_PRIMARY_COLOR + + # There should be 1 scatter trace for the legend + assert len(fig.data) == 1 + trace = fig.data[0] + assert trace.mode == "lines" + assert trace.name == "Test Line" + assert trace.line.dash == "dash" + assert trace.line.color == PLOT_PRIMARY_COLOR + assert trace.x == (None,) + assert trace.y == (None,) + + +@pytest.fixture +def sample_crosslinking_df(): + return pd.DataFrame( + { + "Crosslinker": ["CL1", "CL1", "CL2", "CL2"], + "alphafold_distance": [10.0, 12.0, 8.0, 9.0], + "valid_crosslink": [True, False, True, False], + "link_type": ["intra", "intra", "inter", "inter"], + } + ) + + +@pytest.fixture +def sample_crosslinker_info(): + return { + "CL1": [11.0, 2.0, 0.0], # [length, upper_deviation, lower_deviation] + "CL2": [9.0, 0.0, 1.0], + } + + +@patch("backend.protzilla.data_analysis.crosslinking_validation.create_histograms") +@patch("backend.protzilla.data_analysis.crosslinking_validation.create_bar_plot") +@patch( + "backend.protzilla.data_analysis.crosslinking_validation.add_vertical_line_with_annotation_in_legend" +) +def test_diagrams_of_crosslinking_validation_data_with_drawing_all_vertical_lines( + mock_add_vline, + mock_create_bar, + mock_create_hist, + sample_crosslinking_df, + sample_crosslinker_info, +): + validated_df = sample_crosslinking_df.copy() + + hist_mock = Figure() + mock_create_hist.return_value = hist_mock + bar_mock = Figure() + mock_create_bar.return_value = bar_mock + + figures = diagrams_of_crosslinking_validation_data( + validated_df=validated_df, + structures_to_validate=["P12345"], + crosslinker_information=sample_crosslinker_info, + ) + + # 2 histograms per crosslinker + 1 bar plot + assert len(figures) == 5 + assert all(isinstance(f, Figure) for f in figures) + + assert ( + mock_add_vline.call_count == 8 + ) # for both crosslinkers: 1 call for crosslinker length for each histogram and 1 call for bound on deviation for each histogram + + # Check that create_histograms was called 2 times (1 per crosslinker) + assert mock_create_hist.call_count == 2 + + # Check that create_bar_plot was called once + mock_create_bar.assert_called_once() + + +@pytest.fixture +def sample_crosslinking_df_with_no_std(): + return pd.DataFrame( + { + "Crosslinker": ["CL1", "CL1", "CL2", "CL2"], + "alphafold_distance": [10.5, 10.5, 10.5, 10.5], + "valid_crosslink": [True, False, True, False], + "link_type": ["intra", "intra", "inter", "inter"], + } + ) + + +@pytest.fixture +def sample_crosslinker_info_matching_sample_crosslinking_df_with_no_std(): + return { + "CL1": [10.5, 1.0, 1.0], # [length, upper_deviation, lower_deviation] + "CL2": [10.5, 0.5, 0.3], + } + + +@patch("backend.protzilla.data_analysis.crosslinking_validation.create_histograms") +@patch("backend.protzilla.data_analysis.crosslinking_validation.create_bar_plot") +@patch( + "backend.protzilla.data_analysis.crosslinking_validation.add_vertical_line_with_annotation_in_legend" +) +def test_diagrams_of_crosslinking_validation_data_without_drawing_all_vertical_lines( + mock_add_vline, + mock_create_bar, + mock_create_hist, + sample_crosslinking_df_with_no_std, + sample_crosslinker_info_matching_sample_crosslinking_df_with_no_std, +): + validated_df = sample_crosslinking_df_with_no_std.copy() + + hist_mock = Figure() + mock_create_hist.return_value = hist_mock + bar_mock = Figure() + mock_create_bar.return_value = bar_mock + + figures = diagrams_of_crosslinking_validation_data( + validated_df=validated_df, + structures_to_validate=["P12345"], + crosslinker_information=sample_crosslinker_info_matching_sample_crosslinking_df_with_no_std, + ) + + # 2 histograms per crosslinker + 1 bar plot + assert len(figures) == 5 + assert all(isinstance(f, Figure) for f in figures) + + # CL1: all 3 lines are drawn for both histograms, CL2: only crosslinker_length ist drawn for both histograms, + # the bounds are only drawn for the histogram that is not limited to the range of +- 2 standard deviations + assert mock_add_vline.call_count == 10 + + # Check that create_histograms was called 2 times (1 per crosslinker) + assert mock_create_hist.call_count == 2 + + # Check that create_bar_plot was called once + mock_create_bar.assert_called_once() + + +@pytest.fixture +def sample_crosslinker_info_with_one_crosslinker(): + return { + "CL1": [11.0, 2.0, 1.0], # [length, upper_deviation, lower_deviation] + } + + +@pytest.fixture +def sample_crosslinking_df_with_one_crosslinker(): + return pd.DataFrame( + { + "Crosslinker": ["CL1", "CL1", "CL1", "CL1"], + "alphafold_distance": [10.0, 12.0, 8.0, 9.0], + "valid_crosslink": [True, False, True, False], + "link_type": ["intra", "intra", "inter", "inter"], + } + ) + + +def test_diagrams_calls_with_correct_parameters( + sample_crosslinking_df_with_one_crosslinker, + sample_crosslinker_info_with_one_crosslinker, +): + with patch( + "backend.protzilla.data_analysis.crosslinking_validation.create_histograms" + ) as mock_hist, patch( + "backend.protzilla.data_analysis.crosslinking_validation.add_vertical_line_with_annotation_in_legend" + ) as mock_vline, patch( + "backend.protzilla.data_analysis.crosslinking_validation.create_bar_plot" + ) as mock_bar: + + mock_hist.return_value = Figure() + mock_bar.return_value = "bar_fig" + + figures = diagrams_of_crosslinking_validation_data( + validated_df=sample_crosslinking_df_with_one_crosslinker, + structures_to_validate=["P12345"], + crosslinker_information=sample_crosslinker_info_with_one_crosslinker, + ) + + # There should be 1 histogram calls: 1 per crosslinker + assert mock_hist.call_count == 1 + + # Check histogram call parameters for crosslinker ±2 std + hist_call = mock_hist.call_args_list[0].kwargs + assert hist_call["name_a"] == "Predictions matching CLs (intra: 1, inter: 1)" + assert ( + hist_call["name_b"] == "Predictions not matching CLs (intra: 1, inter: 1)" + ) + assert ( + hist_call["heading"] + == "Predicted distances for P12345 with crosslinker CL1, mean +/- 2 σ" + ) + assert hist_call["relevant_column_a"] == "alphafold_distance" + assert hist_call["relevant_column_b"] == "alphafold_distance" + assert hist_call["one_bin_per_int"] == True + + mean_predicted_lengths = sample_crosslinking_df_with_one_crosslinker[ + "alphafold_distance" + ].mean() + standard_deviation_predicted_lengths = ( + sample_crosslinking_df_with_one_crosslinker["alphafold_distance"].std() + ) + mean_plus_minus_two_std_range = ( + max(0, mean_predicted_lengths - 2 * standard_deviation_predicted_lengths), + mean_predicted_lengths + 2 * standard_deviation_predicted_lengths, + ) + assert hist_call["min_value"] == mean_plus_minus_two_std_range[0] + assert hist_call["max_value"] == mean_plus_minus_two_std_range[1] + + valid_crosslinks = sample_crosslinking_df_with_one_crosslinker.loc[ + sample_crosslinking_df_with_one_crosslinker["valid_crosslink"] == True, + "alphafold_distance", + ] + invalid_crosslinks = sample_crosslinking_df_with_one_crosslinker.loc[ + sample_crosslinking_df_with_one_crosslinker["valid_crosslink"] == False, + "alphafold_distance", + ] + dataframe_a = pd.DataFrame({"alphafold_distance": valid_crosslinks}) + dataframe_b = pd.DataFrame({"alphafold_distance": invalid_crosslinks}) + pdt.assert_frame_equal(hist_call["dataframe_a"], dataframe_a) + pdt.assert_frame_equal(hist_call["dataframe_b"], dataframe_b) + + call_args_list = [call.kwargs for call in mock_vline.call_args_list] + assert any( + call["annotation"] == "CL1 length: 11.0Å" and call["x_value"] == 11.0 + for call in call_args_list + ) + + mock_bar.assert_called_once() + + +def test_validate_multimer_with_invalid_crosslinks(): + sequences_df = pd.DataFrame( + [ + ("P1-1", "ABAB"), + ("P2-1", "ABAB"), + ], + columns=["Protein ID", "Protein Sequence"], + ) + + crosslinking_df = pd.DataFrame( + [ + ("P1", "P2", "AB", "AB", 0, 0, "XL"), + ], + columns=[ + "Protein_id1", + "Protein_id2", + "Peptide1", + "Peptide2", + "CL_position_within_peptide1", + "CL_position_within_peptide2", + "Crosslinker", + ], + ) + + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA"] * 4, + "_atom_site.label_seq_id": [1, 2, 3, 4], + "_atom_site.Cartn_x": [1.0, 2.0, 3.0, 4.0], + "_atom_site.Cartn_y": [0.0, 0.0, 0.0, 0.0], + "_atom_site.Cartn_z": [0.0, 0.0, 0.0, 0.0], + "_atom_site.auth_asym_id": ["A"] * 4, + "_atom_site.label_entity_id": [1, 1, 2, 2], + } + ) + + # length = 1.5, upper_dev = 0.6, lower_dev = 0.6. + # Distances will be [0.0, 0.0, 2.0, 2.0] -> two valid (2.0) and two invalid (0.0). + crosslinker_information = {"XL": [1.5, 0.6, 0.6]} + valid_ids = {"P1": [1], "P2": [2]} + structures_to_validate = ["P1", "P2"] + + structure_metadata_df = pd.DataFrame( + {"entry_id": ["test"], "uniprot_ids": [["P1", "P2"]]} + ) + + out = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + structure_metadata_df=structure_metadata_df, + crosslinker_information=crosslinker_information, + cif_df=cif_df, + amino_acid_sequences_df=sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.label_entity_id", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.manual_bounds.value, + ) + + result_df = out["crosslinking_result_df"] + assert isinstance(result_df, pd.DataFrame) + assert len(result_df) == 4 + + distances = sorted(result_df["alphafold_distance"].astype(float).tolist()) + assert distances == [0.0, 0.0, 2.0, 2.0] + + valid_counts = result_df["valid_crosslink"].value_counts() + assert valid_counts.get(True, 0) == 2 + assert valid_counts.get(False, 0) == 2 + + valid_distances = sorted( + result_df.loc[result_df["valid_crosslink"] == True, "alphafold_distance"] + .astype(float) + .tolist() + ) + assert valid_distances == [2.0, 2.0] + assert "link_type" in result_df.columns + + +def test_get_chains(): + """Test that get_chains extracts chain IDs correctly from CIF data.""" + cif_df = pd.DataFrame( + { + "_atom_site.label_seq_id": [1, 2, 3, 4, 5], + "_atom_site.auth_asym_id": ["A", "A", "B", "B", "B"], + "_atom_site.label_entity_id": [1, 1, 2, 2, 2], + } + ) + + valid_ids = {"P1": [1], "P2": [2]} + + chains_p1 = get_chains( + cif_df=cif_df, + valid_ids=valid_ids, + protein_id="P1", + id_column_name="_atom_site.label_entity_id", + ) + + chains_p2 = get_chains( + cif_df=cif_df, + valid_ids=valid_ids, + protein_id="P2", + id_column_name="_atom_site.label_entity_id", + ) + + assert set(chains_p1) == {"A"} + assert set(chains_p2) == {"B"} + + +def test_expand_crosslinks_to_chain_combinations_homodimer(): + """Test expanding crosslinks for homodimer (same protein twice).""" + crosslinking_df = pd.DataFrame( + [ + ("P1", "P1", "AB", "CD", 0, 0, "XL"), + ], + columns=[ + "Protein_id1", + "Protein_id2", + "Peptide1", + "Peptide2", + "CL_position_within_peptide1", + "CL_position_within_peptide2", + "Crosslinker", + ], + ) + + chains_per_protein = {"P1": {"A": None, "B": None}} + + expanded_df = expand_crosslinks_to_chain_combinations( + crosslinking_df, chains_per_protein + ) + + # For homodimer with 2 chains: combinations with replacement should give us: + # (A,A), (A,B), (B,B) = 3 combinations + assert len(expanded_df) == 3 + assert "Chain_id1" in expanded_df.columns + assert "Chain_id2" in expanded_df.columns + + chain_combos = set( + zip(expanded_df["Chain_id1"].tolist(), expanded_df["Chain_id2"].tolist()) + ) + assert chain_combos == {("A", "A"), ("A", "B"), ("B", "B")} + + +def test_expand_crosslinks_to_chain_combinations_heterodimer(): + """Test expanding crosslinks for heterodimer (different proteins).""" + crosslinking_df = pd.DataFrame( + [ + ("P1", "P2", "AB", "CD", 0, 0, "XL"), + ], + columns=[ + "Protein_id1", + "Protein_id2", + "Peptide1", + "Peptide2", + "CL_position_within_peptide1", + "CL_position_within_peptide2", + "Crosslinker", + ], + ) + + chains_per_protein = {"P1": {"A": None}, "P2": {"C": None, "D": None}} + + expanded_df = expand_crosslinks_to_chain_combinations( + crosslinking_df, chains_per_protein + ) + + # For heterodimer: product of {A} x {C, D} = 2 combinations + assert len(expanded_df) == 2 + + chain_combos = set( + zip(expanded_df["Chain_id1"].tolist(), expanded_df["Chain_id2"].tolist()) + ) + assert chain_combos == {("A", "C"), ("A", "D")} + + +def test_validate_multimer_same_protein_different_chains_intra_vs_inter(): + """Test that intra/inter link_type is determined by chain ID, not protein ID.""" + sequences_df = pd.DataFrame( + [ + ("P1-1", "ABCD"), + ], + columns=["Protein ID", "Protein Sequence"], + ) + + # Single protein P1 with two copies in multimer (P1 appears twice as different chains) + crosslinking_df = pd.DataFrame( + [ + ("P1", "P1", "AB", "AB", 0, 0, "XL"), + ], + columns=[ + "Protein_id1", + "Protein_id2", + "Peptide1", + "Peptide2", + "CL_position_within_peptide1", + "CL_position_within_peptide2", + "Crosslinker", + ], + ) + + cif_df = pd.DataFrame( + { + "_atom_site.label_atom_id": ["CA"] * 8, + "_atom_site.label_seq_id": [1, 2, 3, 4, 1, 2, 3, 4], + "_atom_site.Cartn_x": [1.0, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0, 4.0], + "_atom_site.Cartn_y": [0.0] * 8, + "_atom_site.Cartn_z": [0.0] * 8, + "_atom_site.auth_asym_id": ["A", "A", "A", "A", "B", "B", "B", "B"], + "_atom_site.label_entity_id": [1, 1, 1, 1, 1, 1, 1, 1], + } + ) + + structure_metadata_df = pd.DataFrame( + { + "entry_id": ["test"], + "uniprot_ids": ["ABCD"], + } + ) + + crosslinker_information = {"XL": [0.0, 0.0, 0.0]} + valid_ids = {"P1": [1]} # One protein ID, but present in chains A and B + structures_to_validate = ["P1"] + + out = validate_with_angstrom_deviation( + crosslinking_df=crosslinking_df, + crosslinker_information=crosslinker_information, + structure_metadata_df=structure_metadata_df, + cif_df=cif_df, + amino_acid_sequences_df=sequences_df, + valid_ids=valid_ids, + id_column_name="_atom_site.label_entity_id", + structures_to_validate=structures_to_validate, + validation_criterion=CrosslinkingValidationCriterion.manual_bounds.value, + ) + + result_df = out["crosslinking_result_df"] + + # Should have 3 combinations: (A,A), (A,B), (B,B) + assert len(result_df) == 3 + + # Check link types based on chain IDs + intra_links = result_df[result_df["link_type"] == "intra"] + inter_links = result_df[result_df["link_type"] == "inter"] + + # (A,A) and (B,B) should be intra (same chain) + assert len(intra_links) == 2 + # (A,B) should be inter (different chains) + assert len(inter_links) == 1 + + # Verify the specific chain combinations + intra_combos = set( + zip(intra_links["Chain_id1"].tolist(), intra_links["Chain_id2"].tolist()) + ) + assert intra_combos == {("A", "A"), ("B", "B")} + + inter_combos = set( + zip(inter_links["Chain_id1"].tolist(), inter_links["Chain_id2"].tolist()) + ) + assert inter_combos == {("A", "B")} diff --git a/backend/tests/protzilla/data_analysis/test_differential_expression.py b/backend/tests/protzilla/data_analysis/test_differential_expression.py index 06684d04e..c21b5a4c5 100644 --- a/backend/tests/protzilla/data_analysis/test_differential_expression.py +++ b/backend/tests/protzilla/data_analysis/test_differential_expression.py @@ -16,11 +16,11 @@ kruskal_wallis_test_on_ptm_data, ) from backend.protzilla.data_analysis.plots import create_volcano_plot -from protzilla.data_analysis.differential_expression_t_test import ( +from backend.protzilla.data_analysis.differential_expression_t_test import ( get_z_score_based_fold_change_significance, vectorized_t_test, ) -from tests.paths import TEST_AML_DATA_PATH +from backend.tests.paths import TEST_AML_DATA_PATH @pytest.fixture diff --git a/backend/tests/protzilla/data_preprocessing/test_plots_data_preprocessing.py b/backend/tests/protzilla/data_preprocessing/test_plots_data_preprocessing.py index a12bf6e47..7e4b0739a 100644 --- a/backend/tests/protzilla/data_preprocessing/test_plots_data_preprocessing.py +++ b/backend/tests/protzilla/data_preprocessing/test_plots_data_preprocessing.py @@ -3,6 +3,10 @@ from backend.protzilla.data_preprocessing import imputation from backend.protzilla.data_preprocessing.plots import * from backend.tests.protzilla.data_preprocessing.test_imputation import * +from backend.protzilla.data_analysis.plots import ( + add_vertical_line_with_annotation_in_legend, +) + # this tests will build some Figures and display them if show_figures==True # it tests only for occurring errors @@ -101,6 +105,98 @@ def test_create_histograms( return +def test_create_histograms_one_bin_per_int_is_true(): + + df_a = pd.DataFrame({"value": [1.2, 2.7, 3.5]}) + df_b = pd.DataFrame({"value": [2.1, 4.6, 5.9]}) + + fig = create_histograms( + dataframe_a=df_a, + dataframe_b=df_b, + relevant_column_a="value", + relevant_column_b="value", + name_a="A", + name_b="B", + one_bin_per_int=True, + ) + + trace_a = fig.data[0] + trace_b = fig.data[1] + + # Check bin size is 1 + assert trace_a.xbins["size"] == 1 + assert trace_b.xbins["size"] == 1 + + # Check min and max are rounded correctly + # min_value should be floor(min(values_a.min(), values_b.min())) = floor(1.2) = 1 + # max_value should be ceil(max(values_a.max(), values_b.max())) = ceil(5.9) = 6 + assert trace_a.xbins["start"] == 1 + assert trace_a.xbins["end"] == 6 + assert trace_b.xbins["start"] == 1 + assert trace_b.xbins["end"] == 6 + + +def test_create_histograms_with_empty_dataframe(): + df_empty = pd.DataFrame({"value": []}) + df_nonempty = pd.DataFrame({"value": [1, 2, 3]}) + + fig = create_histograms( + dataframe_a=df_empty, + dataframe_b=df_nonempty, + relevant_column_a="value", + relevant_column_b="value", + name_a="Empty", + name_b="NonEmpty", + one_bin_per_int=True, + ) + + trace_empty = fig.data[0] + trace_nonempty = fig.data[1] + + # Ensure the function did not crash and returned a Figure + assert isinstance(fig, Figure) + + # Even if dataframe_a is empty, trace_a should exist with default bin size 1 + assert trace_empty.xbins["size"] == 1 + + # trace_b should have correct start/end bin values + assert trace_nonempty.xbins["start"] == 1 # floor(min(values_b)) = 1 + assert trace_nonempty.xbins["end"] == 3 # ceil(max(values_b)) = 3 + assert trace_nonempty.xbins["size"] == 1 + + +def test_add_vertical_line_with_annotation_in_legend_adds_line_and_legend_multiple_calls(): + fig = go.Figure() + add_vertical_line_with_annotation_in_legend( + fig=fig, dash="dash", annotation="Line 1", x_value=1.0 + ) + add_vertical_line_with_annotation_in_legend( + fig=fig, dash="dot", annotation="Line 2", x_value=2.0, color="green" + ) + + # Check layout.shapes -> add_vline internally adds a shape to layout.shapes + assert len(fig.layout.shapes) == 2 + vlines_x = [shape.x0 for shape in fig.layout.shapes] + assert vlines_x == [1.0, 2.0] + vlines_colors = [shape["line"]["color"] for shape in fig.layout.shapes] + assert vlines_colors == [PLOT_PRIMARY_COLOR, "green"] + vlines_dashes = [shape["line"]["dash"] for shape in fig.layout.shapes] + assert vlines_dashes == ["dash", "dot"] + + # Check legend traces + assert len(fig.data) == 2 + names = [trace.name for trace in fig.data] + colors = [trace.line.color for trace in fig.data] + dashes = [trace.line.dash for trace in fig.data] + x_values = [trace.x for trace in fig.data] + y_values = [trace.y for trace in fig.data] + assert names == ["Line 1", "Line 2"] + assert colors == [PLOT_PRIMARY_COLOR, "green"] + assert dashes == ["dash", "dot"] + assert x_values == [(None,), (None,)] + assert y_values == [(None,), (None,)] + + @pytest.mark.order(2) @pytest.mark.dependency( depends=[ diff --git a/backend/tests/protzilla/importing/test_alphafold_protein_structure_load.py b/backend/tests/protzilla/importing/test_alphafold_protein_structure_load.py new file mode 100644 index 000000000..334cdbccf --- /dev/null +++ b/backend/tests/protzilla/importing/test_alphafold_protein_structure_load.py @@ -0,0 +1,1166 @@ +from backend.protzilla.steps import OutputItem +import pandas as pd +import pytest +import json +import logging +import shutil +from pathlib import Path +import numpy as np + + +from backend.protzilla.importing.alphafold_protein_structure_load import ( + fetch_alphafold_protein_structure, + to_fasta, + read_alphafold_mmcif, + get_all_available_entry_ids_of_monomer_metadata, + get_all_available_entry_ids_of_multimer_metadata, + get_monomer_structure_dfs, + get_multimer_structure_dfs, + get_monomer_metadata_df, + get_multimer_metadata_df, + get_correct_af_directories, + extend_metadata_csv, + get_amino_acid_sequences_df, + handle_alphafold_files, + upload_multimer_prediction, + check_and_get_metadata_df, + check_dir, + get_json_files_in_dir, + get_cif_df_from_disk, + get_amino_acid_sequences_df_from_disk, + check_success_of_get_df, +) +from backend.protzilla.constants import paths +from backend.protzilla.constants.cif_columns import ( + ATOM_SITE_PREFIX, + ATOM_SITE_COLUMNS, + CHEM_COMP_COLUMNS, +) +from backend.protzilla.constants.data_types import DataKey + + +def test_to_fasta_default_header_and_newline(): + seq = "A" * 130 + out = to_fasta(seq, "test_id", 60) + + expected = ( + ">alpha|test_id\n" + ("A" * 60) + "\n" + ("A" * 60) + "\n" + ("A" * 10) + "\n" + ) + assert out == expected + + +def test_to_fasta_invalid_characters(): + with pytest.raises(ValueError, match=r"Invalid characters in sequence: 01@"): + to_fasta("AbbC@D1Eeff0") + + +def test_to_fasta_whitespace(): + with pytest.raises( + ValueError, match=r"Sequence must be a single, whitespace-free string." + ): + to_fasta(" ") + + +def test_read_alphafold_mmcif_file_not_found(tmp_path): + missing = tmp_path / "unexisting.cif" + with pytest.raises(FileNotFoundError): + read_alphafold_mmcif(missing) + + +def test_read_alphafold_mmcif_is_directory(tmp_path): + with pytest.raises(IsADirectoryError): + read_alphafold_mmcif(tmp_path) + + +def test_read_alphafold_mmcif_empty(tmp_path): + cif = tmp_path / "empty.cif" + cif.write_text("") + with pytest.raises(ValueError, match="No CIF blocks found"): + read_alphafold_mmcif(cif) + + +def test_read_alphafold_mmcif_atom_site_not_found(tmp_path): + cif = tmp_path / "no_atom_site.cif" + cif.write_text( + """ +data_test +_entry.id test +""" + ) + df = read_alphafold_mmcif(cif) + assert isinstance(df, pd.DataFrame) + assert df.empty + + +def test_read_alphafold_mmcif_valid_atom_site(tmp_path): + cif = tmp_path / "atom_site.cif" + cif.write_text( + """ +data_test +loop_ +_chem_comp.id +_chem_comp.mon_nstd_flag +SER y +# +loop_ +_atom_site.id +_atom_site.type_symbol +_atom_site.label_atom_id +_atom_site.label_comp_id +_atom_site.Cartn_x +1 N N SER 1.0 +2 C CA SER 2.0 +""" + ) + + df = read_alphafold_mmcif(cif) + + assert isinstance(df, pd.DataFrame) + assert list(df.columns) == [ + ATOM_SITE_COLUMNS.ID, + ATOM_SITE_COLUMNS.TYPE_SYMBOL, + ATOM_SITE_COLUMNS.LABEL_ATOM_ID, + ATOM_SITE_COLUMNS.LABEL_COMP_ID, + ATOM_SITE_COLUMNS.CARTN_X, + CHEM_COMP_COLUMNS.MON_NSTD_FLAG, + ] + assert len(df) == 2 + assert df[ATOM_SITE_COLUMNS.ID].tolist() == [1, 2] + assert df[ATOM_SITE_COLUMNS.TYPE_SYMBOL].tolist() == ["N", "C"] + assert df[ATOM_SITE_COLUMNS.LABEL_ATOM_ID].tolist() == ["N", "CA"] + assert df[ATOM_SITE_COLUMNS.CARTN_X].tolist() == [1.0, 2.0] + assert df[CHEM_COMP_COLUMNS.MON_NSTD_FLAG].tolist() == [True, True] + + +def test_fetch_alphafold_protein_structure_wrong_uniprot_id(): + with pytest.raises(RuntimeError, match="AlphaFold request failed for NOPROTEIN"): + fetch_alphafold_protein_structure(uniprot_id="NOPROTEIN", persist_upload=True) + + +def test_fetch_alphafold_returned_keys(tmp_path, monkeypatch): + monkeypatch.setattr(paths, "ALPHAFOLD_MONOMER_PATH", tmp_path / "alphafold_monomer") + monkeypatch.setattr( + paths, + "AF_MONOMER_METADATA_CSV_PATH", + tmp_path / "alphafold_monomer_metadata.csv", + ) + + out = fetch_alphafold_protein_structure("Q8WP00", persist_upload=True) + assert out.keys() == { + DataKey.STRUCTURE_METADATA_DF, + DataKey.CIF_DF, + DataKey.PAE_MATRIX, + DataKey.PLDDT_DF, + DataKey.AMINO_ACID_SEQUENCES_DF, + "messages", + "visualization", + } + + +def test_fetch_alphafold_monomer_metadata(tmp_path, monkeypatch): + monkeypatch.setattr(paths, "ALPHAFOLD_MONOMER_PATH", tmp_path / "alphafold_monomer") + monkeypatch.setattr( + paths, + "AF_MONOMER_METADATA_CSV_PATH", + tmp_path / "alphafold_monomer_metadata.csv", + ) + out = fetch_alphafold_protein_structure("Q8WP00", persist_upload=True) + + assert isinstance(out[DataKey.STRUCTURE_METADATA_DF], pd.DataFrame) + assert not out[DataKey.STRUCTURE_METADATA_DF].empty + assert out[DataKey.STRUCTURE_METADATA_DF].iloc[0]["uniprot_accession"] == "Q8WP00" + assert ( + out[DataKey.STRUCTURE_METADATA_DF].iloc[0]["model_created_date"] + == "2025-08-01T00:00:00Z" + ) + assert out[DataKey.STRUCTURE_METADATA_DF].iloc[0]["gene"] == "PRM1" + assert ( + out[DataKey.STRUCTURE_METADATA_DF].iloc[0]["model_used"] + == "AlphaFold Monomer v2.0 pipeline" + ) + + +def test_fetch_alphafold_files_exist(tmp_path, monkeypatch): + monkeypatch.setattr(paths, "ALPHAFOLD_MONOMER_PATH", tmp_path / "alphafold_monomer") + monkeypatch.setattr( + paths, + "AF_MONOMER_METADATA_CSV_PATH", + tmp_path / "alphafold_monomer_metadata.csv", + ) + + fetch_alphafold_protein_structure("Q8WP00", persist_upload=True) + + target_dir = (tmp_path / "alphafold_monomer") / "Q8WP00" + + assert target_dir.exists() + assert target_dir.is_dir() + + fasta_path = target_dir / "Q8WP00.fasta" + assert fasta_path.exists() + assert fasta_path.stat().st_size > 0 + + cif_files = sorted(target_dir.glob("*.cif")) + json_files = sorted(target_dir.glob("*.json")) + + # one mmCif file, confidence json and predicted aligned error + assert len(cif_files) == 1 + assert len(json_files) == 2 + + +def test_fetch_alphafold_dfs_exist(tmp_path, monkeypatch): + monkeypatch.setattr(paths, "ALPHAFOLD_MONOMER_PATH", tmp_path / "alphafold_monomer") + monkeypatch.setattr( + paths, + "AF_MONOMER_METADATA_CSV_PATH", + tmp_path / "alphafold_monomer_metadata.csv", + ) + + out = fetch_alphafold_protein_structure("Q8WP00", persist_upload=True) + + cif_df = out[DataKey.CIF_DF] + assert isinstance(cif_df, pd.DataFrame) + assert not cif_df.empty + assert any(col.startswith(ATOM_SITE_PREFIX) for col in cif_df.columns) + + pae_matrix = out[DataKey.PAE_MATRIX] + assert isinstance(pae_matrix, OutputItem) + assert len(pae_matrix.value) != 0 + + plddt_df = out[DataKey.PLDDT_DF] + assert isinstance(plddt_df, pd.DataFrame) + assert not plddt_df.empty + + seq_df = out[DataKey.AMINO_ACID_SEQUENCES_DF] + assert isinstance(seq_df, pd.DataFrame) + assert not seq_df.empty + + +def test_get_all_available_entry_ids_empty(tmp_path, monkeypatch): + metadata_csv = tmp_path / "alphafold_monomer_metadata.csv" + monkeypatch.setattr(paths, "AF_MONOMER_METADATA_CSV_PATH", metadata_csv) + + assert get_all_available_entry_ids_of_monomer_metadata() == [] + assert metadata_csv.exists() + + df = pd.read_csv(metadata_csv, dtype=str) + assert list(df.columns) == [ + "entry_id", + "uniprot_accession", + "model_created_date", + "gene", + "model_used", + ] + assert len(df) == 0 + + +def test_get_all_available_entry_ids_nonempty(tmp_path, monkeypatch): + metadata_csv = tmp_path / "alphafold_monomer_metadata.csv" + monkeypatch.setattr(paths, "AF_MONOMER_METADATA_CSV_PATH", metadata_csv) + df = pd.DataFrame([{"entry_id": "Q8WP00", "uniprot_accession": "Q8WP00"}]) + df.to_csv(metadata_csv, index=False) + + assert get_all_available_entry_ids_of_monomer_metadata() == ["Q8WP00"] + + +def test_get_prot_structure_dfs_no_entry(tmp_path, monkeypatch): + metadata_csv = tmp_path / "alphafold_monomer_metadata.csv" + monkeypatch.setattr(paths, "AF_MONOMER_METADATA_CSV_PATH", metadata_csv) + pd.DataFrame([{"entry_id": "OTHER", "uniprot_accession": "OTHER"}]).to_csv( + metadata_csv, index=False + ) + + with pytest.raises(ValueError, match=r"No metadata for Entry ID 'Q8WP00'"): + get_monomer_structure_dfs("Q8WP00") + + +def test_get_prot_structure_dfs_success(tmp_path, monkeypatch): + monkeypatch.setattr(paths, "ALPHAFOLD_MONOMER_PATH", tmp_path) + tmp_path.mkdir(parents=True, exist_ok=True) + metadata_csv = tmp_path / "alphafold_monomer_metadata.csv" + monkeypatch.setattr(paths, "AF_MONOMER_METADATA_CSV_PATH", metadata_csv) + + metadata = pd.DataFrame( + [ + { + "entry_id": "Q8WP00", + "uniprot_accession": "Q8WP00", + "model_created_date": "2025-08-01T00:00:00Z", + "gene": "PRM1", + "model_used": "AlphaFold Monomer v2.0 pipeline", + } + ] + ) + metadata.to_csv(metadata_csv, index=False) + + prot_dir = tmp_path / "Q8WP00" + prot_dir.mkdir(parents=True, exist_ok=True) + + cif = prot_dir / "test.cif" + cif.write_text( + """ +data_test +loop_ +_chem_comp.id +_chem_comp.mon_nstd_flag +SER y +# +loop_ +_atom_site.id +_atom_site.type_symbol +_atom_site.label_atom_id +_atom_site.label_comp_id +_atom_site.Cartn_x +1 N N SER 1.0 +2 C CA SER 2.0 +""" + ) + + fasta = prot_dir / "Q8WP00.fasta" + fasta.write_text(">alpha|Q8WP00\nAAAA\n") + + pae = prot_dir / "pae.json" + plddt = prot_dir / "plddt.json" + pae_data = {"predicted_aligned_error": [0.1]} + with open(pae, "w") as f: + json.dump(pae_data, f) + + plddt_data = [{"residueNumber": 1, "confidenceScore": 90}] + with open(plddt, "w") as f: + json.dump(plddt_data, f) + + out = get_monomer_structure_dfs("Q8WP00") + + assert isinstance(out[DataKey.STRUCTURE_METADATA_DF], pd.DataFrame) + assert not out[DataKey.STRUCTURE_METADATA_DF].empty + assert out[DataKey.STRUCTURE_METADATA_DF].iloc[0]["entry_id"] == "Q8WP00" + + cif_df = out[DataKey.CIF_DF] + assert isinstance(cif_df, pd.DataFrame) + assert not cif_df.empty + assert list(cif_df.columns) == [ + ATOM_SITE_COLUMNS.ID, + ATOM_SITE_COLUMNS.TYPE_SYMBOL, + ATOM_SITE_COLUMNS.LABEL_ATOM_ID, + ATOM_SITE_COLUMNS.LABEL_COMP_ID, + ATOM_SITE_COLUMNS.CARTN_X, + CHEM_COMP_COLUMNS.MON_NSTD_FLAG, + ] + assert len(cif_df) == 2 + assert cif_df[ATOM_SITE_COLUMNS.ID].tolist() == [1, 2] + assert cif_df[ATOM_SITE_COLUMNS.TYPE_SYMBOL].tolist() == ["N", "C"] + assert cif_df[ATOM_SITE_COLUMNS.LABEL_ATOM_ID].tolist() == ["N", "CA"] + assert cif_df[ATOM_SITE_COLUMNS.CARTN_X].tolist() == [1.0, 2.0] + assert cif_df[CHEM_COMP_COLUMNS.MON_NSTD_FLAG].tolist() == [True, True] + + assert isinstance(out[DataKey.PAE_MATRIX], OutputItem) + assert isinstance(out[DataKey.PAE_MATRIX].value, np.ndarray) + assert ( + out[DataKey.PAE_MATRIX].value == 0.1 + ) # 0D array (only one value) TODO: Change this to something more reasonable? idk + + assert isinstance(out[DataKey.PLDDT_DF], pd.DataFrame) + assert not out[DataKey.PLDDT_DF].empty + assert out[DataKey.PLDDT_DF]["residueNumber"].tolist() == [1] + assert out[DataKey.PLDDT_DF]["confidenceScore"].tolist() == [90] + + assert isinstance(out[DataKey.AMINO_ACID_SEQUENCES_DF], pd.DataFrame) + assert not out[DataKey.AMINO_ACID_SEQUENCES_DF].empty + assert out[DataKey.AMINO_ACID_SEQUENCES_DF]["Protein ID"].tolist() == ["Q8WP00-1"] + assert out[DataKey.AMINO_ACID_SEQUENCES_DF]["Protein Sequence"].tolist() == ["AAAA"] + + assert any(d.get("level") == logging.INFO for d in out["messages"]) or any( + "Successfully loaded" in d.get("msg", "") for d in out["messages"] + ) + + +def test_get_monomer_and_multimer_metadata_df_create(tmp_path, monkeypatch): + mon_csv = tmp_path / "alphafold_monomer_metadata.csv" + multi_csv = tmp_path / "alphafold_multimer_metadata.csv" + monkeypatch.setattr(paths, "AF_MONOMER_METADATA_CSV_PATH", mon_csv) + monkeypatch.setattr(paths, "AF_MULTIMER_METADATA_CSV_PATH", multi_csv) + + mon_df = get_monomer_metadata_df() + assert isinstance(mon_df, pd.DataFrame) + assert list(mon_df.columns) == [ + "entry_id", + "uniprot_accession", + "model_created_date", + "gene", + "model_used", + ] + assert mon_csv.exists() + + multi_df = get_multimer_metadata_df() + assert isinstance(multi_df, pd.DataFrame) + assert list(multi_df.columns) == [ + "entry_id", + "uniprot_ids", + "model_created_date", + "model_used", + ] + assert multi_csv.exists() + + +def test_get_correct_af_directories_persist_and_temp(tmp_path): + # persist_upload True + temp, work = get_correct_af_directories("abc", tmp_path, True) + assert temp is None + assert work == tmp_path / "ABC" + assert work.exists() + + # persist_upload False -> temporary directory created + temp2, work2 = get_correct_af_directories("xyz", tmp_path, False) + assert temp2 is not None + assert Path(work2).exists() + # cleanup + shutil.rmtree(temp2, ignore_errors=True) + + +def test_extend_metadata_csv_overwrite_and_new(tmp_path): + csv_path = tmp_path / "meta.csv" + existing = pd.DataFrame([{"entry_id": "A", "x": "1"}, {"entry_id": "B", "x": "2"}]) + existing.to_csv(csv_path, index=False) + + messages = [] + new_md = pd.DataFrame([{"entry_id": "A", "x": "9"}]) + extend_metadata_csv("A", csv_path, existing, new_md, messages) + out = pd.read_csv(csv_path, dtype=str) + # entry A should be the updated one, B preserved + assert set(out["entry_id"].tolist()) == {"A", "B"} + assert out[out["entry_id"] == "A"]["x"].iloc[0] == "9" + + # when not present, should write only the provided metadata_df + csv2 = tmp_path / "meta2.csv" + messages2 = [] + extend_metadata_csv( + "C", + csv2, + pd.DataFrame(columns=["entry_id"]), + pd.DataFrame([{"entry_id": "C", "y": "7"}]), + messages2, + ) + out2 = pd.read_csv(csv2, dtype=str) + assert out2.iloc[0]["entry_id"] == "C" + + +def test_get_amino_acid_sequences_df_and_handle_files(tmp_path, monkeypatch): + # create a fasta and call get_amino_acid_sequences_df directly + fasta = tmp_path / "P.fasta" + fasta.write_text(">alpha|P\nTESTSEQ\n") + messages = [] + seq_df = get_amino_acid_sequences_df(fasta, messages) + assert isinstance(seq_df, pd.DataFrame) + assert not seq_df.empty + + # test handle_alphafold_files with no remote files (should still create fasta) + metadata_df = pd.DataFrame([{"entry_id": "P", "uniprot_accession": "P"}]) + out = handle_alphafold_files( + {}, "P", "TESTSEQ", metadata_df, "P", persist_upload=False + ) + assert DataKey.AMINO_ACID_SEQUENCES_DF in out + assert isinstance(out[DataKey.CIF_DF], pd.DataFrame) and out[DataKey.CIF_DF].empty + assert isinstance(out["pae_df"], pd.DataFrame) and out["pae_df"].empty + assert ( + isinstance(out[DataKey.PLDDT_DF], pd.DataFrame) and out[DataKey.PLDDT_DF].empty + ) + assert isinstance(out[DataKey.AMINO_ACID_SEQUENCES_DF], pd.DataFrame) + + +def test_upload_multimer_prediction_basic(tmp_path, monkeypatch): + monkeypatch.setattr(paths, "ALPHAFOLD_MULTIMER_PATH", tmp_path) + + # prepare files + fasta = tmp_path / "seqs.fasta" + fasta.write_text(">alpha|X\nAAAA\n") + cif = tmp_path / "m.cif" + # Note that we only write the absolutely necessary columns here + # Also this is not biologically plausible + cif.write_text( + """ + data_test + loop_ + _chem_comp.id + _chem_comp.mon_nstd_flag + SER y + GLY y + # + loop_ + _atom_site.id + _atom_site.label_atom_id + _atom_site.label_comp_id + _atom_site.auth_asym_id + _atom_site.label_seq_id + _atom_site.B_iso_or_equiv + 1 N SER A 1 99.99 + 2 CA SER A 1 67.76 + 3 CA SER A 2 33.65 + 4 O SER A 2 5.52 + 5 N GLY B 1 0 + 6 CA GLY B 1 13.37 + # + """ + ) + conf = tmp_path / "conf.json" + conf.write_text( + '{"chain_iptm": [0.42, 0.89]}' + ) # Note that we do not use these metrics anywhere + full = tmp_path / "full.json" + full.write_text( + '{"random_column": [1,2], "pae": [[1, 2], [3, 4]], "token_res_ids": [1, 2, 1]}' + ) + job_request = tmp_path / "job_request.json" + job_request.write_text( + json.dumps( + [ + { + "name": "test_job", + "modelSeeds": ["123456789"], + "sequences": [ + { + "proteinChain": { + "sequence": "PE", + "count": 1, + "useStructureTemplate": True, + } + }, + { + "proteinChain": { + "sequence": "T", + "count": 1, + "useStructureTemplate": True, + } + }, + ], + "dialect": "alphafoldserver", + "version": 3, + } + ] + ) + ) + + # monkeypatch copy to actually copy files + def _copy(src, dest_dir): + target = Path(dest_dir) / Path(src).name + shutil.copy(src, target) + return True, "" + + monkeypatch.setattr( + "backend.protzilla.importing.alphafold_protein_structure_load.copy_file_to_directory", + _copy, + ) + + out = upload_multimer_prediction( + entry_id="M1", + uniprot_ids="X, Y", + model_used="m", + amino_acid_sequences=fasta, + cif_file=cif, + confidence_file=conf, + full_data_file=full, + job_request_file=job_request, + persist_upload=True, + ) + + assert isinstance(out[DataKey.STRUCTURE_METADATA_DF], pd.DataFrame) + # check metadata contents + mdf = out[DataKey.STRUCTURE_METADATA_DF] + assert mdf.iloc[0]["entry_id"] == "M1" + assert mdf.iloc[0]["uniprot_ids"] == ["X", "Y"] + assert mdf.iloc[0]["model_used"] == "m" + + # cif contents + cif_df = out[DataKey.CIF_DF] + assert isinstance(cif_df, pd.DataFrame) + assert list(cif_df.columns) == [ + ATOM_SITE_COLUMNS.ID, + ATOM_SITE_COLUMNS.LABEL_ATOM_ID, + ATOM_SITE_COLUMNS.LABEL_COMP_ID, + ATOM_SITE_COLUMNS.AUTH_ASYM_ID, + ATOM_SITE_COLUMNS.LABEL_SEQ_ID, + ATOM_SITE_COLUMNS.B_ISO_OR_EQUIV, + CHEM_COMP_COLUMNS.MON_NSTD_FLAG, + ] + assert cif_df[ATOM_SITE_COLUMNS.ID].tolist() == list(range(1, 7)) + assert cif_df[ATOM_SITE_COLUMNS.LABEL_ATOM_ID].tolist() == [ + "N", + "CA", + "CA", + "O", + "N", + "CA", + ] + assert cif_df[ATOM_SITE_COLUMNS.AUTH_ASYM_ID].tolist() == ["A"] * 4 + ["B"] * 2 + assert cif_df[ATOM_SITE_COLUMNS.B_ISO_OR_EQUIV].tolist() == [ + 99.99, + 67.76, + 33.65, + 5.52, + 0, + 13.37, + ] + assert cif_df[ATOM_SITE_COLUMNS.LABEL_COMP_ID].tolist() == ["SER"] * 4 + ["GLY"] * 2 + assert cif_df[CHEM_COMP_COLUMNS.MON_NSTD_FLAG].tolist() == [True] * 6 + + # confidence JSON + conf_df = out[DataKey.CONFIDENCE_DF] + assert isinstance(conf_df, pd.DataFrame) + assert conf_df["chain_iptm"].tolist() == [0.42, 0.89] + + # full data normalization + full_df = out[DataKey.FULL_DATA_DF] + assert isinstance(full_df, pd.DataFrame) + assert list(full_df.columns) == ["random_column"] + assert full_df.iloc[0]["random_column"] == [1, 2] + + # job request JSON + job_df = out[DataKey.JOB_REQUEST_DF] + assert isinstance(job_df, pd.DataFrame) + assert job_df.iloc[0]["name"] == "test_job" + assert job_df.iloc[0]["dialect"] == "alphafoldserver" + + # sequences + seqs = out[DataKey.AMINO_ACID_SEQUENCES_DF] + assert isinstance(seqs, pd.DataFrame) + assert seqs["Protein Sequence"].tolist() == ["AAAA"] + assert any(str(v).startswith("X") for v in seqs["Protein ID"].tolist()) + + # pLDDT values + plddt_df = out[DataKey.PLDDT_DF] + assert isinstance(plddt_df, pd.DataFrame) + assert list(plddt_df.columns) == ["chainID", "residueNumber", "confidenceScore"] + assert plddt_df["confidenceScore"].tolist() == [ + 67.76, + 33.65, + 13.37, + ] # Keep only CA atoms + + # PAE values + pae_matrix = out[DataKey.PAE_MATRIX].value + assert isinstance(pae_matrix, np.ndarray) + assert pae_matrix[0, 0] == 1 + assert pae_matrix[0, 1] == 2 + assert pae_matrix[1, 0] == 3 + assert pae_matrix[1, 1] == 4 + + upload_dir = tmp_path / "M1" + assert upload_dir.exists() + assert any(upload_dir.glob("*.fasta")) or any(upload_dir.glob("*.fa")) + assert any(upload_dir.glob("*.json")) + assert any(upload_dir.glob("*.cif")) + + # Test no plDDT Data -> plddt_df should be None + cif.write_text( + """ + data_test + loop_ + _chem_comp.id + _chem_comp.mon_nstd_flag + SER y + GLY y + # + loop_ + _atom_site.id + _atom_site.label_atom_id + _atom_site.label_comp_id + _atom_site.auth_asym_id + _atom_site.label_seq_id + 1 N SER A 1 + 2 CA SER A 1 + 3 CA SER A 2 + 4 O SER A 2 + 5 N GLY B 1 + 6 CA GLY B 1 + # + """ + ) + + out = upload_multimer_prediction( + entry_id="M1", + uniprot_ids="X, Y", + model_used="m", + amino_acid_sequences=fasta, + cif_file=cif, + confidence_file=conf, + full_data_file=full, + job_request_file=job_request, + persist_upload=True, + ) + + assert out[DataKey.PLDDT_DF] is None + + +# Additional comprehensive tests for error cases and edge cases +def test_get_monomer_metadata_df_existing_csv(tmp_path, monkeypatch): + """Test reading existing monomer metadata CSV""" + csv_path = tmp_path / "alphafold_monomer_metadata.csv" + monkeypatch.setattr(paths, "AF_MONOMER_METADATA_CSV_PATH", csv_path) + + # create and write existing CSV + existing_data = pd.DataFrame( + [ + { + "entry_id": "P1", + "uniprot_accession": "P1", + "model_created_date": "2025-01-01", + "gene": "G1", + "model_used": "m1", + } + ] + ) + existing_data.to_csv(csv_path, index=False) + + # read it back + df = get_monomer_metadata_df() + assert len(df) == 1 + assert df.iloc[0]["entry_id"] == "P1" + assert df.iloc[0]["gene"] == "G1" + + +def test_get_multimer_metadata_df_existing_csv(tmp_path, monkeypatch): + """Test reading existing multimer metadata CSV""" + csv_path = tmp_path / "alphafold_multimer_metadata.csv" + monkeypatch.setattr(paths, "AF_MULTIMER_METADATA_CSV_PATH", csv_path) + + existing_data = pd.DataFrame( + [ + { + "entry_id": "M1", + "uniprot_ids": "P1,P2", + "model_created_date": "2025-01-01", + "model_used": "m1", + } + ] + ) + existing_data.to_csv(csv_path, index=False) + + df = get_multimer_metadata_df() + assert len(df) == 1 + assert df.iloc[0]["entry_id"] == "M1" + + +def test_to_fasta_empty_sequence(): + """Test to_fasta with empty sequence""" + with pytest.raises( + ValueError, match="Sequence must be a single, whitespace-free string" + ): + to_fasta("") + + +def test_to_fasta_lowercase_conversion(): + """Test that lowercase sequences are converted to uppercase""" + result = to_fasta("acdefg", "test", 10) + assert "ACDEFG" in result + assert "acdefg" not in result + + +def test_upload_multimer_prediction_no_persist(tmp_path, monkeypatch): + """ + Test upload_multimer_prediction with persist_upload=False. + Also tests full_data without PAE values + """ + monkeypatch.setattr(paths, "ALPHAFOLD_MONOMER_PATH", tmp_path) + monkeypatch.setattr(paths, "ALPHAFOLD_MULTIMER_PATH", tmp_path) + + fasta = tmp_path / "seqs.fasta" + fasta.write_text(">alpha|X\nAAAA\n") + cif = tmp_path / "m.cif" + cif.write_text( + "data_test\nloop_\n_chem_comp.id\n_chem_comp.mon_nstd_flag\nSER y\nloop_\n#\n_atom_site.id\n_atom_site.label_comp_id\nN SER\n" + ) + conf = tmp_path / "conf.json" + conf.write_text('[{"residueNumber":1, "confidenceScore":99}]') + full = tmp_path / "full.json" + full.write_text('{"a": [1,2], "token_res_ids": [1], "pae": [[2]]}') + job_request = tmp_path / "job_request.json" + job_request.write_text( + json.dumps( + [ + { + "name": "test_job_2", + "modelSeeds": ["987654321"], + "sequences": [ + { + "proteinChain": { + "sequence": "BBBB", + "count": 1, + "useStructureTemplate": True, + } + } + ], + "dialect": "alphafoldserver", + "version": 3, + } + ] + ) + ) + + out = upload_multimer_prediction( + entry_id="M2", + uniprot_ids="Y", + model_used="test", + amino_acid_sequences=fasta, + cif_file=cif, + confidence_file=conf, + full_data_file=full, + job_request_file=job_request, + persist_upload=False, + ) + + # verify dataframes are returned + assert isinstance(out[DataKey.STRUCTURE_METADATA_DF], pd.DataFrame) + assert isinstance(out[DataKey.CIF_DF], pd.DataFrame) + assert isinstance(out[DataKey.JOB_REQUEST_DF], pd.DataFrame) + assert out[DataKey.PAE_MATRIX].value is not None + assert out[DataKey.JOB_REQUEST_DF].iloc[0]["name"] == "test_job_2" + # directory should still exist (created for the entry) + upload_dir = tmp_path / "M2" + assert not upload_dir.exists() + + +def test_get_prot_structure_dfs_missing_cif(tmp_path, monkeypatch): + """Test get_prot_structure_dfs when CIF file is missing""" + metadata_csv = tmp_path / "alphafold_monomer_metadata.csv" + monkeypatch.setattr(paths, "AF_MONOMER_METADATA_CSV_PATH", metadata_csv) + monkeypatch.setattr(paths, "ALPHAFOLD_MONOMER_PATH", tmp_path) + + metadata = pd.DataFrame([{"entry_id": "NOCIF", "uniprot_accession": "NOCIF"}]) + metadata.to_csv(metadata_csv, index=False) + + prot_dir = tmp_path / "NOCIF" + prot_dir.mkdir(parents=True, exist_ok=True) + + with pytest.raises(FileNotFoundError, match="No CIF file found"): + get_monomer_structure_dfs("NOCIF") + + +def test_get_prot_structure_dfs_missing_fasta(tmp_path, monkeypatch): + """Test get_prot_structure_dfs when FASTA file is missing""" + metadata_csv = tmp_path / "alphafold_monomer_metadata.csv" + monkeypatch.setattr(paths, "AF_MONOMER_METADATA_CSV_PATH", metadata_csv) + monkeypatch.setattr(paths, "ALPHAFOLD_MONOMER_PATH", tmp_path) + + metadata = pd.DataFrame([{"entry_id": "NOFASTA", "uniprot_accession": "NOFASTA"}]) + metadata.to_csv(metadata_csv, index=False) + + prot_dir = tmp_path / "NOFASTA" + prot_dir.mkdir(parents=True, exist_ok=True) + + # create CIF but no FASTA + cif = prot_dir / "test.cif" + cif.write_text( + "data_test\nloop_\n_chem_comp.id\n_chem_comp.mon_nstd_flag\nSER y\nloop_\n#\n_atom_site.id\n_atom_site.label_comp_id\nN SER\n" + ) + + with pytest.raises(FileNotFoundError, match="No FASTA file found"): + get_monomer_structure_dfs("NOFASTA") + + +def test_get_prot_structure_dfs_missing_json(tmp_path, monkeypatch): + """Test get_prot_structure_dfs when JSON files are missing""" + metadata_csv = tmp_path / "alphafold_monomer_metadata.csv" + monkeypatch.setattr(paths, "AF_MONOMER_METADATA_CSV_PATH", metadata_csv) + monkeypatch.setattr(paths, "ALPHAFOLD_MONOMER_PATH", tmp_path) + + metadata = pd.DataFrame([{"entry_id": "NOJSON", "uniprot_accession": "NOJSON"}]) + metadata.to_csv(metadata_csv, index=False) + + prot_dir = tmp_path / "NOJSON" + prot_dir.mkdir(parents=True, exist_ok=True) + + # create CIF and FASTA but no JSON + cif = prot_dir / "test.cif" + cif.write_text( + "data_test\nloop_\n_chem_comp.id\n_chem_comp.mon_nstd_flag\nSER y\nloop_\n#\n_atom_site.id\n_atom_site.label_comp_id\nN SER\n" + ) + + fasta = prot_dir / "test.fasta" + # valid header for parse_fasta_id (expects at least one "|" in the id) + fasta.write_text(">alpha|NOJSON\nAAAA\n") + + with pytest.raises(FileNotFoundError, match="No JSON files"): + get_monomer_structure_dfs("NOJSON") + + +def test_extend_metadata_csv_empty_existing(tmp_path): + """Test extend_metadata_csv with empty existing DataFrame""" + csv_path = tmp_path / "meta_empty.csv" + existing = pd.DataFrame(columns=["entry_id", "x"]) + existing.to_csv(csv_path, index=False) + + messages = [] + new_md = pd.DataFrame([{"entry_id": "Z", "x": "new"}]) + extend_metadata_csv("Z", csv_path, existing, new_md, messages) + + out = pd.read_csv(csv_path, dtype=str) + assert out.iloc[0]["entry_id"] == "Z" + + +def test_get_all_available_entry_ids_of_multimer_metadata_empty(tmp_path, monkeypatch): + metadata_csv = tmp_path / "alphafold_multimer_metadata.csv" + monkeypatch.setattr(paths, "AF_MULTIMER_METADATA_CSV_PATH", metadata_csv) + + assert get_all_available_entry_ids_of_multimer_metadata() == [] + assert metadata_csv.exists() + + df = pd.read_csv(metadata_csv, dtype=str) + assert list(df.columns) == [ + "entry_id", + "uniprot_ids", + "model_created_date", + "model_used", + ] + assert len(df) == 0 + + +def test_get_all_available_entry_ids_of_multimer_metadata_nonempty( + tmp_path, monkeypatch +): + metadata_csv = tmp_path / "alphafold_multimer_metadata.csv" + monkeypatch.setattr(paths, "AF_MULTIMER_METADATA_CSV_PATH", metadata_csv) + + df = pd.DataFrame( + [ + { + "entry_id": "M1", + "uniprot_ids": "P1,P2", + "model_created_date": "2025-01-01T00:00:00Z", + "model_used": "test", + } + ] + ) + df.to_csv(metadata_csv, index=False) + + assert get_all_available_entry_ids_of_multimer_metadata() == ["M1"] + + +def test_check_and_get_metadata_df_success(tmp_path): + all_df = pd.DataFrame( + [ + {"entry_id": "A", "x": "1"}, + {"entry_id": "B", "x": "2"}, + ] + ) + out = check_and_get_metadata_df("B", all_df, tmp_path / "meta.csv") + assert isinstance(out, pd.DataFrame) + assert len(out) == 1 + assert out.iloc[0]["entry_id"] == "B" + + +def test_check_dir_missing_raises(tmp_path): + d = tmp_path / "MISSING" + with pytest.raises(FileNotFoundError, match="AlphaFold data directory not found"): + check_dir("MISSING", d) + + +def test_get_json_files_in_dir_success(tmp_path): + d = tmp_path / "D" + d.mkdir() + (d / "a.json").write_text('{"x": 1}') + (d / "b.json").write_text('{"y": 2}') + files = get_json_files_in_dir("E1", d) + assert len(files) == 2 + assert all(f.suffix == ".json" for f in files) + + +def test_get_json_files_in_dir_missing_raises(tmp_path): + d = tmp_path / "D" + d.mkdir() + with pytest.raises(FileNotFoundError, match="No JSON files found"): + get_json_files_in_dir("E1", d) + + +def test_get_cif_df_from_disk_multiple_cif_warns(tmp_path): + d = tmp_path / "E1" + d.mkdir() + + cif1 = d / "a.cif" + cif2 = d / "b.cif" + cif1.write_text( + """ +data_test +loop_ +_chem_comp.id +_chem_comp.mon_nstd_flag +SER y +# +loop_ +_atom_site.id +_atom_site.type_symbol +_atom_site.label_comp_id +N N SER +""" + ) + cif2.write_text( + """ +data_test +loop_ +_chem_comp.id +_chem_comp.mon_nstd_flag +SER y +# +loop_ +_atom_site.id +_atom_site.type_symbol +_atom_site.label_comp_id +CA C SER +""" + ) + + messages = [] + df = get_cif_df_from_disk("E1", d, messages) + assert isinstance(df, pd.DataFrame) + assert not df.empty + assert any(m.get("level") == logging.WARNING for m in messages) + + +def test_get_multimer_structure_dfs_success(tmp_path, monkeypatch): + monkeypatch.setattr(paths, "ALPHAFOLD_MULTIMER_PATH", tmp_path / "multimer") + monkeypatch.setattr( + paths, + "AF_MULTIMER_METADATA_CSV_PATH", + tmp_path / "alphafold_multimer_metadata.csv", + ) + + paths.ALPHAFOLD_MULTIMER_PATH.mkdir(parents=True, exist_ok=True) + + md = pd.DataFrame( + [ + { + "entry_id": "M1", + "uniprot_ids": "P1,P2", + "model_created_date": "2025-01-01T00:00:00Z", + "model_used": "Multimer", + } + ] + ) + md.to_csv(paths.AF_MULTIMER_METADATA_CSV_PATH, index=False) + + prot_dir = paths.ALPHAFOLD_MULTIMER_PATH / "M1" + prot_dir.mkdir(parents=True, exist_ok=True) + + cif = prot_dir / "m1.cif" + cif.write_text( + """ +data_test +loop_ +_chem_comp.id +_chem_comp.mon_nstd_flag +SER y +# +loop_ +_atom_site.id +_atom_site.type_symbol +_atom_site.label_comp_id +N N SER +""" + ) + + fasta = prot_dir / "m1.fasta" + fasta.write_text(">alpha|M1\nAAAA\n") + + confidence = prot_dir / "confidence.json" + full_data = prot_dir / "full.json" + job_request = prot_dir / "job_request.json" + confidence.write_text(json.dumps({"chain_iptm": [0.75]})) + full_data.write_text( + json.dumps({"pae": [[0.1, 0.2], [0.3, 0.4]], "token_res_ids": [1]}) + ) + job_request.write_text( + json.dumps( + [ + { + "name": "multimer_job", + "modelSeeds": ["111111111"], + "sequences": [ + { + "proteinChain": { + "sequence": "AAAA", + "count": 2, + "useStructureTemplate": True, + } + } + ], + "dialect": "alphafoldserver", + "version": 3, + } + ] + ) + ) + + out = get_multimer_structure_dfs("M1") + assert isinstance(out[DataKey.STRUCTURE_METADATA_DF], pd.DataFrame) + assert isinstance(out[DataKey.CIF_DF], pd.DataFrame) + assert isinstance(out[DataKey.AMINO_ACID_SEQUENCES_DF], pd.DataFrame) + assert isinstance(out[DataKey.CONFIDENCE_DF], pd.DataFrame) + assert isinstance(out[DataKey.FULL_DATA_DF], pd.DataFrame) + assert isinstance(out[DataKey.JOB_REQUEST_DF], pd.DataFrame) + + assert "chain_iptm" in out[DataKey.CONFIDENCE_DF].columns + assert "pae" not in out[DataKey.FULL_DATA_DF].columns + assert out[DataKey.JOB_REQUEST_DF].iloc[0]["name"] == "multimer_job" + assert out[DataKey.JOB_REQUEST_DF].iloc[0]["version"] == 3 + + assert any(m.get("level") == logging.INFO for m in out["messages"]) or any( + "Successfully loaded" in str(m.get("msg", "")) for m in out["messages"] + ) + + +def test_get_multimer_structure_dfs_json_fallback_warns(tmp_path, monkeypatch): + monkeypatch.setattr(paths, "ALPHAFOLD_MULTIMER_PATH", tmp_path / "multimer") + monkeypatch.setattr( + paths, + "AF_MULTIMER_METADATA_CSV_PATH", + tmp_path / "alphafold_multimer_metadata.csv", + ) + + paths.ALPHAFOLD_MULTIMER_PATH.mkdir(parents=True, exist_ok=True) + + md = pd.DataFrame( + [ + { + "entry_id": "M2", + "uniprot_ids": "P1,P2", + "model_created_date": "2025-01-01T00:00:00Z", + "model_used": "Multimer", + } + ] + ) + md.to_csv(paths.AF_MULTIMER_METADATA_CSV_PATH, index=False) + + prot_dir = paths.ALPHAFOLD_MULTIMER_PATH / "M2" + prot_dir.mkdir(parents=True, exist_ok=True) + + cif = prot_dir / "m2.cif" + cif.write_text( + """ +data_test +loop_ +_chem_comp.id +_chem_comp.mon_nstd_flag +SER y +# +loop_ +_atom_site.id +_atom_site.type_symbol +_atom_site.label_comp_id +N N SER +""" + ) + + fasta = prot_dir / "m2.fasta" + fasta.write_text(">alpha|M2\nAAAA\n") + + j1 = prot_dir / "j1.json" + j2 = prot_dir / "j2.json" + j3 = prot_dir / "j3.json" + j1.write_text(json.dumps({"wrong_key": 1})) + j2.write_text(json.dumps({"pae": 2})) + j3.write_text(json.dumps({"sequences": 3})) + + with pytest.raises(RuntimeError) as exc_info: + get_multimer_structure_dfs("M2") + + assert "Failed to read JSON files in" in str(exc_info.value) + assert "Could not detect confidence scores/full data/job request" in str( + exc_info.value + ) diff --git a/backend/tests/protzilla/importing/test_crosslinking_import.py b/backend/tests/protzilla/importing/test_crosslinking_import.py new file mode 100644 index 000000000..d6e26ade0 --- /dev/null +++ b/backend/tests/protzilla/importing/test_crosslinking_import.py @@ -0,0 +1,295 @@ +import pytest +import pandas as pd +from unittest.mock import patch, Mock +from requests.exceptions import Timeout +from backend.protzilla.importing.crosslinking_import import ( + aggregate_data, + remove_brackets_from_peptide, + get_amino_acid_where_crosslink_is_connected_proteomediscoverer_xlinkx_format, + validate_data_before_lookup, + execute_uniprot_request, + process_uniprot_response, + iterate_for_protein_designation, + get_missing_protein_designation, + crosslinking_import, +) + + +def test_aggregate_data(): + df = pd.DataFrame({"Protein1": ["A", "B", None], "Protein2": ["C", "B", "D"]}) + result = aggregate_data(df, "Protein") + assert result == {"A", "B", "C", "D"} + + +def test_remove_brackets_from_peptide(): + assert remove_brackets_from_peptide("[ABC]DE[FG]") == "ABCDEFG" + + +def test_get_amino_acid_where_crosslink_is_connected_proteomediscoverer_xlinkx_format(): + assert ( + get_amino_acid_where_crosslink_is_connected_proteomediscoverer_xlinkx_format( + "[ACD]EF" + ) + == 1 + ) + assert ( + get_amino_acid_where_crosslink_is_connected_proteomediscoverer_xlinkx_format( + "ACDEF" + ) + == 0 + ) + + +def test_validate_data_before_lookup(): + data = {"A", "B", "C"} + + def validator(x): + return x != "B" + + valid, results = validate_data_before_lookup(data, validator, "ERROR") + assert valid == {"A", "C"} + assert results == {"B": (False, None, "ERROR")} + + +def test_validate_data_before_lookup_empty(): + valid, results = validate_data_before_lookup(set(), lambda x: True, "ERROR") + assert valid == set() + assert results == {} + + +def test_execute_uniprot_request_success(): + mock_response = Mock() + mock_response.raise_for_status.return_value = None + results = {} + valid_data = {"P12345"} + with patch( + "protzilla.importing.crosslinking_import.requests.get", + return_value=mock_response, + ): + response = execute_uniprot_request( + "url", {"param": "value"}, valid_data, results + ) + assert response == mock_response + assert results == {} + + +def test_execute_uniprot_request_timeout(): + results = {} + valid_data = {"P12345"} + with patch( + "protzilla.importing.crosslinking_import.requests.get", + side_effect=Timeout(), + ): + response = execute_uniprot_request( + "url", {"param": "value"}, valid_data, results + ) + assert response is None + assert results["P12345"][2] == "TIMEOUT" + + +def test_process_uniprot_response_id_to_gene_name(): + results = {} + input_data = {"P1", "P2"} + + mock_response = Mock() + mock_response.text = "Entry\tGene Names (primary)\n" "P1\tGENE1\n" "P2\t\n" + + process_uniprot_response( + response=mock_response, + results=results, + input_data=input_data, + mode="id_to_gene_name", + ) + + assert results["P1"] == (True, "GENE1", None) + + +def test_uniprot_lookup_successful_request_but_no_results(monkeypatch): + from backend.protzilla.importing.crosslinking_import import uniprot_lookup + + def mock_execute(*args, **kwargs): + mock = Mock() + mock.text = "Entry\tGene Names (primary)\n" + return mock + + monkeypatch.setattr( + "backend.protzilla.importing.crosslinking_import.execute_uniprot_request", + mock_execute, + ) + + results = {} + uniprot_lookup( + input_data={"P1"}, + mode="id_to_gene_name", + results=results, + ) + + assert results == {} + + +def _minimal_valid_crosslinking_df(): + return pd.DataFrame( + { + "Protein_id1": ["P1"], + "Protein_id2": ["P2"], + "Protein1": ["GENE1"], + "Protein2": ["GENE2"], + "Is_intra_crosslink": [False], + "Crosslinker": ["DSS"], + "Peptide1": ["AAA"], + "Peptide2": ["BBB"], + "CL_position_within_peptide1": [1], + "CL_position_within_peptide2": [2], + "Q_value": [0.01], + } + ) + + +def test_iterate_for_protein_designation(): + df = _minimal_valid_crosslinking_df() + lookup_results = { + "P1": (True, "GENE1", None), + "P2": (True, "GENE2", None), + } + good_df, failed_df = iterate_for_protein_designation( + df, + "Protein_id", + "Protein", + lookup_results, + ) + + assert len(good_df) == 1 + assert failed_df.empty + + +def test_get_missing_protein_designation(): + df = _minimal_valid_crosslinking_df() + + def mock_lookup(ids): + return {pid: (True, f"Gene_{pid}", None) for pid in ids} + + good_df, failed_df = get_missing_protein_designation( + df, + "Protein_id", + "Protein", + mock_lookup, + ) + + assert len(good_df) == 1 + assert failed_df.empty + + +@pytest.mark.parametrize( + "input_string, mock_result, expected", + [ + ( + "9606,10090", + { + "uids": ["9606", "10090"], + "9606": {"scientificname": "Homo sapiens"}, + "10090": {"scientificname": "Mus musculus"}, + }, + (True, ["9606", "10090"], ["Homo sapiens", "Mus musculus"], None), + ), + ( + "9606,9999", + { + "uids": ["9606"], + "9606": {"scientificname": "Homo sapiens"}, + }, + (False, "9999", None, "ORGANISM_ID_NOT_FOUND"), + ), + ], +) +def test_process_organism_id_from_text_field( + monkeypatch, input_string, mock_result, expected +): + from backend.protzilla.importing.crosslinking_import import ( + process_organism_id_from_text_field, + ) + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"result": mock_result} + + monkeypatch.setattr( + "protzilla.importing.crosslinking_import.requests.get", + lambda *args, **kwargs: mock_response, + ) + + result = process_organism_id_from_text_field(input_string) + + assert result == expected + + +def test_aggregate_failed_proteins_for_display(): + df = pd.DataFrame( + { + "Protein1": ["A"], + "Protein2": ["B"], + "Protein1_error": ["ERR1"], + "Protein2_error": [None], + } + ) + + from backend.protzilla.importing.crosslinking_import import ( + aggregate_failed_proteins_for_display, + ) + + result = aggregate_failed_proteins_for_display(df) + assert result == "A -> ERR1" + + +def test_crosslinking_import_csv(tmp_path): + csv_file = tmp_path / "test.csv" + csv_file.write_text( + "Protein1,Protein2,Peptide1,Peptide2," + "CL_position_within_peptide1,CL_position_within_peptide2," + "Crosslinker,Q_value\n" + "RAD50,MRE11,AAA,BBB,1,2,DSS,0.01\n" + ) + + with patch( + "protzilla.importing.crosslinking_import.get_protein_ids_from_gene_name", + return_value={ + "RAD50": (True, "P12345", None), + "MRE11": (True, "Q67890", None), + }, + ): + result = crosslinking_import(csv_file, organism_ids="9606") + + assert "crosslinking_df" in result + assert not result["crosslinking_df"].empty + + +def test_crosslinking_import_xlsx(monkeypatch, tmp_path): + xlsx = tmp_path / "test.xlsx" + pd.DataFrame( + { + "Protein_id1": ["P1"], + "Protein_id2": ["P2"], + "Peptide1": ["[AAA]"], + "Peptide2": ["[BBB]"], + "Is_intra_crosslink": ["Intra"], + "CL_position_within_peptide1": [1], + "CL_position_within_peptide2": [2], + "Crosslinker": ["DSS"], + "Q_value": [0.01], + } + ).to_excel(xlsx, index=False) + + monkeypatch.setattr( + "protzilla.importing.crosslinking_import.get_gene_name_from_protein_ids", + lambda ids: {i: (True, f"G{i}", None) for i in ids}, + ) + + result = crosslinking_import(xlsx, organism_ids="9606") + assert "crosslinking_df" in result + + +def test_crosslinking_import_invalid_file(tmp_path): + bad_file = tmp_path / "test.txt" + bad_file.write_text("something invalid") + result = crosslinking_import(bad_file, organism_ids="9606") + assert "messages" in result + assert any("Unsupported file type" in m["msg"] for m in result["messages"]) diff --git a/backend/tests/protzilla/importing/test_pae_matrix_reduction.py b/backend/tests/protzilla/importing/test_pae_matrix_reduction.py new file mode 100644 index 000000000..4daf5449e --- /dev/null +++ b/backend/tests/protzilla/importing/test_pae_matrix_reduction.py @@ -0,0 +1,162 @@ +import numpy as np +import pandas as pd +from backend.protzilla.importing.alphafold_protein_structure_load import ( + reduce_pae_to_per_amino_acid, +) +import pytest + +# These test cases were generated by AI (Gemini) but have been manually checked + + +@pytest.fixture +def empty_cif_df(): + """Returns an empty atom_site DataFrame with required columns.""" + return pd.DataFrame( + columns=[ + "_atom_site.label_entity_id", + "_atom_site.label_seq_id", + "_atom_site.label_atom_id", + ] + ) + + +def test_no_duplicates(empty_cif_df): + """Edge Case 1: Every residue has exactly one token. + + The PAE matrix should remain entirely untouched. + """ + pae_matrix = np.array([[1.0, 2.0], [3.0, 4.0]]) + token_res_ids = [1, 2] + + result = reduce_pae_to_per_amino_acid(pae_matrix, token_res_ids, empty_cif_df) + + assert np.array_equal(result, pae_matrix) + + +def test_duplicates_fallback_to_first_token(empty_cif_df): + """Edge Case 2: Multi-token residue, but length mismatch with CIF. + + Should fall back to keeping the first token (offset 0) and deleting the rest. + """ + # 3 tokens: Residue 1 has 2 tokens, Residue 2 has 1 token. + pae_matrix = np.array([[10, 11, 12], [20, 21, 22], [30, 31, 32]]) + token_res_ids = [1, 1, 2] + + # Keeping token 0 (first of res 1) and token 2 (res 2). Token 1 should be deleted. + expected_indices = [0, 2] + expected_matrix = pae_matrix[np.ix_(expected_indices, expected_indices)] + + result = reduce_pae_to_per_amino_acid(pae_matrix, token_res_ids, empty_cif_df) + + assert np.array_equal(result, expected_matrix) + + +def test_duplicates_keep_ca_atom(): + """Edge Case 3: Run length matches CIF length, and exactly one CA atom is found. + + Should keep the token corresponding exactly to the 'CA' atom position. + """ + # 4 tokens: Residue 1 has 3 tokens, Residue 2 has 1 token. + pae_matrix = np.diag([1.0, 2.0, 3.0, 4.0]) + token_res_ids = [1, 1, 1, 2] + + # CIF setup: 3 atoms for chain 1, residue 1. 'CA' sits at relative index 1. + cif_df = pd.DataFrame( + { + "_atom_site.label_entity_id": ["1", "1", "1"], + "_atom_site.label_seq_id": [1, 1, 1], + "_atom_site.label_atom_id": ["N", "CA", "C"], + } + ) + + # Expected: Keep global index 1 (the CA atom) and global index 3 (residue 2). + # Global indices 0 and 2 should be wiped out. + expected_indices = [1, 3] + expected_matrix = pae_matrix[np.ix_(expected_indices, expected_indices)] + + result = reduce_pae_to_per_amino_acid(pae_matrix, token_res_ids, cif_df) + + assert np.array_equal(result, expected_matrix) + + +def test_duplicates_cif_match_but_no_ca_fallback(): + """Edge Case 4a: Run length matches CIF length, but no CA atom exists. + + Should fall back to keeping the first token (offset 0). + """ + pae_matrix = np.diag([10, 20, 30]) + token_res_ids = [1, 1, 2] + + # CIF matches length (2 atoms), but neither is 'CA' + cif_df = pd.DataFrame( + { + "_atom_site.label_entity_id": ["1", "1"], + "_atom_site.label_seq_id": [1, 1], + "_atom_site.label_atom_id": ["N", "O"], + } + ) + + # Expected to keep global index 0 (fallback) and global index 2. + expected_indices = [0, 2] + expected_matrix = pae_matrix[np.ix_(expected_indices, expected_indices)] + + result = reduce_pae_to_per_amino_acid(pae_matrix, token_res_ids, cif_df) + + assert np.array_equal(result, expected_matrix) + + +def test_duplicates_cif_match_multiple_ca_fallback(): + """Edge Case 4b: Run length matches CIF length, but multiple CA atoms exist. + + Should fall back to keeping the first token (offset 0). + """ + pae_matrix = np.diag([10, 20, 30]) + token_res_ids = [1, 1, 2] + + # CIF matches length (2 atoms), but both claim to be 'CA' + cif_df = pd.DataFrame( + { + "_atom_site.label_entity_id": ["1", "1"], + "_atom_site.label_seq_id": [1, 1], + "_atom_site.label_atom_id": ["CA", "CA"], + } + ) + + # Expected to keep global index 0 (fallback) and global index 2. + expected_indices = [0, 2] + expected_matrix = pae_matrix[np.ix_(expected_indices, expected_indices)] + + result = reduce_pae_to_per_amino_acid(pae_matrix, token_res_ids, cif_df) + + assert np.array_equal(result, expected_matrix) + + +def test_multiple_chains_tracking(): + """Edge Case 5: The system has multiple chains. + + Verifies that `current_chain_idx` increments whenever `res_id == 1` starts a run, + and queries the correct stringified `_atom_site.label_entity_id`. + """ + # Chain 1: res 1 (len 1), res 2 (len 1) + # Chain 2: res 1 (len 2) -> Triggered by encountering 1 again + token_res_ids = [1, 2, 1, 1] + pae_matrix = np.diag([100, 200, 300, 400]) + + cif_df = pd.DataFrame( + { + "_atom_site.label_entity_id": ["1", "1", "2", "2"], + "_atom_site.label_seq_id": [1, 2, 1, 1], + "_atom_site.label_atom_id": ["CA", "CA", "N", "CA"], + } + ) + + # Chain 1, Res 1 (idx 0): len 1 -> Keep + # Chain 1, Res 2 (idx 1): len 1 -> Keep + # Chain 2, Res 1 (idx 2, 3): len 2 -> Matches CIF length for chain '2'. + # CA is at relative index 1 (global idx 3). Global idx 2 is dropped. + expected_indices = [0, 1, 3] + expected_matrix = pae_matrix[np.ix_(expected_indices, expected_indices)] + + result = reduce_pae_to_per_amino_acid(pae_matrix, token_res_ids, cif_df) + + assert np.array_equal(result, expected_matrix) diff --git a/backend/tests/protzilla/importing/test_query_generation.py b/backend/tests/protzilla/importing/test_query_generation.py new file mode 100644 index 000000000..5da4c7a3d --- /dev/null +++ b/backend/tests/protzilla/importing/test_query_generation.py @@ -0,0 +1,104 @@ +import json + +import pytest +from unittest.mock import patch, Mock +import requests +from backend.protzilla.importing.query_generation import ( + generate_alphafold_query_json, +) + +FAKE_FASTA_1 = ">P69905 Hemoglobin subunit alpha\nMVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHG" +FAKE_FASTA_2 = ">P68871 Hemoglobin subunit beta\nVLSPADKTNVKAAWGKVGGHAAEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHG" + + +@patch("backend.protzilla.importing.query_generation.requests.get") +def test_generate_alphafold_multimer_json_query_for_multiple_proteins(mock_get): + mock_resp1 = Mock() + mock_resp1.status_code = 200 + mock_resp1.text = FAKE_FASTA_1 + mock_resp1.raise_for_status = Mock() + + mock_resp2 = Mock() + mock_resp2.status_code = 200 + mock_resp2.text = FAKE_FASTA_2 + mock_resp2.raise_for_status = Mock() + + mock_get.side_effect = [mock_resp1, mock_resp2] + + result = generate_alphafold_query_json("P69905 P68871", "2,3", -1, "name") + downloads = result["downloads"].value + + assert len(downloads) == 1 + key = list(downloads.keys())[0] + assert key == "name.json" + + parsed_json = downloads[key][0] + + # Check top-level keys + expected_keys = {"name", "modelSeeds", "sequences", "dialect", "version"} + assert set(parsed_json.keys()) == expected_keys + + # Check name, version, dialect, modelSeeds + assert parsed_json["name"] == "name" + assert parsed_json["version"] == 1 + assert parsed_json["dialect"] == "alphafoldserver" + assert parsed_json["modelSeeds"] == [] + + # Check sequences + sequences = parsed_json["sequences"] + assert len(sequences) == 2 + + # First protein + protein_chain_1 = sequences[0]["proteinChain"] + assert ( + protein_chain_1["sequence"] + == "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHG" + ) + assert protein_chain_1["count"] == 2 + + # Second protein + protein_chain_2 = sequences[1]["proteinChain"] + assert ( + protein_chain_2["sequence"] + == "VLSPADKTNVKAAWGKVGGHAAEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHG" + ) + assert protein_chain_2["count"] == 3 + + +@patch("backend.protzilla.importing.query_generation.requests.get") +def test_generate_alphafold_multimer_json_query_with_model_seed(mock_get): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.text = FAKE_FASTA_1 + mock_resp.raise_for_status = Mock() + mock_get.return_value = mock_resp + + result = generate_alphafold_query_json("P69905", "2", model_seed=12345, name="name") + downloads = result["downloads"].value + key = list(downloads.keys())[0] + query_json = downloads[key][0] + assert query_json["modelSeeds"] == [12345] + + +def test_generate_alphafold_multimer_json_query_with_mismatched_number_of_ids_and_number_of_copies(): + with pytest.raises(ValueError) as error: + generate_alphafold_query_json("P69905 P68871", "2", -1, "name") + + msg = str(error.value) + assert "2 ids" in msg + assert "1 entries for number of copies" in msg + + +def test_generate_alphafold_multimer_json_query_with_invalid_copy_number(): + with pytest.raises(ValueError, match="Invalid list of number of copies per id"): + generate_alphafold_query_json("P69905", "abc", -1, "name") + + +@patch("backend.protzilla.importing.query_generation.requests.get") +def test_generate_alphafold_multimer_json_query_with_http_error(mock_get): + mock_resp = Mock() + mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError() + mock_get.return_value = mock_resp + + with pytest.raises(requests.exceptions.HTTPError): + generate_alphafold_query_json("P69905", "2", -1, "name") diff --git a/backend/tests/protzilla/test_disk_operator.py b/backend/tests/protzilla/test_disk_operator.py index 58d88e3cf..e158ebff0 100644 --- a/backend/tests/protzilla/test_disk_operator.py +++ b/backend/tests/protzilla/test_disk_operator.py @@ -2,7 +2,7 @@ import pytest -from protzilla.disk_operator import YamlOperator +from backend.protzilla.disk_operator import YamlOperator @pytest.fixture() diff --git a/backend/user_data/workflows/cl_monomer.yaml b/backend/user_data/workflows/cl_monomer.yaml new file mode 100644 index 000000000..32c4e2fd7 --- /dev/null +++ b/backend/user_data/workflows/cl_monomer.yaml @@ -0,0 +1,62 @@ +current_step_id: s00004_AlphaFoldPredictionLoad +df_mode: Standard +graph_edges: +- !!python/tuple + - s00003_CrosslinkingImport + - s00005_CrosslinkingValidationWithAngstromDeviation + - source_handle: crosslinking_df + target_handle: crosslinking_df +- !!python/tuple + - s00004_AlphaFoldPredictionLoad + - s00005_CrosslinkingValidationWithAngstromDeviation + - source_handle: structure_metadata_df + target_handle: structure_metadata_df +- !!python/tuple + - s00004_AlphaFoldPredictionLoad + - s00005_CrosslinkingValidationWithAngstromDeviation + - source_handle: cif_df + target_handle: cif_df +- !!python/tuple + - s00004_AlphaFoldPredictionLoad + - s00005_CrosslinkingValidationWithAngstromDeviation + - source_handle: amino_acid_sequences_df + target_handle: amino_acid_sequences_df +id_clock: 5 +steps: +- calculation_status: incomplete + form_inputs: + file_path: null + organism_ids: '' + instance_identifier: s00003_CrosslinkingImport + messages: [] + output: {} + plots: {} + type: CrosslinkingImport + visual_data: + node_position: + x: -58.407821229050285 + y: -3.4357541899441344 +- calculation_status: incomplete + form_inputs: + persist_upload: true + uniprot_id: '' + instance_identifier: s00004_AlphaFoldPredictionLoad + messages: [] + output: {} + plots: {} + type: AlphaFoldPredictionLoad + visual_data: + node_position: + x: 284.1888049686339 + y: -3.6223900627321264 +- calculation_status: incomplete + form_inputs: {} + instance_identifier: s00005_CrosslinkingValidationWithAngstromDeviation + messages: [] + output: {} + plots: {} + type: CrosslinkingValidationWithAngstromDeviation + visual_data: + node_position: + x: 88.81734340376391 + y: 184.78418276583125 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0ea3e32fd..1cbe87bc5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@dagrejs/dagre": "^2.0.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/material": "^6.4.12", @@ -15,15 +16,17 @@ "@mui/x-data-grid": "^7.29.6", "@storybook/addon-actions": "^8.6.14", "@types/node": "^24.3.1", + "@xyflow/react": "^12.10.0", "axios": "^1.9.0", "bootstrap": "^5.3.6", - "corepack": "^0.34.0", + "corepack": "^0.34.6", "fast-deep-equal": "^3.1.3", "file-saver": "^2.0.5", "framer-motion": "^12.17.0", "lodash.merge": "^4.6.2", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", + "molstar": "^5.7.0", "moment": "^2.30.1", "plotly.js": "^3.0.1", "plotly.js-dist-min": "^3.0.1", @@ -67,6 +70,7 @@ "jsdom": "^26.1.0", "lint-staged": "^15.5.2", "prettier": "^3.5.3", + "sass-embedded": "^1.98.0", "storybook": "^8.6.14", "typescript": "~5.6.3", "typescript-eslint": "^8.34.0", @@ -482,6 +486,13 @@ "node": ">=6.9.0" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@choojs/findup": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", @@ -628,6 +639,21 @@ "node": ">=18" } }, + "node_modules/@dagrejs/dagre": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz", + "integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "3.0.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz", + "integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", @@ -2159,6 +2185,316 @@ "node": ">=12.4.0" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2565,6 +2901,13 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@storybook/addon-actions": { "version": "8.6.14", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.14.tgz", @@ -3546,6 +3889,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/argparse": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-2.0.17.tgz", + "integrity": "sha512-fueJssTf+4dW4HODshEGkIZbkLKHzgu1FvCI4cTc/MKum/534Euo3SrN+ilq8xgyHnOjtmg33/hee8iXLRg1XA==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3593,6 +3942,22 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/benchmark": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/benchmark/-/benchmark-2.1.5.tgz", + "integrity": "sha512-cKio2eFB3v7qmKcvIHLUMw/dIx/8bhWPuzpzRT4unCPRTD8VdA9Zb0afxpcxOqR4PixRS7yT42FqGS8BYL8g1w==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", @@ -3602,6 +3967,83 @@ "@types/deep-eql": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3617,13 +4059,44 @@ "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, - "node_modules/@types/file-saver": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", - "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", "dev": true }, "node_modules/@types/geojson": { @@ -3639,6 +4112,15 @@ "@types/geojson": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", @@ -3650,6 +4132,12 @@ "@types/react": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3677,12 +4165,27 @@ "@types/pbf": "*" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", "dev": true }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.3.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", @@ -3691,6 +4194,16 @@ "undici-types": "~7.10.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -3721,6 +4234,18 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "18.3.24", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", @@ -3763,6 +4288,25 @@ "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", "dev": true }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/stylis": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", @@ -3776,6 +4320,18 @@ "@types/geojson": "*" } }, + "node_modules/@types/swagger-ui-dist": { + "version": "3.30.6", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-dist/-/swagger-ui-dist-3.30.6.tgz", + "integrity": "sha512-FVxN7wjLYRtJsZBscOcOcf8oR++m38vbUFjT33Mr9HBuasX9bRDrJsp7iwixcOtKSHEEa2B7o2+4wEiXqC+Ebw==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -4014,6 +4570,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -4513,11 +5075,90 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4606,8 +5247,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-query": { "version": "5.3.0", @@ -4627,7 +5267,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" @@ -4764,6 +5403,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.reduce": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.8.tgz", + "integrity": "sha512-DwuEqgXFBwbmZSRqt3BpQigWNUoqw9Ml2dTWdF3B2zQlQX4OeUE0zyuzX0fX0IbTvjdkZbcBTU3idgpO78qkTw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-array-method-boxes-properly": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "is-string": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.tosorted": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", @@ -4784,7 +5445,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", @@ -4825,7 +5485,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -4873,6 +5532,16 @@ "npm": ">=6" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4922,6 +5591,46 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bootstrap": { "version": "5.3.8", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", @@ -5003,6 +5712,15 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -5112,6 +5830,16 @@ "element-size": "^1.1.1" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -5141,6 +5869,46 @@ "node": ">=8" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -5150,6 +5918,23 @@ "node": ">= 16" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chromatic": { "version": "11.29.0", "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-11.29.0.tgz", @@ -5178,6 +5963,12 @@ "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -5336,6 +6127,13 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5347,6 +6145,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -5356,6 +6164,51 @@ "node": ">=18" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5376,6 +6229,28 @@ "typedarray": "^0.0.6" } }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5390,15 +6265,25 @@ "node": ">=18" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/corepack": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/corepack/-/corepack-0.34.0.tgz", - "integrity": "sha512-8D9N/k9hDjoISCDGUzH2wBF0fJD49p3G7ifoEZcc0vhB7Py6r+Mc1SpJ8dvnWY/HMP95K60WkQbN7vgbUgXgpA==", + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/corepack/-/corepack-0.34.6.tgz", + "integrity": "sha512-gvylq9kzJB09mSsiOnKOnhg0YdCWNy2aGaeGbYF4HlyGd/v4moxEonQjJPYI45/K4zP7q1hW9qCVvaYYKK5nkA==", + "license": "MIT", "bin": { "corepack": "dist/corepack.js", "pnpm": "dist/pnpm.js", @@ -5410,6 +6295,23 @@ "node": "^20.10.0 || ^22.11.0 || >=24.0.0" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -5580,6 +6482,28 @@ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==" }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-force": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", @@ -5653,6 +6577,15 @@ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==" }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", @@ -5679,6 +6612,41 @@ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==" }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -5696,7 +6664,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -5713,7 +6680,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -5730,7 +6696,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -5744,9 +6709,10 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -5765,6 +6731,19 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -5808,7 +6787,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -5837,6 +6815,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5850,6 +6837,30 @@ "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", "integrity": "sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw==" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5944,6 +6955,12 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.217", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.217.tgz", @@ -5969,6 +6986,15 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6013,7 +7039,6 @@ "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "dev": true, "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", @@ -6077,6 +7102,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -6167,7 +7198,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", @@ -6288,6 +7318,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -6806,6 +7842,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -6820,6 +7866,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -6875,6 +7930,83 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ext": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", @@ -6883,6 +8015,12 @@ "type": "^2.7.2" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/falafel": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.5.tgz", @@ -7023,6 +8161,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -7151,6 +8310,22 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fp-ts": { + "version": "2.16.11", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.11.tgz", + "integrity": "sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==", + "license": "MIT", + "peer": true + }, "node_modules/framer-motion": { "version": "12.23.12", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz", @@ -7177,6 +8352,15 @@ } } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -7212,7 +8396,6 @@ "version": "1.1.8", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -7232,7 +8415,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7319,7 +8501,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -7475,7 +8656,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" @@ -7701,11 +8881,16 @@ "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==" }, + "node_modules/h264-mp4-encoder": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/h264-mp4-encoder/-/h264-mp4-encoder-1.0.12.tgz", + "integrity": "sha512-xih3J+Go0o1RqGjhOt6TwXLWWGqLONRPyS8yoMu/RoS/S8WyEv4HuHp1KBsDDl8srZQ3gw9f95JYkCSjCuZbHQ==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -7753,7 +8938,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, "dependencies": { "dunder-proto": "^1.0.0" }, @@ -7800,6 +8984,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -7825,6 +9049,36 @@ "node": ">=18" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -7914,6 +9168,12 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7960,11 +9220,16 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", @@ -7974,6 +9239,48 @@ "node": ">= 0.4" } }, + "node_modules/io-ts": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.22.tgz", + "integrity": "sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==", + "license": "MIT", + "peerDependencies": { + "fp-ts": "^2.5.0" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -7993,7 +9300,6 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -8015,7 +9321,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", @@ -8034,7 +9339,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, "dependencies": { "has-bigints": "^1.0.2" }, @@ -8049,7 +9353,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -8116,7 +9419,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", @@ -8133,7 +9435,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -8145,6 +9446,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -8172,7 +9483,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, "dependencies": { "call-bound": "^1.0.3" }, @@ -8243,6 +9553,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-iexplorer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-iexplorer/-/is-iexplorer-1.0.0.tgz", @@ -8255,7 +9575,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -8272,7 +9591,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -8293,7 +9611,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -8327,6 +9644,12 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8348,7 +9671,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -8360,7 +9682,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, "dependencies": { "call-bound": "^1.0.3" }, @@ -8387,7 +9708,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -8413,7 +9733,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", @@ -8444,7 +9763,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -8456,7 +9774,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, "dependencies": { "call-bound": "^1.0.3" }, @@ -8471,7 +9788,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" @@ -8967,6 +10283,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -9158,6 +10484,16 @@ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9174,58 +10510,924 @@ "node": ">=0.10.0" } }, - "node_modules/memoizerific": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", - "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==", - "dev": true, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", "dependencies": { - "map-or-similar": "^1.5.0" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "node": ">=12" }, - "engines": { - "node": ">=8.6" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memoizerific": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", + "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==", + "dev": true, + "dependencies": { + "map-or-similar": "^1.5.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "engines": { "node": ">= 0.6" @@ -9340,6 +11542,93 @@ } } }, + "node_modules/molstar": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/molstar/-/molstar-5.7.0.tgz", + "integrity": "sha512-Bo/QDiEkoRdhyhmFXNBPP2kiNTHZNgJO69AzqO+CrpkSj7JUrYNtBlt49vqa2AJfLUrBNgeeHcfjbxGLmfO7sQ==", + "license": "MIT", + "dependencies": { + "@types/argparse": "^2.0.17", + "@types/benchmark": "^2.1.5", + "@types/compression": "1.8.1", + "@types/express": "^5.0.6", + "@types/node": "^22.19.13", + "@types/node-fetch": "^2.6.13", + "@types/swagger-ui-dist": "3.30.6", + "argparse": "^2.0.1", + "compression": "^1.8.1", + "cors": "^2.8.6", + "express": "^5.2.1", + "h264-mp4-encoder": "^1.0.12", + "immutable": "^5.1.4", + "io-ts": "^2.2.22", + "mutative": "^1.3.0", + "node-fetch": "^2.7.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "rxjs": "^7.8.2", + "swagger-ui-dist": "^5.32.0", + "tslib": "^2.8.1", + "util.promisify": "^1.1.3" + }, + "bin": { + "cif2bcif": "lib/commonjs/cli/cif2bcif/index.js", + "cifschema": "lib/commonjs/cli/cifschema/index.js", + "model-server": "lib/commonjs/servers/model/server.js", + "model-server-preprocess": "lib/commonjs/servers/model/preprocess.js", + "model-server-query": "lib/commonjs/servers/model/query.js", + "mvs-print-schema": "lib/commonjs/cli/mvs/mvs-print-schema.js", + "mvs-render": "lib/commonjs/cli/mvs/mvs-render.js", + "mvs-validate": "lib/commonjs/cli/mvs/mvs-validate.js", + "volume-server": "lib/commonjs/servers/volume/server.js", + "volume-server-pack": "lib/commonjs/servers/volume/pack.js", + "volume-server-query": "lib/commonjs/servers/volume/query.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@google-cloud/storage": "^7.14.0", + "canvas": "^2.11.2", + "gl": "^6.0.2", + "jpeg-js": "^0.4.4", + "pngjs": "^6.0.0", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@google-cloud/storage": { + "optional": true + }, + "canvas": { + "optional": true + }, + "gl": { + "optional": true + }, + "jpeg-js": { + "optional": true + }, + "pngjs": { + "optional": true + } + } + }, + "node_modules/molstar/node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/molstar/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -9399,6 +11688,15 @@ "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==" }, + "node_modules/mutative": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/mutative/-/mutative-1.3.0.tgz", + "integrity": "sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ==", + "license": "MIT", + "engines": { + "node": ">=14.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -9466,6 +11764,15 @@ "ms": "^2.1.1" } }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", @@ -9481,6 +11788,56 @@ "tslib": "^2.0.3" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", @@ -9548,7 +11905,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -9560,7 +11916,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -9569,7 +11924,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -9618,6 +11972,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.getownpropertydescriptors": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.9.tgz", + "integrity": "sha512-mt8YM6XwsTTovI+kdZdHSxoyF2DI59up034orlC9NfweclcWOt7CVascNNLp6U+bjFVCVCIh9PwS76tDM/rH8g==", + "license": "MIT", + "dependencies": { + "array.prototype.reduce": "^1.0.8", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "gopd": "^1.2.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object.groupby": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", @@ -9650,6 +12025,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9710,7 +12106,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", @@ -9775,6 +12170,31 @@ "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -9822,6 +12242,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9867,6 +12296,16 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -10166,11 +12605,34 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/protocol-buffers-schema": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -10185,6 +12647,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -10218,6 +12695,46 @@ "performance-now": "^2.1.0" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -10314,6 +12831,33 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-plotly.js": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-plotly.js/-/react-plotly.js-2.6.0.tgz", @@ -10410,6 +12954,21 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -10462,7 +13021,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -10484,7 +13042,6 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -10591,6 +13148,72 @@ "regl-scatter2d": "^3.2.3" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -10732,6 +13355,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -10766,11 +13405,19 @@ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", @@ -10808,7 +13455,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" @@ -10841,6 +13487,392 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sass": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", + "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-embedded": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.98.0.tgz", + "integrity": "sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.5.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.1.5", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.98.0", + "sass-embedded-android-arm": "1.98.0", + "sass-embedded-android-arm64": "1.98.0", + "sass-embedded-android-riscv64": "1.98.0", + "sass-embedded-android-x64": "1.98.0", + "sass-embedded-darwin-arm64": "1.98.0", + "sass-embedded-darwin-x64": "1.98.0", + "sass-embedded-linux-arm": "1.98.0", + "sass-embedded-linux-arm64": "1.98.0", + "sass-embedded-linux-musl-arm": "1.98.0", + "sass-embedded-linux-musl-arm64": "1.98.0", + "sass-embedded-linux-musl-riscv64": "1.98.0", + "sass-embedded-linux-musl-x64": "1.98.0", + "sass-embedded-linux-riscv64": "1.98.0", + "sass-embedded-linux-x64": "1.98.0", + "sass-embedded-unknown-all": "1.98.0", + "sass-embedded-win32-arm64": "1.98.0", + "sass-embedded-win32-x64": "1.98.0" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.98.0.tgz", + "integrity": "sha512-6n4RyK7/1mhdfYvpP3CClS3fGoYqDvRmLClCESS6I7+SAzqjxvGG6u5Fo+cb1nrPNbbilgbM4QKdgcgWHO9NCA==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.98.0" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.98.0.tgz", + "integrity": "sha512-LjGiMhHgu7VL1n7EJxTCre1x14bUsWd9d3dnkS2rku003IWOI/fxc7OXgaKagoVzok1kv09rzO3vFXJR5ZeONQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.98.0.tgz", + "integrity": "sha512-M9Ra98A6vYJHpwhoC/5EuH1eOshQ9ZyNwC8XifUDSbRl/cGeQceT1NReR9wFj3L7s1pIbmes1vMmaY2np0uAKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.98.0.tgz", + "integrity": "sha512-WPe+0NbaJIZE1fq/RfCZANMeIgmy83x4f+SvFOG7LhUthHpZWcOcrPTsCKKmN3xMT3iw+4DXvqTYOCYGRL3hcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.98.0.tgz", + "integrity": "sha512-zrD25dT7OHPEgLWuPEByybnIfx4rnCtfge4clBgjZdZ3lF6E7qNLRBtSBmoFflh6Vg0RlEjJo5VlpnTMBM5MQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.98.0.tgz", + "integrity": "sha512-cgr1z9rBnCdMf8K+JabIaYd9Rag2OJi5mjq08XJfbJGMZV/TA6hFJCLGkr5/+ZOn4/geTM5/3aSfQ8z5EIJAOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.98.0.tgz", + "integrity": "sha512-OLBOCs/NPeiMqTdOrMFbVHBQFj19GS3bSVSxIhcCq16ZyhouUkYJEZjxQgzv9SWA2q6Ki8GCqp4k6jMeUY9dcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.98.0.tgz", + "integrity": "sha512-03baQZCxVyEp8v1NWBRlzGYrmVT/LK7ZrHlF1piscGiGxwfdxoLXVuxsylx3qn/dD/4i/rh7Bzk7reK1br9jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.98.0.tgz", + "integrity": "sha512-axOE3t2MTBwCtkUCbrdM++Gj0gC0fdHJPrgzQ+q1WUmY9NoNMGqflBtk5mBZaWUeha2qYO3FawxCB8lctFwCtw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.98.0.tgz", + "integrity": "sha512-OBkjTDPYR4hSaueOGIM6FDpl9nt/VZwbSRpbNu9/eEJcxE8G/vynRugW8KRZmCFjPy8j/jkGBvvS+k9iOqKV3g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.98.0.tgz", + "integrity": "sha512-LeqNxQA8y4opjhe68CcFvMzCSrBuJqYVFbwElEj9bagHXQHTp9xVPJRn6VcrC+0VLEDq13HVXMv7RslIuU0zmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.98.0.tgz", + "integrity": "sha512-7w6hSuOHKt8FZsmjRb3iGSxEzM87fO9+M8nt5JIQYMhHTj5C+JY/vcske0v715HCVj5e1xyTnbGXf8FcASeAIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.98.0.tgz", + "integrity": "sha512-QikNyDEJOVqPmxyCFkci8ZdCwEssdItfjQFJB+D+Uy5HFqcS5Lv3d3GxWNX/h1dSb23RPyQdQc267ok5SbEyJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.98.0.tgz", + "integrity": "sha512-E7fNytc/v4xFBQKzgzBddV/jretA4ULAPO6XmtBiQu4zZBdBozuSxsQLe2+XXeb0X4S2GIl72V7IPABdqke/vA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.98.0.tgz", + "integrity": "sha512-VsvP0t/uw00mMNPv3vwyYKUrFbqzxQHnRMO+bHdAMjvLw4NFf6mscpym9Bzf+NXwi1ZNKnB6DtXjmcpcvqFqYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.98.0.tgz", + "integrity": "sha512-C4MMzcAo3oEDQnW7L8SBgB9F2Fq5qHPnaYTZRMOH3Mp/7kM4OooBInXpCiiFjLnjY95hzP4KyctVx0uYR6MYlQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "dependencies": { + "sass": "1.98.0" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.98.0.tgz", + "integrity": "sha512-nP/10xbAiPbhQkMr3zQfXE4TuOxPzWRQe1Hgbi90jv2R4TbzbqQTuZVOaJf7KOAN4L2Bo6XCTRjK5XkVnwZuwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.98.0.tgz", + "integrity": "sha512-/lbrVsfbcbdZQ5SJCWcV0NVPd6YRs+FtAnfedp4WbCkO/ZO7Zt/58MvI4X2BVpRY/Nt5ZBo1/7v2gYcQ+J4svQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -10875,6 +13907,76 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", @@ -10900,7 +14002,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -10915,7 +14016,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", @@ -10925,6 +14025,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shallow-copy": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", @@ -10960,7 +14066,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -10979,7 +14084,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -10995,7 +14099,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -11013,7 +14116,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -11105,6 +14207,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -11133,6 +14245,15 @@ "escodegen": "^2.1.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", @@ -11143,7 +14264,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" @@ -11333,7 +14453,6 @@ "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", @@ -11354,7 +14473,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", @@ -11372,7 +14490,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -11385,6 +14502,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -11496,6 +14627,24 @@ "resolved": "https://registry.npmjs.org/strongly-connected-components/-/strongly-connected-components-1.0.1.tgz", "integrity": "sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-components": { "version": "6.1.19", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.19.tgz", @@ -11639,12 +14788,44 @@ "svg-path-bounds": "^1.0.1" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz", + "integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", + "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -11767,6 +14948,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/topojson-client": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", @@ -11809,6 +14999,26 @@ "node": ">=18" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -11904,11 +15114,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -11922,7 +15170,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", @@ -11941,7 +15188,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", @@ -11962,7 +15208,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", @@ -12032,7 +15277,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", @@ -12051,6 +15295,105 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unified/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -12060,6 +15403,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unplugin": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", @@ -12181,6 +15533,32 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/util.promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.1.3.tgz", + "integrity": "sha512-GIEaZ6o86fj09Wtf0VfZ5XP7tmd4t3jM5aZCgmBi231D0DB1AEBa3Aa6MP48DMsAIi96WkpWLimIWVwOjbDMOw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "for-each": "^0.3.3", + "get-intrinsic": "^1.2.6", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "object.getownpropertydescriptors": "^2.1.8", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -12193,6 +15571,50 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "6.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", @@ -12609,7 +16031,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", @@ -12628,7 +16049,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", @@ -12655,7 +16075,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", @@ -12887,6 +16306,44 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 4cb0aa4bc..a887a3416 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,13 +31,14 @@ "@xyflow/react": "^12.10.0", "axios": "^1.9.0", "bootstrap": "^5.3.6", - "corepack": "^0.34.0", + "corepack": "^0.34.6", "fast-deep-equal": "^3.1.3", "file-saver": "^2.0.5", "framer-motion": "^12.17.0", "lodash.merge": "^4.6.2", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", + "molstar": "^5.7.0", "moment": "^2.30.1", "plotly.js": "^3.0.1", "plotly.js-dist-min": "^3.0.1", @@ -81,6 +82,7 @@ "jsdom": "^26.1.0", "lint-staged": "^15.5.2", "prettier": "^3.5.3", + "sass-embedded": "^1.98.0", "storybook": "^8.6.14", "typescript": "~5.6.3", "typescript-eslint": "^8.34.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 02417ae58..34a5cd65e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -33,7 +33,7 @@ importers: version: 24.3.1 "@xyflow/react": specifier: ^12.10.0 - version: 12.10.0(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 12.10.2(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) axios: specifier: ^1.9.0 version: 1.11.0 @@ -41,8 +41,8 @@ importers: specifier: ^5.3.6 version: 5.3.8(@popperjs/core@2.11.8) corepack: - specifier: ^0.34.0 - version: 0.34.0 + specifier: ^0.34.6 + version: 0.34.6 fast-deep-equal: specifier: ^3.1.3 version: 3.1.3 @@ -61,6 +61,9 @@ importers: mobx-react-lite: specifier: ^4.1.0 version: 4.1.0(mobx@6.13.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + molstar: + specifier: ^5.7.0 + version: 5.7.0(@types/react@18.3.24)(fp-ts@2.16.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) moment: specifier: ^2.30.1 version: 2.30.1 @@ -118,7 +121,7 @@ importers: version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.6.3) "@storybook/react-vite": specifier: ^8.6.14 - version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.50.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1)) + version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.50.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1)) "@storybook/test": specifier: ^8.6.14 version: 8.6.14(storybook@8.6.14(prettier@3.6.2)) @@ -148,7 +151,7 @@ importers: version: 8.43.0(eslint@9.35.0)(typescript@5.6.3) "@vitejs/plugin-react": specifier: ^4.5.2 - version: 4.7.0(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1)) + version: 4.7.0(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1)) eslint: specifier: ^9.28.0 version: 9.35.0 @@ -185,6 +188,9 @@ importers: prettier: specifier: ^3.5.3 version: 3.6.2 + sass-embedded: + specifier: ^1.98.0 + version: 1.98.0 storybook: specifier: ^8.6.14 version: 8.6.14(prettier@3.6.2) @@ -196,16 +202,16 @@ importers: version: 8.43.0(eslint@9.35.0)(typescript@5.6.3) vite: specifier: ^6.3.5 - version: 6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + version: 6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) vite-plugin-svgr: specifier: ^4.3.0 - version: 4.5.0(rollup@4.50.1)(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1)) + version: 4.5.0(rollup@4.50.1)(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1)) + version: 5.1.4(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1)) vitest: specifier: ^3.2.3 - version: 3.2.4(@types/node@24.3.1)(jsdom@26.1.0)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.1)(jsdom@26.1.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) packages: "@adobe/css-tools@4.4.4": @@ -431,6 +437,12 @@ packages: } engines: { node: ">=6.9.0" } + "@bufbuild/protobuf@2.11.0": + resolution: + { + integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==, + } + "@choojs/findup@0.2.1": resolution: { @@ -1663,6 +1675,12 @@ packages: integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==, } + "@scarf/scarf@1.4.0": + resolution: + { + integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==, + } + "@storybook/addon-actions@8.6.14": resolution: { @@ -2097,6 +2115,12 @@ packages: integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==, } + "@types/argparse@2.0.17": + resolution: + { + integrity: sha512-fueJssTf+4dW4HODshEGkIZbkLKHzgu1FvCI4cTc/MKum/534Euo3SrN+ilq8xgyHnOjtmg33/hee8iXLRg1XA==, + } + "@types/aria-query@5.0.4": resolution: { @@ -2127,12 +2151,36 @@ packages: integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==, } + "@types/benchmark@2.1.5": + resolution: + { + integrity: sha512-cKio2eFB3v7qmKcvIHLUMw/dIx/8bhWPuzpzRT4unCPRTD8VdA9Zb0afxpcxOqR4PixRS7yT42FqGS8BYL8g1w==, + } + + "@types/body-parser@1.19.6": + resolution: + { + integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==, + } + "@types/chai@5.2.2": resolution: { integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==, } + "@types/compression@1.8.1": + resolution: + { + integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==, + } + + "@types/connect@3.4.38": + resolution: + { + integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==, + } + "@types/d3-color@3.1.3": resolution: { @@ -2169,6 +2217,12 @@ packages: integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==, } + "@types/debug@4.1.12": + resolution: + { + integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==, + } + "@types/deep-eql@4.0.2": resolution: { @@ -2181,12 +2235,30 @@ packages: integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==, } + "@types/estree-jsx@1.0.5": + resolution: + { + integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==, + } + "@types/estree@1.0.8": resolution: { integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, } + "@types/express-serve-static-core@5.1.1": + resolution: + { + integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==, + } + + "@types/express@5.0.6": + resolution: + { + integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==, + } + "@types/file-saver@2.0.7": resolution: { @@ -2205,6 +2277,12 @@ packages: integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==, } + "@types/hast@3.0.4": + resolution: + { + integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==, + } + "@types/hoist-non-react-statics@3.3.7": resolution: { @@ -2213,6 +2291,12 @@ packages: peerDependencies: "@types/react": "*" + "@types/http-errors@2.0.5": + resolution: + { + integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==, + } + "@types/json-schema@7.0.15": resolution: { @@ -2237,12 +2321,36 @@ packages: integrity: sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==, } + "@types/mdast@4.0.4": + resolution: + { + integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==, + } + "@types/mdx@2.0.13": resolution: { integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==, } + "@types/ms@2.1.0": + resolution: + { + integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==, + } + + "@types/node-fetch@2.6.13": + resolution: + { + integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==, + } + + "@types/node@22.19.15": + resolution: + { + integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==, + } + "@types/node@24.3.1": resolution: { @@ -2279,6 +2387,18 @@ packages: integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==, } + "@types/qs@6.15.0": + resolution: + { + integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==, + } + + "@types/range-parser@1.2.7": + resolution: + { + integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==, + } + "@types/react-dom@18.3.7": resolution: { @@ -2313,6 +2433,18 @@ packages: integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==, } + "@types/send@1.2.1": + resolution: + { + integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==, + } + + "@types/serve-static@2.2.0": + resolution: + { + integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==, + } + "@types/stylis@4.2.5": resolution: { @@ -2325,6 +2457,24 @@ packages: integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==, } + "@types/swagger-ui-dist@3.30.6": + resolution: + { + integrity: sha512-FVxN7wjLYRtJsZBscOcOcf8oR++m38vbUFjT33Mr9HBuasX9bRDrJsp7iwixcOtKSHEEa2B7o2+4wEiXqC+Ebw==, + } + + "@types/unist@2.0.11": + resolution: + { + integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==, + } + + "@types/unist@3.0.3": + resolution: + { + integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==, + } + "@types/uuid@9.0.8": resolution: { @@ -2420,6 +2570,12 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + "@ungap/structured-clone@1.3.0": + resolution: + { + integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==, + } + "@unrs/resolver-binding-android-arm-eabi@1.11.1": resolution: { @@ -2667,19 +2823,19 @@ packages: integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==, } - "@xyflow/react@12.10.0": + "@xyflow/react@12.10.2": resolution: { - integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==, + integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==, } peerDependencies: react: ">=17" react-dom: ">=17" - "@xyflow/system@0.0.74": + "@xyflow/system@0.0.76": resolution: { - integrity: sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==, + integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==, } abs-svg-path@0.1.1: @@ -2688,6 +2844,13 @@ packages: integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==, } + accepts@2.0.0: + resolution: + { + integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==, + } + engines: { node: ">= 0.6" } + acorn-jsx@5.3.2: resolution: { @@ -2859,6 +3022,13 @@ packages: } engines: { node: ">= 0.4" } + array.prototype.reduce@1.0.8: + resolution: + { + integrity: sha512-DwuEqgXFBwbmZSRqt3BpQigWNUoqw9Ml2dTWdF3B2zQlQX4OeUE0zyuzX0fX0IbTvjdkZbcBTU3idgpO78qkTw==, + } + engines: { node: ">= 0.4" } + array.prototype.tosorted@1.1.4: resolution: { @@ -2920,6 +3090,12 @@ packages: } engines: { node: ">=10", npm: ">=6" } + bail@2.0.2: + resolution: + { + integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==, + } + balanced-match@1.0.2: resolution: { @@ -2964,6 +3140,13 @@ packages: integrity: sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==, } + body-parser@2.2.2: + resolution: + { + integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==, + } + engines: { node: ">=18" } + bootstrap@5.3.8: resolution: { @@ -3011,6 +3194,13 @@ packages: integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==, } + bytes@3.1.2: + resolution: + { + integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==, + } + engines: { node: ">= 0.8" } + cac@6.7.14: resolution: { @@ -3071,6 +3261,12 @@ packages: integrity: sha512-onIcjRpz69/Hx5bB5HGbYKUF2uC6QT6Gp+pfpGm3A7mPfcluSLV5v4Zu+oflDUwLdUw0rLIBhUbi0v8hM4FJQQ==, } + ccount@2.0.1: + resolution: + { + integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==, + } + chai@5.3.3: resolution: { @@ -3099,6 +3295,30 @@ packages: } engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 } + character-entities-html4@2.1.0: + resolution: + { + integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==, + } + + character-entities-legacy@3.0.0: + resolution: + { + integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==, + } + + character-entities@2.0.2: + resolution: + { + integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==, + } + + character-reference-invalid@2.0.1: + resolution: + { + integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==, + } + check-error@2.1.1: resolution: { @@ -3228,6 +3448,12 @@ packages: integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==, } + colorjs.io@0.5.2: + resolution: + { + integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==, + } + combined-stream@1.0.8: resolution: { @@ -3235,6 +3461,12 @@ packages: } engines: { node: ">= 0.8" } + comma-separated-tokens@2.0.3: + resolution: + { + integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==, + } + commander@13.1.0: resolution: { @@ -3248,6 +3480,20 @@ packages: integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==, } + compressible@2.0.18: + resolution: + { + integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==, + } + engines: { node: ">= 0.6" } + + compression@1.8.1: + resolution: + { + integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==, + } + engines: { node: ">= 0.8.0" } + concat-map@0.0.1: resolution: { @@ -3261,6 +3507,20 @@ packages: } engines: { "0": node >= 0.8 } + content-disposition@1.0.1: + resolution: + { + integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==, + } + engines: { node: ">=18" } + + content-type@1.0.5: + resolution: + { + integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==, + } + engines: { node: ">= 0.6" } + convert-source-map@1.9.0: resolution: { @@ -3273,6 +3533,20 @@ packages: integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, } + cookie-signature@1.2.2: + resolution: + { + integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==, + } + engines: { node: ">=6.6.0" } + + cookie@0.7.2: + resolution: + { + integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, + } + engines: { node: ">= 0.6" } + cookie@1.0.2: resolution: { @@ -3286,14 +3560,21 @@ packages: integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==, } - corepack@0.34.0: + corepack@0.34.6: resolution: { - integrity: sha512-8D9N/k9hDjoISCDGUzH2wBF0fJD49p3G7ifoEZcc0vhB7Py6r+Mc1SpJ8dvnWY/HMP95K60WkQbN7vgbUgXgpA==, + integrity: sha512-gvylq9kzJB09mSsiOnKOnhg0YdCWNy2aGaeGbYF4HlyGd/v4moxEonQjJPYI45/K4zP7q1hW9qCVvaYYKK5nkA==, } engines: { node: ^20.10.0 || ^22.11.0 || >=24.0.0 } hasBin: true + cors@2.8.6: + resolution: + { + integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==, + } + engines: { node: ">= 0.10" } + cosmiconfig@7.1.0: resolution: { @@ -3611,12 +3892,30 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: + { + integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, + } + engines: { node: ">=6.0" } + peerDependencies: + supports-color: "*" + peerDependenciesMeta: + supports-color: + optional: true + decimal.js@10.6.0: resolution: { integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==, } + decode-named-character-reference@1.3.0: + resolution: + { + integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==, + } + deep-eql@5.0.2: resolution: { @@ -3664,6 +3963,13 @@ packages: } engines: { node: ">=0.4.0" } + depd@2.0.0: + resolution: + { + integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==, + } + engines: { node: ">= 0.8" } + dequal@2.0.3: resolution: { @@ -3685,6 +3991,12 @@ packages: engines: { node: ">=0.10" } hasBin: true + devlop@1.1.0: + resolution: + { + integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==, + } + doctrine@2.1.0: resolution: { @@ -3773,6 +4085,12 @@ packages: integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, } + ee-first@1.1.1: + resolution: + { + integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==, + } + electron-to-chromium@1.5.217: resolution: { @@ -3809,6 +4127,13 @@ packages: integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, } + encodeurl@2.0.0: + resolution: + { + integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==, + } + engines: { node: ">= 0.8" } + end-of-stream@1.4.5: resolution: { @@ -3849,6 +4174,12 @@ packages: } engines: { node: ">= 0.4" } + es-array-method-boxes-properly@1.0.0: + resolution: + { + integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==, + } + es-define-property@1.0.1: resolution: { @@ -3953,6 +4284,12 @@ packages: } engines: { node: ">=6" } + escape-html@1.0.3: + resolution: + { + integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==, + } + escape-string-regexp@4.0.0: resolution: { @@ -3960,6 +4297,13 @@ packages: } engines: { node: ">=10" } + escape-string-regexp@5.0.0: + resolution: + { + integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==, + } + engines: { node: ">=12" } + escodegen@2.1.0: resolution: { @@ -4139,6 +4483,12 @@ packages: } engines: { node: ">=4.0" } + estree-util-is-identifier-name@3.0.0: + resolution: + { + integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==, + } + estree-walker@2.0.2: resolution: { @@ -4158,6 +4508,13 @@ packages: } engines: { node: ">=0.10.0" } + etag@1.8.1: + resolution: + { + integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==, + } + engines: { node: ">= 0.6" } + event-emitter@0.3.5: resolution: { @@ -4191,12 +4548,25 @@ packages: } engines: { node: ">=12.0.0" } + express@5.2.1: + resolution: + { + integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==, + } + engines: { node: ">= 18" } + ext@1.7.0: resolution: { integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==, } + extend@3.0.2: + resolution: + { + integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==, + } + falafel@2.2.5: resolution: { @@ -4280,6 +4650,13 @@ packages: } engines: { node: ">=8" } + finalhandler@2.1.1: + resolution: + { + integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==, + } + engines: { node: ">= 18.0.0" } + find-root@1.1.0: resolution: { @@ -4357,12 +4734,25 @@ packages: } engines: { node: ">= 6" } - framer-motion@12.23.12: + forwarded@0.2.0: resolution: { - integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==, + integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, } - peerDependencies: + engines: { node: ">= 0.6" } + + fp-ts@2.16.11: + resolution: + { + integrity: sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==, + } + + framer-motion@12.23.12: + resolution: + { + integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==, + } + peerDependencies: "@emotion/is-prop-valid": "*" react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -4374,6 +4764,13 @@ packages: react-dom: optional: true + fresh@2.0.0: + resolution: + { + integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==, + } + engines: { node: ">= 0.8" } + from2@2.3.0: resolution: { @@ -4675,6 +5072,12 @@ packages: integrity: sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==, } + h264-mp4-encoder@1.0.12: + resolution: + { + integrity: sha512-xih3J+Go0o1RqGjhOt6TwXLWWGqLONRPyS8yoMu/RoS/S8WyEv4HuHp1KBsDDl8srZQ3gw9f95JYkCSjCuZbHQ==, + } + has-bigints@1.1.0: resolution: { @@ -4735,6 +5138,18 @@ packages: } engines: { node: ">= 0.4" } + hast-util-to-jsx-runtime@2.3.6: + resolution: + { + integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==, + } + + hast-util-whitespace@3.0.0: + resolution: + { + integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==, + } + hoist-non-react-statics@3.3.2: resolution: { @@ -4748,6 +5163,19 @@ packages: } engines: { node: ">=18" } + html-url-attributes@3.0.1: + resolution: + { + integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==, + } + + http-errors@2.0.1: + resolution: + { + integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, + } + engines: { node: ">= 0.8" } + http-proxy-agent@7.0.2: resolution: { @@ -4791,6 +5219,13 @@ packages: } engines: { node: ">=0.10.0" } + iconv-lite@0.7.2: + resolution: + { + integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==, + } + engines: { node: ">=0.10.0" } + ieee754@1.2.1: resolution: { @@ -4811,10 +5246,10 @@ packages: } engines: { node: ">= 4" } - immutable@5.1.3: + immutable@5.1.5: resolution: { - integrity: sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==, + integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==, } import-fresh@3.3.1: @@ -4851,6 +5286,12 @@ packages: } engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 } + inline-style-parser@0.2.7: + resolution: + { + integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==, + } + internal-slot@1.1.0: resolution: { @@ -4858,6 +5299,33 @@ packages: } engines: { node: ">= 0.4" } + io-ts@2.2.22: + resolution: + { + integrity: sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==, + } + peerDependencies: + fp-ts: ^2.5.0 + + ipaddr.js@1.9.1: + resolution: + { + integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==, + } + engines: { node: ">= 0.10" } + + is-alphabetical@2.0.1: + resolution: + { + integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==, + } + + is-alphanumerical@2.0.1: + resolution: + { + integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==, + } + is-arguments@1.2.0: resolution: { @@ -4939,6 +5407,12 @@ packages: } engines: { node: ">= 0.4" } + is-decimal@2.0.1: + resolution: + { + integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==, + } + is-docker@2.2.1: resolution: { @@ -5010,6 +5484,12 @@ packages: } engines: { node: ">=0.10.0" } + is-hexadecimal@2.0.1: + resolution: + { + integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==, + } + is-iexplorer@1.0.0: resolution: { @@ -5065,12 +5545,25 @@ packages: } engines: { node: ">=0.10.0" } + is-plain-obj@4.1.0: + resolution: + { + integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==, + } + engines: { node: ">=12" } + is-potential-custom-element-name@1.0.1: resolution: { integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==, } + is-promise@4.0.0: + resolution: + { + integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==, + } + is-regex@1.2.1: resolution: { @@ -5394,6 +5887,12 @@ packages: } engines: { node: ">=18" } + longest-streak@3.1.0: + resolution: + { + integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==, + } + loose-envify@1.4.0: resolution: { @@ -5471,6 +5970,12 @@ packages: } engines: { node: ">=16.14.0", npm: ">=8.1.0" } + markdown-table@3.0.4: + resolution: + { + integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==, + } + math-intrinsics@1.1.0: resolution: { @@ -5485,364 +5990,731 @@ packages: } engines: { node: ">=0.10.0" } - memoizerific@1.11.3: + mdast-util-find-and-replace@3.0.2: resolution: { - integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==, + integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==, } - merge-stream@2.0.0: + mdast-util-from-markdown@2.0.3: resolution: { - integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==, + integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==, } - merge2@1.4.1: + mdast-util-gfm-autolink-literal@2.0.1: resolution: { - integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, + integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==, } - engines: { node: ">= 8" } - micromatch@4.0.8: + mdast-util-gfm-footnote@2.1.0: resolution: { - integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, + integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==, } - engines: { node: ">=8.6" } - mime-db@1.52.0: + mdast-util-gfm-strikethrough@2.0.0: resolution: { - integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, + integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==, } - engines: { node: ">= 0.6" } - mime-types@2.1.35: + mdast-util-gfm-table@2.0.0: resolution: { - integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, + integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==, } - engines: { node: ">= 0.6" } - mimic-fn@4.0.0: + mdast-util-gfm-task-list-item@2.0.0: resolution: { - integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==, + integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==, } - engines: { node: ">=12" } - mimic-function@5.0.1: + mdast-util-gfm@3.1.0: resolution: { - integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==, + integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==, } - engines: { node: ">=18" } - min-indent@1.0.1: + mdast-util-mdx-expression@2.0.1: resolution: { - integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==, + integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==, } - engines: { node: ">=4" } - minimatch@3.1.2: + mdast-util-mdx-jsx@3.2.0: resolution: { - integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==, + integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==, } - minimatch@9.0.5: + mdast-util-mdxjs-esm@2.0.1: resolution: { - integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==, + integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==, } - engines: { node: ">=16 || 14 >=14.17" } - minimist@1.2.8: + mdast-util-phrasing@4.1.0: resolution: { - integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, + integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==, } - minipass@7.1.2: + mdast-util-to-hast@13.2.1: resolution: { - integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==, + integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==, } - engines: { node: ">=16 || 14 >=14.17" } - mobx-react-lite@4.1.0: + mdast-util-to-markdown@2.1.2: resolution: { - integrity: sha512-QEP10dpHHBeQNv1pks3WnHRCem2Zp636lq54M2nKO2Sarr13pL4u6diQXf65yzXUn0mkk18SyIDCm9UOJYTi1w==, + integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==, } - peerDependencies: - mobx: ^6.9.0 - react: ^16.8.0 || ^17 || ^18 || ^19 - react-dom: "*" - react-native: "*" - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - mobx@6.13.7: + mdast-util-to-string@4.0.0: resolution: { - integrity: sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==, + integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==, } - moment@2.30.1: + media-typer@1.1.0: resolution: { - integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==, + integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==, } + engines: { node: ">= 0.8" } - motion-dom@12.23.12: + memoizerific@1.11.3: resolution: { - integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==, + integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==, } - motion-utils@12.23.6: + merge-descriptors@2.0.0: resolution: { - integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==, + integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==, } + engines: { node: ">=18" } - mouse-change@1.4.0: + merge-stream@2.0.0: resolution: { - integrity: sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==, + integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==, } - mouse-event-offset@3.0.2: + merge2@1.4.1: resolution: { - integrity: sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==, + integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, } + engines: { node: ">= 8" } - mouse-event@1.0.5: + micromark-core-commonmark@2.0.3: resolution: { - integrity: sha512-ItUxtL2IkeSKSp9cyaX2JLUuKk2uMoxBg4bbOWVd29+CskYJR9BGsUqtXenNzKbnDshvupjUewDIYVrOB6NmGw==, + integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==, } - mouse-wheel@1.2.0: + micromark-extension-gfm-autolink-literal@2.1.0: resolution: { - integrity: sha512-+OfYBiUOCTWcTECES49neZwL5AoGkXE+lFjIvzwNCnYRlso+EnfvovcBxGoyQ0yQt806eSPjS675K0EwWknXmw==, + integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==, } - ms@2.0.0: + micromark-extension-gfm-footnote@2.1.0: resolution: { - integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==, + integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==, } - ms@2.1.3: + micromark-extension-gfm-strikethrough@2.1.0: resolution: { - integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, + integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==, } - murmurhash-js@1.0.0: + micromark-extension-gfm-table@2.1.1: resolution: { - integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==, + integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==, } - nanoid@3.3.11: + micromark-extension-gfm-tagfilter@2.0.0: resolution: { - integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, + integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==, } - engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } - hasBin: true - napi-postinstall@0.3.3: + micromark-extension-gfm-task-list-item@2.1.0: resolution: { - integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==, + integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==, } - engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } - hasBin: true - native-promise-only@0.8.1: + micromark-extension-gfm@3.0.0: resolution: { - integrity: sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==, + integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==, } - natural-compare@1.4.0: + micromark-factory-destination@2.0.1: resolution: { - integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, + integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==, } - needle@2.9.1: + micromark-factory-label@2.0.1: resolution: { - integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==, + integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==, } - engines: { node: ">= 4.4.x" } - hasBin: true - next-tick@1.1.0: + micromark-factory-space@2.0.1: resolution: { - integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==, + integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==, } - no-case@3.0.4: + micromark-factory-title@2.0.1: resolution: { - integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==, + integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==, } - node-addon-api@7.1.1: + micromark-factory-whitespace@2.0.1: resolution: { - integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==, + integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==, } - node-releases@2.0.20: + micromark-util-character@2.1.1: resolution: { - integrity: sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==, + integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==, } - normalize-svg-path@0.1.0: + micromark-util-chunked@2.0.1: resolution: { - integrity: sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==, + integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==, } - normalize-svg-path@1.1.0: + micromark-util-classify-character@2.0.1: resolution: { - integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==, + integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==, } - npm-run-path@5.3.0: + micromark-util-combine-extensions@2.0.1: resolution: { - integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==, + integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==, } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } - number-is-integer@1.0.1: + micromark-util-decode-numeric-character-reference@2.0.2: resolution: { - integrity: sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==, + integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==, } - engines: { node: ">=0.10.0" } - nwsapi@2.2.22: + micromark-util-decode-string@2.0.1: resolution: { - integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==, + integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==, } - object-assign@4.1.1: + micromark-util-encode@2.0.1: resolution: { - integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, + integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==, } - engines: { node: ">=0.10.0" } - object-inspect@1.13.4: + micromark-util-html-tag-name@2.0.1: resolution: { - integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==, + integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==, } - engines: { node: ">= 0.4" } - object-keys@1.1.1: + micromark-util-normalize-identifier@2.0.1: resolution: { - integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==, + integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==, } - engines: { node: ">= 0.4" } - object.assign@4.1.7: + micromark-util-resolve-all@2.0.1: resolution: { - integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==, + integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==, } - engines: { node: ">= 0.4" } - object.entries@1.1.9: + micromark-util-sanitize-uri@2.0.1: resolution: { - integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==, + integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==, } - engines: { node: ">= 0.4" } - object.fromentries@2.0.8: + micromark-util-subtokenize@2.1.0: resolution: { - integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==, + integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==, } - engines: { node: ">= 0.4" } - object.groupby@1.0.3: + micromark-util-symbol@2.0.1: resolution: { - integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==, + integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==, } - engines: { node: ">= 0.4" } - object.values@1.2.1: + micromark-util-types@2.0.2: resolution: { - integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==, + integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==, } - engines: { node: ">= 0.4" } - once@1.3.3: + micromark@4.0.2: resolution: { - integrity: sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==, + integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==, } - once@1.4.0: + micromatch@4.0.8: resolution: { - integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, + integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, } + engines: { node: ">=8.6" } - onetime@6.0.0: + mime-db@1.52.0: resolution: { - integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==, + integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, } - engines: { node: ">=12" } + engines: { node: ">= 0.6" } - onetime@7.0.0: + mime-db@1.54.0: resolution: { - integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==, + integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==, } - engines: { node: ">=18" } + engines: { node: ">= 0.6" } - open@8.4.2: + mime-types@2.1.35: resolution: { - integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==, + integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, } - engines: { node: ">=12" } + engines: { node: ">= 0.6" } - optionator@0.9.4: + mime-types@3.0.2: resolution: { - integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, + integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==, } - engines: { node: ">= 0.8.0" } + engines: { node: ">=18" } - own-keys@1.0.1: + mimic-fn@4.0.0: + resolution: + { + integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==, + } + engines: { node: ">=12" } + + mimic-function@5.0.1: + resolution: + { + integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==, + } + engines: { node: ">=18" } + + min-indent@1.0.1: + resolution: + { + integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==, + } + engines: { node: ">=4" } + + minimatch@3.1.2: + resolution: + { + integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==, + } + + minimatch@9.0.5: + resolution: + { + integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==, + } + engines: { node: ">=16 || 14 >=14.17" } + + minimist@1.2.8: + resolution: + { + integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, + } + + minipass@7.1.2: + resolution: + { + integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==, + } + engines: { node: ">=16 || 14 >=14.17" } + + mobx-react-lite@4.1.0: + resolution: + { + integrity: sha512-QEP10dpHHBeQNv1pks3WnHRCem2Zp636lq54M2nKO2Sarr13pL4u6diQXf65yzXUn0mkk18SyIDCm9UOJYTi1w==, + } + peerDependencies: + mobx: ^6.9.0 + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: "*" + react-native: "*" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + + mobx@6.13.7: + resolution: + { + integrity: sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==, + } + + molstar@5.7.0: + resolution: + { + integrity: sha512-Bo/QDiEkoRdhyhmFXNBPP2kiNTHZNgJO69AzqO+CrpkSj7JUrYNtBlt49vqa2AJfLUrBNgeeHcfjbxGLmfO7sQ==, + } + engines: { node: ">=22.0.0" } + hasBin: true + peerDependencies: + "@google-cloud/storage": ^7.14.0 + canvas: ^2.11.2 + gl: ^6.0.2 + jpeg-js: ^0.4.4 + pngjs: ^6.0.0 + react: ">=16.14.0" + react-dom: ">=16.14.0" + peerDependenciesMeta: + "@google-cloud/storage": + optional: true + canvas: + optional: true + gl: + optional: true + jpeg-js: + optional: true + pngjs: + optional: true + + moment@2.30.1: + resolution: + { + integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==, + } + + motion-dom@12.23.12: + resolution: + { + integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==, + } + + motion-utils@12.23.6: + resolution: + { + integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==, + } + + mouse-change@1.4.0: + resolution: + { + integrity: sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==, + } + + mouse-event-offset@3.0.2: + resolution: + { + integrity: sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==, + } + + mouse-event@1.0.5: + resolution: + { + integrity: sha512-ItUxtL2IkeSKSp9cyaX2JLUuKk2uMoxBg4bbOWVd29+CskYJR9BGsUqtXenNzKbnDshvupjUewDIYVrOB6NmGw==, + } + + mouse-wheel@1.2.0: + resolution: + { + integrity: sha512-+OfYBiUOCTWcTECES49neZwL5AoGkXE+lFjIvzwNCnYRlso+EnfvovcBxGoyQ0yQt806eSPjS675K0EwWknXmw==, + } + + ms@2.0.0: + resolution: + { + integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==, + } + + ms@2.1.3: + resolution: + { + integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, + } + + murmurhash-js@1.0.0: + resolution: + { + integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==, + } + + mutative@1.3.0: + resolution: + { + integrity: sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ==, + } + engines: { node: ">=14.0" } + + nanoid@3.3.11: + resolution: + { + integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + + napi-postinstall@0.3.3: + resolution: + { + integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==, + } + engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } + hasBin: true + + native-promise-only@0.8.1: + resolution: + { + integrity: sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==, + } + + natural-compare@1.4.0: + resolution: + { + integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, + } + + needle@2.9.1: + resolution: + { + integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==, + } + engines: { node: ">= 4.4.x" } + hasBin: true + + negotiator@0.6.4: + resolution: + { + integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==, + } + engines: { node: ">= 0.6" } + + negotiator@1.0.0: + resolution: + { + integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==, + } + engines: { node: ">= 0.6" } + + next-tick@1.1.0: + resolution: + { + integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==, + } + + no-case@3.0.4: + resolution: + { + integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==, + } + + node-addon-api@7.1.1: + resolution: + { + integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==, + } + + node-fetch@2.7.0: + resolution: + { + integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==, + } + engines: { node: 4.x || >=6.0.0 } + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-releases@2.0.20: + resolution: + { + integrity: sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==, + } + + normalize-svg-path@0.1.0: + resolution: + { + integrity: sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==, + } + + normalize-svg-path@1.1.0: + resolution: + { + integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==, + } + + npm-run-path@5.3.0: + resolution: + { + integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==, + } + engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + + number-is-integer@1.0.1: + resolution: + { + integrity: sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==, + } + engines: { node: ">=0.10.0" } + + nwsapi@2.2.22: + resolution: + { + integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==, + } + + object-assign@4.1.1: + resolution: + { + integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, + } + engines: { node: ">=0.10.0" } + + object-inspect@1.13.4: + resolution: + { + integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==, + } + engines: { node: ">= 0.4" } + + object-keys@1.1.1: + resolution: + { + integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==, + } + engines: { node: ">= 0.4" } + + object.assign@4.1.7: + resolution: + { + integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==, + } + engines: { node: ">= 0.4" } + + object.entries@1.1.9: + resolution: + { + integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==, + } + engines: { node: ">= 0.4" } + + object.fromentries@2.0.8: + resolution: + { + integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==, + } + engines: { node: ">= 0.4" } + + object.getownpropertydescriptors@2.1.9: + resolution: + { + integrity: sha512-mt8YM6XwsTTovI+kdZdHSxoyF2DI59up034orlC9NfweclcWOt7CVascNNLp6U+bjFVCVCIh9PwS76tDM/rH8g==, + } + engines: { node: ">= 0.4" } + + object.groupby@1.0.3: + resolution: + { + integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==, + } + engines: { node: ">= 0.4" } + + object.values@1.2.1: + resolution: + { + integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==, + } + engines: { node: ">= 0.4" } + + on-finished@2.4.1: + resolution: + { + integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==, + } + engines: { node: ">= 0.8" } + + on-headers@1.1.0: + resolution: + { + integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==, + } + engines: { node: ">= 0.8" } + + once@1.3.3: + resolution: + { + integrity: sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==, + } + + once@1.4.0: + resolution: + { + integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, + } + + onetime@6.0.0: + resolution: + { + integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==, + } + engines: { node: ">=12" } + + onetime@7.0.0: + resolution: + { + integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==, + } + engines: { node: ">=18" } + + open@8.4.2: + resolution: + { + integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==, + } + engines: { node: ">=12" } + + optionator@0.9.4: + resolution: + { + integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, + } + engines: { node: ">= 0.8.0" } + + own-keys@1.0.1: resolution: { integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==, @@ -5882,6 +6754,12 @@ packages: integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==, } + parse-entities@4.0.2: + resolution: + { + integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==, + } + parse-json@5.2.0: resolution: { @@ -5913,6 +6791,13 @@ packages: integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==, } + parseurl@1.3.3: + resolution: + { + integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==, + } + engines: { node: ">= 0.8" } + path-exists@4.0.0: resolution: { @@ -5947,6 +6832,12 @@ packages: } engines: { node: ">=16 || 14 >=14.18" } + path-to-regexp@8.3.0: + resolution: + { + integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==, + } + path-type@4.0.0: resolution: { @@ -6132,12 +7023,25 @@ packages: integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, } + property-information@7.1.0: + resolution: + { + integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==, + } + protocol-buffers-schema@3.6.0: resolution: { integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==, } + proxy-addr@2.0.7: + resolution: + { + integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==, + } + engines: { node: ">= 0.10" } + proxy-from-env@1.1.0: resolution: { @@ -6151,6 +7055,13 @@ packages: } engines: { node: ">=6" } + qs@6.15.0: + resolution: + { + integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==, + } + engines: { node: ">=0.6" } + queue-microtask@1.2.3: resolution: { @@ -6175,6 +7086,20 @@ packages: integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==, } + range-parser@1.2.1: + resolution: + { + integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==, + } + engines: { node: ">= 0.6" } + + raw-body@3.0.2: + resolution: + { + integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==, + } + engines: { node: ">= 0.10" } + react-confetti@6.4.0: resolution: { @@ -6233,6 +7158,15 @@ packages: integrity: sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==, } + react-markdown@10.1.0: + resolution: + { + integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==, + } + peerDependencies: + "@types/react": ">=18" + react: ">=18" + react-plotly.js@2.6.0: resolution: { @@ -6365,6 +7299,30 @@ packages: integrity: sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==, } + remark-gfm@4.0.1: + resolution: + { + integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==, + } + + remark-parse@11.0.0: + resolution: + { + integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==, + } + + remark-rehype@11.1.2: + resolution: + { + integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==, + } + + remark-stringify@11.0.0: + resolution: + { + integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==, + } + reselect@5.1.1: resolution: { @@ -6418,94 +7376,273 @@ packages: } engines: { node: ">=18" } - reusify@1.1.0: + reusify@1.1.0: + resolution: + { + integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==, + } + engines: { iojs: ">=1.0.0", node: ">=0.10.0" } + + rfdc@1.4.1: + resolution: + { + integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==, + } + + right-now@1.0.0: + resolution: + { + integrity: sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==, + } + + rollup@4.50.1: + resolution: + { + integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==, + } + engines: { node: ">=18.0.0", npm: ">=8.0.0" } + hasBin: true + + router@2.2.0: + resolution: + { + integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==, + } + engines: { node: ">= 18" } + + rrweb-cssom@0.8.0: + resolution: + { + integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==, + } + + run-parallel@1.2.0: + resolution: + { + integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, + } + + rw@1.3.3: + resolution: + { + integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==, + } + + rxjs@7.8.2: + resolution: + { + integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==, + } + + safe-array-concat@1.1.3: + resolution: + { + integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==, + } + engines: { node: ">=0.4" } + + safe-buffer@5.1.2: + resolution: + { + integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==, + } + + safe-buffer@5.2.1: + resolution: + { + integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, + } + + safe-push-apply@1.0.0: + resolution: + { + integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==, + } + engines: { node: ">= 0.4" } + + safe-regex-test@1.1.0: + resolution: + { + integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==, + } + engines: { node: ">= 0.4" } + + safer-buffer@2.1.2: + resolution: + { + integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, + } + + sass-embedded-all-unknown@1.98.0: + resolution: + { + integrity: sha512-6n4RyK7/1mhdfYvpP3CClS3fGoYqDvRmLClCESS6I7+SAzqjxvGG6u5Fo+cb1nrPNbbilgbM4QKdgcgWHO9NCA==, + } + cpu: ["!arm", "!arm64", "!riscv64", "!x64"] + + sass-embedded-android-arm64@1.98.0: + resolution: + { + integrity: sha512-M9Ra98A6vYJHpwhoC/5EuH1eOshQ9ZyNwC8XifUDSbRl/cGeQceT1NReR9wFj3L7s1pIbmes1vMmaY2np0uAKQ==, + } + engines: { node: ">=14.0.0" } + cpu: [arm64] + os: [android] + + sass-embedded-android-arm@1.98.0: + resolution: + { + integrity: sha512-LjGiMhHgu7VL1n7EJxTCre1x14bUsWd9d3dnkS2rku003IWOI/fxc7OXgaKagoVzok1kv09rzO3vFXJR5ZeONQ==, + } + engines: { node: ">=14.0.0" } + cpu: [arm] + os: [android] + + sass-embedded-android-riscv64@1.98.0: + resolution: + { + integrity: sha512-WPe+0NbaJIZE1fq/RfCZANMeIgmy83x4f+SvFOG7LhUthHpZWcOcrPTsCKKmN3xMT3iw+4DXvqTYOCYGRL3hcQ==, + } + engines: { node: ">=14.0.0" } + cpu: [riscv64] + os: [android] + + sass-embedded-android-x64@1.98.0: + resolution: + { + integrity: sha512-zrD25dT7OHPEgLWuPEByybnIfx4rnCtfge4clBgjZdZ3lF6E7qNLRBtSBmoFflh6Vg0RlEjJo5VlpnTMBM5MQQ==, + } + engines: { node: ">=14.0.0" } + cpu: [x64] + os: [android] + + sass-embedded-darwin-arm64@1.98.0: + resolution: + { + integrity: sha512-cgr1z9rBnCdMf8K+JabIaYd9Rag2OJi5mjq08XJfbJGMZV/TA6hFJCLGkr5/+ZOn4/geTM5/3aSfQ8z5EIJAOg==, + } + engines: { node: ">=14.0.0" } + cpu: [arm64] + os: [darwin] + + sass-embedded-darwin-x64@1.98.0: resolution: { - integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==, + integrity: sha512-OLBOCs/NPeiMqTdOrMFbVHBQFj19GS3bSVSxIhcCq16ZyhouUkYJEZjxQgzv9SWA2q6Ki8GCqp4k6jMeUY9dcA==, } - engines: { iojs: ">=1.0.0", node: ">=0.10.0" } + engines: { node: ">=14.0.0" } + cpu: [x64] + os: [darwin] - rfdc@1.4.1: + sass-embedded-linux-arm64@1.98.0: resolution: { - integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==, + integrity: sha512-axOE3t2MTBwCtkUCbrdM++Gj0gC0fdHJPrgzQ+q1WUmY9NoNMGqflBtk5mBZaWUeha2qYO3FawxCB8lctFwCtw==, } + engines: { node: ">=14.0.0" } + cpu: [arm64] + os: [linux] - right-now@1.0.0: + sass-embedded-linux-arm@1.98.0: resolution: { - integrity: sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==, + integrity: sha512-03baQZCxVyEp8v1NWBRlzGYrmVT/LK7ZrHlF1piscGiGxwfdxoLXVuxsylx3qn/dD/4i/rh7Bzk7reK1br9jvQ==, } + engines: { node: ">=14.0.0" } + cpu: [arm] + os: [linux] - rollup@4.50.1: + sass-embedded-linux-musl-arm64@1.98.0: resolution: { - integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==, + integrity: sha512-LeqNxQA8y4opjhe68CcFvMzCSrBuJqYVFbwElEj9bagHXQHTp9xVPJRn6VcrC+0VLEDq13HVXMv7RslIuU0zmA==, } - engines: { node: ">=18.0.0", npm: ">=8.0.0" } - hasBin: true + engines: { node: ">=14.0.0" } + cpu: [arm64] + os: [linux] - rrweb-cssom@0.8.0: + sass-embedded-linux-musl-arm@1.98.0: resolution: { - integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==, + integrity: sha512-OBkjTDPYR4hSaueOGIM6FDpl9nt/VZwbSRpbNu9/eEJcxE8G/vynRugW8KRZmCFjPy8j/jkGBvvS+k9iOqKV3g==, } + engines: { node: ">=14.0.0" } + cpu: [arm] + os: [linux] - run-parallel@1.2.0: + sass-embedded-linux-musl-riscv64@1.98.0: resolution: { - integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, + integrity: sha512-7w6hSuOHKt8FZsmjRb3iGSxEzM87fO9+M8nt5JIQYMhHTj5C+JY/vcske0v715HCVj5e1xyTnbGXf8FcASeAIw==, } + engines: { node: ">=14.0.0" } + cpu: [riscv64] + os: [linux] - rw@1.3.3: + sass-embedded-linux-musl-x64@1.98.0: resolution: { - integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==, + integrity: sha512-QikNyDEJOVqPmxyCFkci8ZdCwEssdItfjQFJB+D+Uy5HFqcS5Lv3d3GxWNX/h1dSb23RPyQdQc267ok5SbEyJw==, } + engines: { node: ">=14.0.0" } + cpu: [x64] + os: [linux] - safe-array-concat@1.1.3: + sass-embedded-linux-riscv64@1.98.0: resolution: { - integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==, + integrity: sha512-E7fNytc/v4xFBQKzgzBddV/jretA4ULAPO6XmtBiQu4zZBdBozuSxsQLe2+XXeb0X4S2GIl72V7IPABdqke/vA==, } - engines: { node: ">=0.4" } + engines: { node: ">=14.0.0" } + cpu: [riscv64] + os: [linux] - safe-buffer@5.1.2: + sass-embedded-linux-x64@1.98.0: resolution: { - integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==, + integrity: sha512-VsvP0t/uw00mMNPv3vwyYKUrFbqzxQHnRMO+bHdAMjvLw4NFf6mscpym9Bzf+NXwi1ZNKnB6DtXjmcpcvqFqYg==, } + engines: { node: ">=14.0.0" } + cpu: [x64] + os: [linux] - safe-buffer@5.2.1: + sass-embedded-unknown-all@1.98.0: resolution: { - integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, + integrity: sha512-C4MMzcAo3oEDQnW7L8SBgB9F2Fq5qHPnaYTZRMOH3Mp/7kM4OooBInXpCiiFjLnjY95hzP4KyctVx0uYR6MYlQ==, } + os: ["!android", "!darwin", "!linux", "!win32"] - safe-push-apply@1.0.0: + sass-embedded-win32-arm64@1.98.0: resolution: { - integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==, + integrity: sha512-nP/10xbAiPbhQkMr3zQfXE4TuOxPzWRQe1Hgbi90jv2R4TbzbqQTuZVOaJf7KOAN4L2Bo6XCTRjK5XkVnwZuwQ==, } - engines: { node: ">= 0.4" } + engines: { node: ">=14.0.0" } + cpu: [arm64] + os: [win32] - safe-regex-test@1.1.0: + sass-embedded-win32-x64@1.98.0: resolution: { - integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==, + integrity: sha512-/lbrVsfbcbdZQ5SJCWcV0NVPd6YRs+FtAnfedp4WbCkO/ZO7Zt/58MvI4X2BVpRY/Nt5ZBo1/7v2gYcQ+J4svQ==, } - engines: { node: ">= 0.4" } + engines: { node: ">=14.0.0" } + cpu: [x64] + os: [win32] - safer-buffer@2.1.2: + sass-embedded@1.98.0: resolution: { - integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, + integrity: sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==, } + engines: { node: ">=16.0.0" } + hasBin: true - sass@1.89.2: + sass@1.98.0: resolution: { - integrity: sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==, + integrity: sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==, } engines: { node: ">=14.0.0" } hasBin: true @@ -6544,6 +7681,20 @@ packages: engines: { node: ">=10" } hasBin: true + send@1.2.1: + resolution: + { + integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==, + } + engines: { node: ">= 18" } + + serve-static@2.2.1: + resolution: + { + integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==, + } + engines: { node: ">= 18" } + set-cookie-parser@2.7.1: resolution: { @@ -6571,6 +7722,12 @@ packages: } engines: { node: ">= 0.4" } + setprototypeof@1.2.0: + resolution: + { + integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==, + } + shallow-copy@0.0.1: resolution: { @@ -6691,6 +7848,12 @@ packages: } engines: { node: ">=0.10.0" } + space-separated-tokens@2.0.2: + resolution: + { + integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==, + } + stable-hash@0.0.5: resolution: { @@ -6715,6 +7878,13 @@ packages: integrity: sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==, } + statuses@2.0.2: + resolution: + { + integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==, + } + engines: { node: ">= 0.8" } + std-env@3.9.0: resolution: { @@ -6832,6 +8002,12 @@ packages: integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==, } + stringify-entities@4.0.4: + resolution: + { + integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==, + } + strip-ansi@6.0.1: resolution: { @@ -6893,6 +8069,18 @@ packages: integrity: sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==, } + style-to-js@1.1.21: + resolution: + { + integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==, + } + + style-to-object@1.0.14: + resolution: + { + integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==, + } + styled-components@6.1.19: resolution: { @@ -6940,6 +8128,13 @@ packages: } engines: { node: ">=8" } + supports-color@8.1.1: + resolution: + { + integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==, + } + engines: { node: ">=10" } + supports-preserve-symlinks-flag@1.0.0: resolution: { @@ -6971,12 +8166,32 @@ packages: integrity: sha512-vJJjVq/R5lSr2KLfVXVAStktfcfa1pNFjFOgyJnzZFXlO/fDZ5DmM8FpnSKKzLPfEYTVeXuVBTHF296TpxuJVg==, } + swagger-ui-dist@5.32.0: + resolution: + { + integrity: sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==, + } + symbol-tree@3.2.4: resolution: { integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==, } + sync-child-process@1.0.2: + resolution: + { + integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==, + } + engines: { node: ">=16.0.0" } + + sync-message-port@1.2.0: + resolution: + { + integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==, + } + engines: { node: ">=16.0.0" } + terser@5.42.0: resolution: { @@ -7107,6 +8322,13 @@ packages: } engines: { node: ">=8.0" } + toidentifier@1.0.1: + resolution: + { + integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==, + } + engines: { node: ">=0.6" } + topojson-client@3.1.0: resolution: { @@ -7121,6 +8343,12 @@ packages: } engines: { node: ">=16" } + tr46@0.0.3: + resolution: + { + integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==, + } + tr46@5.1.1: resolution: { @@ -7128,6 +8356,18 @@ packages: } engines: { node: ">=18" } + trim-lines@3.0.1: + resolution: + { + integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==, + } + + trough@2.2.0: + resolution: + { + integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==, + } + ts-api-utils@2.1.0: resolution: { @@ -7202,6 +8442,13 @@ packages: } engines: { node: ">=12.20" } + type-is@2.0.1: + resolution: + { + integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==, + } + engines: { node: ">= 0.6" } + type@2.7.3: resolution: { @@ -7273,12 +8520,54 @@ packages: } engines: { node: ">= 0.4" } + undici-types@6.21.0: + resolution: + { + integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==, + } + undici-types@7.10.0: resolution: { integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==, } + unified@11.0.5: + resolution: + { + integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==, + } + + unist-util-is@6.0.1: + resolution: + { + integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==, + } + + unist-util-position@5.0.0: + resolution: + { + integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==, + } + + unist-util-stringify-position@4.0.0: + resolution: + { + integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==, + } + + unist-util-visit-parents@6.0.2: + resolution: + { + integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==, + } + + unist-util-visit@5.1.0: + resolution: + { + integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==, + } + universalify@2.0.1: resolution: { @@ -7286,6 +8575,13 @@ packages: } engines: { node: ">= 10.0.0" } + unpipe@1.0.0: + resolution: + { + integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==, + } + engines: { node: ">= 0.8" } + unplugin@1.16.1: resolution: { @@ -7340,6 +8636,13 @@ packages: integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, } + util.promisify@1.1.3: + resolution: + { + integrity: sha512-GIEaZ6o86fj09Wtf0VfZ5XP7tmd4t3jM5aZCgmBi231D0DB1AEBa3Aa6MP48DMsAIi96WkpWLimIWVwOjbDMOw==, + } + engines: { node: ">= 0.8" } + util@0.12.5: resolution: { @@ -7360,6 +8663,31 @@ packages: } hasBin: true + varint@6.0.0: + resolution: + { + integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==, + } + + vary@1.1.2: + resolution: + { + integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, + } + engines: { node: ">= 0.8" } + + vfile-message@4.0.3: + resolution: + { + integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==, + } + + vfile@6.0.3: + resolution: + { + integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==, + } + vite-node@3.2.4: resolution: { @@ -7486,6 +8814,12 @@ packages: integrity: sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q==, } + webidl-conversions@3.0.1: + resolution: + { + integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==, + } + webidl-conversions@7.0.0: resolution: { @@ -7520,6 +8854,12 @@ packages: } engines: { node: ">=18" } + whatwg-url@5.0.0: + resolution: + { + integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==, + } + which-boxed-primitive@1.1.1: resolution: { @@ -7700,6 +9040,12 @@ packages: react: optional: true + zwitch@2.0.4: + resolution: + { + integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==, + } + snapshots: "@adobe/css-tools@4.4.4": {} @@ -7883,6 +9229,8 @@ snapshots: "@babel/helper-string-parser": 7.27.1 "@babel/helper-validator-identifier": 7.27.1 + "@bufbuild/protobuf@2.11.0": {} + "@choojs/findup@0.2.1": dependencies: commander: 2.20.3 @@ -8175,12 +9523,12 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - "@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1))": + "@joshwooding/vite-plugin-react-docgen-typescript@0.5.0(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1))": dependencies: glob: 10.4.5 magic-string: 0.27.0 react-docgen-typescript: 2.4.0(typescript@5.6.3) - vite: 6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) optionalDependencies: typescript: 5.6.3 @@ -8606,6 +9954,8 @@ snapshots: "@rtsao/scc@1.1.0": {} + "@scarf/scarf@1.4.0": {} + "@storybook/addon-actions@8.6.14(storybook@8.6.14(prettier@3.6.2))": dependencies: "@storybook/global": 5.0.0 @@ -8706,13 +10056,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - "@storybook/builder-vite@8.6.14(storybook@8.6.14(prettier@3.6.2))(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1))": + "@storybook/builder-vite@8.6.14(storybook@8.6.14(prettier@3.6.2))(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1))": dependencies: "@storybook/csf-plugin": 8.6.14(storybook@8.6.14(prettier@3.6.2)) browser-assert: 1.2.1 storybook: 8.6.14(prettier@3.6.2) ts-dedent: 2.2.0 - vite: 6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) "@storybook/components@8.6.14(storybook@8.6.14(prettier@3.6.2))": dependencies: @@ -8775,11 +10125,11 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.6.14(prettier@3.6.2) - "@storybook/react-vite@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.50.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1))": + "@storybook/react-vite@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.50.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1))": dependencies: - "@joshwooding/vite-plugin-react-docgen-typescript": 0.5.0(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1)) + "@joshwooding/vite-plugin-react-docgen-typescript": 0.5.0(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1)) "@rollup/pluginutils": 5.3.0(rollup@4.50.1) - "@storybook/builder-vite": 8.6.14(storybook@8.6.14(prettier@3.6.2))(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1)) + "@storybook/builder-vite": 8.6.14(storybook@8.6.14(prettier@3.6.2))(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1)) "@storybook/react": 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.6.3) find-up: 5.0.0 magic-string: 0.30.19 @@ -8789,7 +10139,7 @@ snapshots: resolve: 1.22.10 storybook: 8.6.14(prettier@3.6.2) tsconfig-paths: 4.2.0 - vite: 6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) optionalDependencies: "@storybook/test": 8.6.14(storybook@8.6.14(prettier@3.6.2)) transitivePeerDependencies: @@ -8968,6 +10318,8 @@ snapshots: tslib: 2.8.1 optional: true + "@types/argparse@2.0.17": {} + "@types/aria-query@5.0.4": {} "@types/babel__core@7.20.5": @@ -8991,10 +10343,26 @@ snapshots: dependencies: "@babel/types": 7.28.4 + "@types/benchmark@2.1.5": {} + + "@types/body-parser@1.19.6": + dependencies: + "@types/connect": 3.4.38 + "@types/node": 24.3.1 + "@types/chai@5.2.2": dependencies: "@types/deep-eql": 4.0.2 + "@types/compression@1.8.1": + dependencies: + "@types/express": 5.0.6 + "@types/node": 24.3.1 + + "@types/connect@3.4.38": + dependencies: + "@types/node": 24.3.1 + "@types/d3-color@3.1.3": {} "@types/d3-drag@3.0.7": @@ -9016,12 +10384,33 @@ snapshots: "@types/d3-interpolate": 3.0.4 "@types/d3-selection": 3.0.11 + "@types/debug@4.1.12": + dependencies: + "@types/ms": 2.1.0 + "@types/deep-eql@4.0.2": {} "@types/doctrine@0.0.9": {} + "@types/estree-jsx@1.0.5": + dependencies: + "@types/estree": 1.0.8 + "@types/estree@1.0.8": {} + "@types/express-serve-static-core@5.1.1": + dependencies: + "@types/node": 24.3.1 + "@types/qs": 6.15.0 + "@types/range-parser": 1.2.7 + "@types/send": 1.2.1 + + "@types/express@5.0.6": + dependencies: + "@types/body-parser": 1.19.6 + "@types/express-serve-static-core": 5.1.1 + "@types/serve-static": 2.2.0 + "@types/file-saver@2.0.7": {} "@types/geojson-vt@3.2.5": @@ -9030,11 +10419,17 @@ snapshots: "@types/geojson@7946.0.16": {} + "@types/hast@3.0.4": + dependencies: + "@types/unist": 3.0.3 + "@types/hoist-non-react-statics@3.3.7(@types/react@18.3.24)": dependencies: "@types/react": 18.3.24 hoist-non-react-statics: 3.3.2 + "@types/http-errors@2.0.5": {} + "@types/json-schema@7.0.15": {} "@types/json5@0.0.29": {} @@ -9047,8 +10442,23 @@ snapshots: "@types/mapbox__point-geometry": 0.1.4 "@types/pbf": 3.0.5 + "@types/mdast@4.0.4": + dependencies: + "@types/unist": 3.0.3 + "@types/mdx@2.0.13": {} + "@types/ms@2.1.0": {} + + "@types/node-fetch@2.6.13": + dependencies: + "@types/node": 24.3.1 + form-data: 4.0.4 + + "@types/node@22.19.15": + dependencies: + undici-types: 6.21.0 + "@types/node@24.3.1": dependencies: undici-types: 7.10.0 @@ -9065,6 +10475,10 @@ snapshots: "@types/prop-types@15.7.15": {} + "@types/qs@6.15.0": {} + + "@types/range-parser@1.2.7": {} + "@types/react-dom@18.3.7(@types/react@18.3.24)": dependencies: "@types/react": 18.3.24 @@ -9085,12 +10499,27 @@ snapshots: "@types/resolve@1.20.6": {} + "@types/send@1.2.1": + dependencies: + "@types/node": 24.3.1 + + "@types/serve-static@2.2.0": + dependencies: + "@types/http-errors": 2.0.5 + "@types/node": 24.3.1 + "@types/stylis@4.2.5": {} "@types/supercluster@7.1.3": dependencies: "@types/geojson": 7946.0.16 + "@types/swagger-ui-dist@3.30.6": {} + + "@types/unist@2.0.11": {} + + "@types/unist@3.0.3": {} + "@types/uuid@9.0.8": {} "@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.6.3))(eslint@9.35.0)(typescript@5.6.3)": @@ -9186,6 +10615,8 @@ snapshots: "@typescript-eslint/types": 8.43.0 eslint-visitor-keys: 4.2.1 + "@ungap/structured-clone@1.3.0": {} + "@unrs/resolver-binding-android-arm-eabi@1.11.1": optional: true @@ -9245,7 +10676,7 @@ snapshots: "@unrs/resolver-binding-win32-x64-msvc@1.11.1": optional: true - "@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1))": + "@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1))": dependencies: "@babel/core": 7.28.4 "@babel/plugin-transform-react-jsx-self": 7.27.1(@babel/core@7.28.4) @@ -9253,7 +10684,7 @@ snapshots: "@rolldown/pluginutils": 1.0.0-beta.27 "@types/babel__core": 7.20.5 react-refresh: 0.17.0 - vite: 6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -9272,13 +10703,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - "@vitest/mocker@3.2.4(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1))": + "@vitest/mocker@3.2.4(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1))": dependencies: "@vitest/spy": 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) "@vitest/pretty-format@2.0.5": dependencies: @@ -9331,9 +10762,9 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - "@xyflow/react@12.10.0(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + "@xyflow/react@12.10.2(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: - "@xyflow/system": 0.0.74 + "@xyflow/system": 0.0.76 classcat: 5.0.5 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -9342,7 +10773,7 @@ snapshots: - "@types/react" - immer - "@xyflow/system@0.0.74": + "@xyflow/system@0.0.76": dependencies: "@types/d3-drag": 3.0.7 "@types/d3-interpolate": 3.0.4 @@ -9356,6 +10787,11 @@ snapshots: abs-svg-path@0.1.1: {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -9458,6 +10894,17 @@ snapshots: es-abstract: 1.24.0 es-shim-unscopables: 1.1.0 + array.prototype.reduce@1.0.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-array-method-boxes-properly: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + is-string: 1.1.1 + array.prototype.tosorted@1.1.4: dependencies: call-bind: 1.0.8 @@ -9504,6 +10951,8 @@ snapshots: cosmiconfig: 7.1.0 resolve: 1.22.10 + bail@2.0.2: {} + balanced-match@1.0.2: {} base64-arraybuffer@1.0.2: {} @@ -9523,6 +10972,20 @@ snapshots: readable-stream: 2.3.8 safe-buffer: 5.2.1 + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + bootstrap@5.3.8(@popperjs/core@2.11.8): dependencies: "@popperjs/core": 2.11.8 @@ -9551,6 +11014,8 @@ snapshots: buffer-from@1.1.2: {} + bytes@3.1.2: {} + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -9582,6 +11047,8 @@ snapshots: dependencies: element-size: 1.1.1 + ccount@2.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -9602,6 +11069,14 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + check-error@2.1.1: {} chokidar@4.0.3: @@ -9668,14 +11143,34 @@ snapshots: colorette@2.0.20: {} + colorjs.io@0.5.2: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@13.1.0: {} commander@2.20.3: {} + compressible@2.0.18: + dependencies: + mime-db: 1.52.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + concat-map@0.0.1: {} concat-stream@1.6.2: @@ -9685,15 +11180,28 @@ snapshots: readable-stream: 2.3.8 typedarray: 0.0.6 + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.0.2: {} core-util-is@1.0.3: {} - corepack@0.34.0: {} + corepack@0.34.6: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 cosmiconfig@7.1.0: dependencies: @@ -9879,8 +11387,16 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -9903,6 +11419,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-kerning@2.1.2: {} @@ -9910,6 +11428,10 @@ snapshots: detect-libc@1.0.3: optional: true + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -9960,6 +11482,8 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + electron-to-chromium@1.5.217: {} element-size@1.1.1: {} @@ -9974,6 +11498,8 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -10045,6 +11571,8 @@ snapshots: unbox-primitive: 1.1.0 which-typed-array: 1.1.19 + es-array-method-boxes-properly@1.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -10154,8 +11682,12 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -10341,6 +11873,8 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -10349,6 +11883,8 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + event-emitter@0.3.5: dependencies: d: 1.0.2 @@ -10372,10 +11908,45 @@ snapshots: expect-type@1.2.2: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.1 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + ext@1.7.0: dependencies: type: 2.7.3 + extend@3.0.2: {} + falafel@2.2.5: dependencies: acorn: 7.4.1 @@ -10419,6 +11990,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.1 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-root@1.1.0: {} find-up@5.0.0: @@ -10464,6 +12046,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + + fp-ts@2.16.11: {} + framer-motion@12.23.12(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: motion-dom: 12.23.12 @@ -10474,6 +12060,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + fresh@2.0.0: {} + from2@2.3.0: dependencies: inherits: 2.0.4 @@ -10695,6 +12283,8 @@ snapshots: grid-index@1.1.0: {} + h264-mp4-encoder@1.0.12: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -10725,6 +12315,30 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + "@types/estree": 1.0.8 + "@types/hast": 3.0.4 + "@types/unist": 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + "@types/hast": 3.0.4 + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -10733,6 +12347,16 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-url-attributes@3.0.1: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -10759,14 +12383,17 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} ignore@7.0.5: {} - immutable@5.1.3: - optional: true + immutable@5.1.5: {} import-fresh@3.3.1: dependencies: @@ -10781,12 +12408,27 @@ snapshots: ini@4.1.3: {} + inline-style-parser@0.2.7: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 hasown: 2.0.2 side-channel: 1.1.0 + io-ts@2.2.22(fp-ts@2.16.11): + dependencies: + fp-ts: 2.16.11 + + ipaddr.js@1.9.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-arguments@1.2.0: dependencies: call-bound: 1.0.4 @@ -10840,6 +12482,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-docker@2.2.1: {} is-extglob@2.1.1: {} @@ -10871,6 +12515,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-iexplorer@1.0.0: {} is-map@2.0.3: {} @@ -10890,8 +12536,12 @@ snapshots: is-plain-obj@1.1.0: {} + is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -11093,6 +12743,8 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -11101,95 +12753,445 @@ snapshots: lower-case@2.0.2: dependencies: - tslib: 2.8.1 + tslib: 2.8.1 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.27.0: + dependencies: + "@jridgewell/sourcemap-codec": 1.5.5 + + magic-string@0.30.19: + dependencies: + "@jridgewell/sourcemap-codec": 1.5.5 + + map-limit@0.0.1: + dependencies: + once: 1.3.3 + + map-or-similar@1.5.0: {} + + mapbox-gl@1.13.3: + dependencies: + "@mapbox/geojson-rewind": 0.5.2 + "@mapbox/geojson-types": 1.0.2 + "@mapbox/jsonlint-lines-primitives": 2.0.2 + "@mapbox/mapbox-gl-supported": 1.5.0(mapbox-gl@1.13.3) + "@mapbox/point-geometry": 0.1.0 + "@mapbox/tiny-sdf": 1.2.5 + "@mapbox/unitbezier": 0.0.0 + "@mapbox/vector-tile": 1.3.1 + "@mapbox/whoots-js": 3.1.0 + csscolorparser: 1.0.3 + earcut: 2.2.4 + geojson-vt: 3.2.1 + gl-matrix: 3.4.4 + grid-index: 1.1.0 + murmurhash-js: 1.0.0 + pbf: 3.3.0 + potpack: 1.0.2 + quickselect: 2.0.0 + rw: 1.3.3 + supercluster: 7.1.5 + tinyqueue: 2.0.3 + vt-pbf: 3.1.3 + + maplibre-gl@4.7.1: + dependencies: + "@mapbox/geojson-rewind": 0.5.2 + "@mapbox/jsonlint-lines-primitives": 2.0.2 + "@mapbox/point-geometry": 0.1.0 + "@mapbox/tiny-sdf": 2.0.7 + "@mapbox/unitbezier": 0.0.1 + "@mapbox/vector-tile": 1.3.1 + "@mapbox/whoots-js": 3.1.0 + "@maplibre/maplibre-gl-style-spec": 20.4.0 + "@types/geojson": 7946.0.16 + "@types/geojson-vt": 3.2.5 + "@types/mapbox__point-geometry": 0.1.4 + "@types/mapbox__vector-tile": 1.3.4 + "@types/pbf": 3.0.5 + "@types/supercluster": 7.1.3 + earcut: 3.0.2 + geojson-vt: 4.0.2 + gl-matrix: 3.4.4 + global-prefix: 4.0.0 + kdbush: 4.0.2 + murmurhash-js: 1.0.0 + pbf: 3.3.0 + potpack: 2.1.0 + quickselect: 3.0.0 + supercluster: 8.0.1 + tinyqueue: 3.0.0 + vt-pbf: 3.1.3 + + markdown-table@3.0.4: {} + + math-intrinsics@1.1.0: {} + + math-log2@1.0.1: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + "@types/mdast": 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + "@types/mdast": 4.0.4 + "@types/unist": 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + "@types/mdast": 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + "@types/mdast": 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + "@types/mdast": 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + "@types/mdast": 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + "@types/mdast": 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + "@types/estree-jsx": 1.0.5 + "@types/hast": 3.0.4 + "@types/mdast": 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + "@types/estree-jsx": 1.0.5 + "@types/hast": 3.0.4 + "@types/mdast": 4.0.4 + "@types/unist": 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + "@types/estree-jsx": 1.0.5 + "@types/hast": 3.0.4 + "@types/mdast": 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + "@types/mdast": 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + "@types/hast": 3.0.4 + "@types/mdast": 4.0.4 + "@ungap/structured-clone": 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + "@types/mdast": 4.0.4 + "@types/unist": 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + "@types/mdast": 4.0.4 + + media-typer@1.1.0: {} + + memoizerific@1.11.3: + dependencies: + map-or-similar: 1.5.0 + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 - lru-cache@10.4.3: {} + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - lru-cache@5.1.1: + micromark-factory-whitespace@2.0.1: dependencies: - yallist: 3.1.1 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - lz-string@1.5.0: {} + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - magic-string@0.27.0: + micromark-util-chunked@2.0.1: dependencies: - "@jridgewell/sourcemap-codec": 1.5.5 + micromark-util-symbol: 2.0.1 - magic-string@0.30.19: + micromark-util-classify-character@2.0.1: dependencies: - "@jridgewell/sourcemap-codec": 1.5.5 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - map-limit@0.0.1: + micromark-util-combine-extensions@2.0.1: dependencies: - once: 1.3.3 + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 - map-or-similar@1.5.0: {} + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 - mapbox-gl@1.13.3: + micromark-util-decode-string@2.0.1: dependencies: - "@mapbox/geojson-rewind": 0.5.2 - "@mapbox/geojson-types": 1.0.2 - "@mapbox/jsonlint-lines-primitives": 2.0.2 - "@mapbox/mapbox-gl-supported": 1.5.0(mapbox-gl@1.13.3) - "@mapbox/point-geometry": 0.1.0 - "@mapbox/tiny-sdf": 1.2.5 - "@mapbox/unitbezier": 0.0.0 - "@mapbox/vector-tile": 1.3.1 - "@mapbox/whoots-js": 3.1.0 - csscolorparser: 1.0.3 - earcut: 2.2.4 - geojson-vt: 3.2.1 - gl-matrix: 3.4.4 - grid-index: 1.1.0 - murmurhash-js: 1.0.0 - pbf: 3.3.0 - potpack: 1.0.2 - quickselect: 2.0.0 - rw: 1.3.3 - supercluster: 7.1.5 - tinyqueue: 2.0.3 - vt-pbf: 3.1.3 + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 - maplibre-gl@4.7.1: + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: dependencies: - "@mapbox/geojson-rewind": 0.5.2 - "@mapbox/jsonlint-lines-primitives": 2.0.2 - "@mapbox/point-geometry": 0.1.0 - "@mapbox/tiny-sdf": 2.0.7 - "@mapbox/unitbezier": 0.0.1 - "@mapbox/vector-tile": 1.3.1 - "@mapbox/whoots-js": 3.1.0 - "@maplibre/maplibre-gl-style-spec": 20.4.0 - "@types/geojson": 7946.0.16 - "@types/geojson-vt": 3.2.5 - "@types/mapbox__point-geometry": 0.1.4 - "@types/mapbox__vector-tile": 1.3.4 - "@types/pbf": 3.0.5 - "@types/supercluster": 7.1.3 - earcut: 3.0.2 - geojson-vt: 4.0.2 - gl-matrix: 3.4.4 - global-prefix: 4.0.0 - kdbush: 4.0.2 - murmurhash-js: 1.0.0 - pbf: 3.3.0 - potpack: 2.1.0 - quickselect: 3.0.0 - supercluster: 8.0.1 - tinyqueue: 3.0.0 - vt-pbf: 3.1.3 + micromark-util-symbol: 2.0.1 - math-intrinsics@1.1.0: {} + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 - math-log2@1.0.1: {} + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 - memoizerific@1.11.3: + micromark-util-subtokenize@2.1.0: dependencies: - map-or-similar: 1.5.0 + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - merge-stream@2.0.0: {} + micromark-util-symbol@2.0.1: {} - merge2@1.4.1: {} + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + "@types/debug": 4.1.12 + debug: 4.4.1 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color micromatch@4.0.8: dependencies: @@ -11198,10 +13200,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -11230,6 +13238,38 @@ snapshots: mobx@6.13.7: {} + molstar@5.7.0(@types/react@18.3.24)(fp-ts@2.16.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + "@types/argparse": 2.0.17 + "@types/benchmark": 2.1.5 + "@types/compression": 1.8.1 + "@types/express": 5.0.6 + "@types/node": 22.19.15 + "@types/node-fetch": 2.6.13 + "@types/swagger-ui-dist": 3.30.6 + argparse: 2.0.1 + compression: 1.8.1 + cors: 2.8.6 + express: 5.2.1 + h264-mp4-encoder: 1.0.12 + immutable: 5.1.5 + io-ts: 2.2.22(fp-ts@2.16.11) + mutative: 1.3.0 + node-fetch: 2.7.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-markdown: 10.1.0(@types/react@18.3.24)(react@18.3.1) + remark-gfm: 4.0.1 + rxjs: 7.8.2 + swagger-ui-dist: 5.32.0 + tslib: 2.8.1 + util.promisify: 1.1.3 + transitivePeerDependencies: + - "@types/react" + - encoding + - fp-ts + - supports-color + moment@2.30.1: {} motion-dom@12.23.12: @@ -11258,6 +13298,8 @@ snapshots: murmurhash-js@1.0.0: {} + mutative@1.3.0: {} + nanoid@3.3.11: {} napi-postinstall@0.3.3: {} @@ -11274,6 +13316,10 @@ snapshots: transitivePeerDependencies: - supports-color + negotiator@0.6.4: {} + + negotiator@1.0.0: {} + next-tick@1.1.0: {} no-case@3.0.4: @@ -11284,6 +13330,10 @@ snapshots: node-addon-api@7.1.1: optional: true + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.20: {} normalize-svg-path@0.1.0: {} @@ -11331,6 +13381,16 @@ snapshots: es-abstract: 1.24.0 es-object-atoms: 1.1.1 + object.getownpropertydescriptors@2.1.9: + dependencies: + array.prototype.reduce: 1.0.8 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + gopd: 1.2.0 + safe-array-concat: 1.1.3 + object.groupby@1.0.3: dependencies: call-bind: 1.0.8 @@ -11344,6 +13404,12 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + once@1.3.3: dependencies: wrappy: 1.0.2 @@ -11397,6 +13463,16 @@ snapshots: parenthesis@3.1.8: {} + parse-entities@4.0.2: + dependencies: + "@types/unist": 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-json@5.2.0: dependencies: "@babel/code-frame": 7.27.1 @@ -11416,6 +13492,8 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -11429,6 +13507,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@8.3.0: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -11566,12 +13646,23 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-information@7.1.0: {} + protocol-buffers-schema@3.6.0: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} punycode@2.3.1: {} + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} quickselect@2.0.0: {} @@ -11582,6 +13673,15 @@ snapshots: dependencies: performance-now: 2.1.0 + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-confetti@6.4.0(react@18.3.1): dependencies: react: 18.3.1 @@ -11623,6 +13723,24 @@ snapshots: react-is@19.1.1: {} + react-markdown@10.1.0(@types/react@18.3.24)(react@18.3.1): + dependencies: + "@types/hast": 3.0.4 + "@types/mdast": 4.0.4 + "@types/react": 18.3.24 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 18.3.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-plotly.js@2.6.0(plotly.js@3.1.0(mapbox-gl@1.13.3))(react@18.3.1): dependencies: plotly.js: 3.1.0(mapbox-gl@1.13.3) @@ -11766,6 +13884,40 @@ snapshots: regl@2.1.1: {} + remark-gfm@4.0.1: + dependencies: + "@types/mdast": 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + "@types/mdast": 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + "@types/hast": 3.0.4 + "@types/mdast": 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + "@types/mdast": 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + reselect@5.1.1: {} resolve-from@4.0.0: {} @@ -11828,6 +13980,16 @@ snapshots: "@rollup/rollup-win32-x64-msvc": 4.50.1 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.1 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + rrweb-cssom@0.8.0: {} run-parallel@1.2.0: @@ -11836,6 +13998,10 @@ snapshots: rw@1.3.3: {} + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -11861,10 +14027,97 @@ snapshots: safer-buffer@2.1.2: {} - sass@1.89.2: + sass-embedded-all-unknown@1.98.0: + dependencies: + sass: 1.98.0 + optional: true + + sass-embedded-android-arm64@1.98.0: + optional: true + + sass-embedded-android-arm@1.98.0: + optional: true + + sass-embedded-android-riscv64@1.98.0: + optional: true + + sass-embedded-android-x64@1.98.0: + optional: true + + sass-embedded-darwin-arm64@1.98.0: + optional: true + + sass-embedded-darwin-x64@1.98.0: + optional: true + + sass-embedded-linux-arm64@1.98.0: + optional: true + + sass-embedded-linux-arm@1.98.0: + optional: true + + sass-embedded-linux-musl-arm64@1.98.0: + optional: true + + sass-embedded-linux-musl-arm@1.98.0: + optional: true + + sass-embedded-linux-musl-riscv64@1.98.0: + optional: true + + sass-embedded-linux-musl-x64@1.98.0: + optional: true + + sass-embedded-linux-riscv64@1.98.0: + optional: true + + sass-embedded-linux-x64@1.98.0: + optional: true + + sass-embedded-unknown-all@1.98.0: + dependencies: + sass: 1.98.0 + optional: true + + sass-embedded-win32-arm64@1.98.0: + optional: true + + sass-embedded-win32-x64@1.98.0: + optional: true + + sass-embedded@1.98.0: + dependencies: + "@bufbuild/protobuf": 2.11.0 + colorjs.io: 0.5.2 + immutable: 5.1.5 + rxjs: 7.8.2 + supports-color: 8.1.1 + sync-child-process: 1.0.2 + varint: 6.0.0 + optionalDependencies: + sass-embedded-all-unknown: 1.98.0 + sass-embedded-android-arm: 1.98.0 + sass-embedded-android-arm64: 1.98.0 + sass-embedded-android-riscv64: 1.98.0 + sass-embedded-android-x64: 1.98.0 + sass-embedded-darwin-arm64: 1.98.0 + sass-embedded-darwin-x64: 1.98.0 + sass-embedded-linux-arm: 1.98.0 + sass-embedded-linux-arm64: 1.98.0 + sass-embedded-linux-musl-arm: 1.98.0 + sass-embedded-linux-musl-arm64: 1.98.0 + sass-embedded-linux-musl-riscv64: 1.98.0 + sass-embedded-linux-musl-x64: 1.98.0 + sass-embedded-linux-riscv64: 1.98.0 + sass-embedded-linux-x64: 1.98.0 + sass-embedded-unknown-all: 1.98.0 + sass-embedded-win32-arm64: 1.98.0 + sass-embedded-win32-x64: 1.98.0 + + sass@1.98.0: dependencies: chokidar: 4.0.3 - immutable: 5.1.3 + immutable: 5.1.5 source-map-js: 1.2.1 optionalDependencies: "@parcel/watcher": 2.5.1 @@ -11884,6 +14137,31 @@ snapshots: semver@7.7.2: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.1: {} set-function-length@1.2.2: @@ -11908,6 +14186,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + shallow-copy@0.0.1: {} shallowequal@1.1.0: {} @@ -11979,6 +14259,8 @@ snapshots: source-map@0.6.1: {} + space-separated-tokens@2.0.2: {} + stable-hash@0.0.5: {} stack-trace@0.0.9: {} @@ -11989,6 +14271,8 @@ snapshots: dependencies: escodegen: 2.1.0 + statuses@2.0.2: {} + std-env@3.9.0: {} stop-iteration-iterator@1.1.0: @@ -12088,6 +14372,11 @@ snapshots: dependencies: safe-buffer: 5.1.2 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -12116,6 +14405,14 @@ snapshots: strongly-connected-components@1.0.1: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + styled-components@6.1.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: "@emotion/is-prop-valid": 1.2.2 @@ -12148,6 +14445,10 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} svg-arc-to-cubic-bezier@3.2.0: {} @@ -12169,8 +14470,18 @@ snapshots: parse-svg-path: 0.1.2 svg-path-bounds: 1.0.2 + swagger-ui-dist@5.32.0: + dependencies: + "@scarf/scarf": 1.4.0 + symbol-tree@3.2.4: {} + sync-child-process@1.0.2: + dependencies: + sync-message-port: 1.2.0 + + sync-message-port@1.2.0: {} + terser@5.42.0: dependencies: "@jridgewell/source-map": 0.3.11 @@ -12232,6 +14543,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + topojson-client@3.1.0: dependencies: commander: 2.20.3 @@ -12240,10 +14553,16 @@ snapshots: dependencies: tldts: 6.1.86 + tr46@0.0.3: {} + tr46@5.1.1: dependencies: punycode: 2.3.1 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.1.0(typescript@5.6.3): dependencies: typescript: 5.6.3 @@ -12279,6 +14598,12 @@ snapshots: type-fest@2.19.0: {} + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + type@2.7.3: {} typed-array-buffer@1.0.3: @@ -12341,10 +14666,47 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@6.21.0: {} + undici-types@7.10.0: {} + unified@11.0.5: + dependencies: + "@types/unist": 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + "@types/unist": 3.0.3 + + unist-util-position@5.0.0: + dependencies: + "@types/unist": 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + "@types/unist": 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + "@types/unist": 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + "@types/unist": 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} + unpipe@1.0.0: {} + unplugin@1.16.1: dependencies: acorn: 8.15.0 @@ -12394,6 +14756,21 @@ snapshots: util-deprecate@1.0.2: {} + util.promisify@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + for-each: 0.3.5 + get-intrinsic: 1.3.0 + has-proto: 1.2.0 + has-symbols: 1.1.0 + object.getownpropertydescriptors: 2.1.9 + safe-array-concat: 1.1.3 + util@0.12.5: dependencies: inherits: 2.0.4 @@ -12406,13 +14783,27 @@ snapshots: uuid@9.0.1: {} - vite-node@3.2.4(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1): + varint@6.0.0: {} + + vary@1.1.2: {} + + vfile-message@4.0.3: + dependencies: + "@types/unist": 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + "@types/unist": 3.0.3 + vfile-message: 4.0.3 + + vite-node@3.2.4(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) transitivePeerDependencies: - "@types/node" - jiti @@ -12427,29 +14818,29 @@ snapshots: - tsx - yaml - vite-plugin-svgr@4.5.0(rollup@4.50.1)(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1)): + vite-plugin-svgr@4.5.0(rollup@4.50.1)(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1)): dependencies: "@rollup/pluginutils": 5.3.0(rollup@4.50.1) "@svgr/core": 8.1.0(typescript@5.6.3) "@svgr/plugin-jsx": 8.1.0(@svgr/core@8.1.0(typescript@5.6.3)) - vite: 6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1)): + vite-tsconfig-paths@5.1.4(typescript@5.6.3)(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1)): dependencies: debug: 4.4.1 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.6.3) optionalDependencies: - vite: 6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1): + vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -12460,15 +14851,16 @@ snapshots: optionalDependencies: "@types/node": 24.3.1 fsevents: 2.3.3 - sass: 1.89.2 + sass: 1.98.0 + sass-embedded: 1.98.0 terser: 5.42.0 yaml: 2.8.1 - vitest@3.2.4(@types/node@24.3.1)(jsdom@26.1.0)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.1)(jsdom@26.1.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1): dependencies: "@types/chai": 5.2.2 "@vitest/expect": 3.2.4 - "@vitest/mocker": 3.2.4(vite@6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1)) + "@vitest/mocker": 3.2.4(vite@6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1)) "@vitest/pretty-format": 3.2.4 "@vitest/runner": 3.2.4 "@vitest/snapshot": 3.2.4 @@ -12486,10 +14878,11 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.6(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.3.1)(sass@1.89.2)(terser@5.42.0)(yaml@2.8.1) + vite: 6.3.6(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.3.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.42.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: + "@types/debug": 4.1.12 "@types/node": 24.3.1 jsdom: 26.1.0 transitivePeerDependencies: @@ -12522,6 +14915,8 @@ snapshots: dependencies: get-canvas-context: 1.0.2 + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} webpack-virtual-modules@0.6.2: {} @@ -12537,6 +14932,11 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -12641,3 +15041,5 @@ snapshots: optionalDependencies: "@types/react": 18.3.24 react: 18.3.1 + + zwitch@2.0.4: {} diff --git a/frontend/src/components/app/run-screen/node-editor/StepNode.tsx b/frontend/src/components/app/run-screen/node-editor/StepNode.tsx index 27dd6cc47..8cbe82858 100644 --- a/frontend/src/components/app/run-screen/node-editor/StepNode.tsx +++ b/frontend/src/components/app/run-screen/node-editor/StepNode.tsx @@ -7,12 +7,20 @@ import type React from "react"; import { styled } from "styled-components"; import { + handleCifIcon, + handleConfidenceIcon, + handleCrosslinkingIcon, + handleDebugDataIcon, handleDnaIcon, + handleFullDataIcon, handleMetadataIcon, + handlePaeIcon, handlePeptidesIcon, + handlePlddtIcon, handleProteinIcon, handlePsmIcon, handleSequencesIcon, + handleStructureMetadataIcon, } from "../../../core/shared/icon/icons"; // --- Constants --- @@ -106,12 +114,21 @@ export type StepNodeType = Node; type HandleIcon = React.ComponentType>; const DATA_TYPE_ICON_MAP: Partial> = { + amino_acid_sequences_df: handleSequencesIcon, + cif_df: handleCifIcon, + confidence_df: handleConfidenceIcon, + crosslinking_df: handleCrosslinkingIcon, + debug_data: handleDebugDataIcon, fasta_df: handleSequencesIcon, + full_data_df: handleFullDataIcon, + gene_mapping_df: handleDnaIcon, + metadata_df: handleMetadataIcon, + pae_matrix: handlePaeIcon, peptide_df: handlePeptidesIcon, + plddt_df: handlePlddtIcon, protein_df: handleProteinIcon, - metadata_df: handleMetadataIcon, - gene_mapping_df: handleDnaIcon, psm_df: handlePsmIcon, + structure_metadata_df: handleStructureMetadataIcon, }; const triangleStyle = (direction: HandleDirection) => ({ diff --git a/frontend/src/components/app/run-screen/run-screen.tsx b/frontend/src/components/app/run-screen/run-screen.tsx index 649f947d8..3cf3224b6 100644 --- a/frontend/src/components/app/run-screen/run-screen.tsx +++ b/frontend/src/components/app/run-screen/run-screen.tsx @@ -3,6 +3,7 @@ import { DataTable, FlexColumn, FlexRow, + MolstarViewer, PlotComponent, SecondaryButton, SectionTitle, @@ -11,13 +12,16 @@ import { import { useToggleableState } from "@protzilla/hooks"; import { spacing } from "@protzilla/theme"; import { + ApiResponse, callApiWithParameters, + Download, emptyRunData, footerMessages, Image, StepID, StepOutputInfo, SwitchComponent, + Visualization, } from "@protzilla/utils"; import { Figure } from "plotly.js"; import React, { useCallback, useEffect, useState } from "react"; @@ -25,6 +29,7 @@ import { Col } from "react-grid-system"; import { useLocation, useNavigate } from "react-router-dom"; import { styled } from "styled-components"; +import { CrosslinkerInformation } from "../../core/shared/molstar-viewer/crosslinker-processing"; import { H3 } from "../../core/shared/text"; const StyledNavbar = styled(Navbar)` @@ -74,6 +79,57 @@ const FooterText = styled.div` width: 100%; `; +interface UseStepOutputsParams { + available_outputs: TOutput[]; + endpoint: string; + runName: string; + stepId?: string; + transform: (output: TOutput, response: TResponse) => TResult; + enabled?: boolean; +} + +function useCertainStepOutputs({ + available_outputs, + endpoint, + runName, + stepId, + transform, + enabled = true, +}: UseStepOutputsParams): TResult[] { + const [data, setData] = useState([]); + + useEffect(() => { + if (!enabled || !stepId || available_outputs.length === 0) { + setData([]); + return; + } + + const fetchData = async () => { + try { + const responses = await Promise.all( + available_outputs.map(async (output) => { + const response: TResponse = await callApiWithParameters(endpoint, { + run_name: runName, + step_id: stepId, + output_key: output.label, + }); + + return transform(output, response); + }), + ); + + setData(responses); + } catch (error) { + console.error("Failed to fetch outputs:", error); + } + }; + + void fetchData(); + }, [available_outputs, endpoint, runName, stepId, transform, enabled]); + + return data; +} + export const RunScreen: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); @@ -81,13 +137,81 @@ export const RunScreen: React.FC = () => { const runName = location.state?.runName; const [runData, setRunData] = useState(emptyRunData); + const [selectedOutputTab, setSelectedOutputTab] = useState(""); const [plots, setPlots] = useState(); const [selectedPlot, setSelectedPlot] = useState
({ data: [], layout: {} }); const [availableTables, setAvailableTables] = useState(); + const [hasLoadedVisualizations, setHasLoadedVisualizations] = useState(false); + useEffect(() => { + if (selectedOutputTab === "Visualizations") { + setHasLoadedVisualizations(true); + } + }, [selectedOutputTab]); + + const [availableVisualizations, setAvailableVisualizations] = useState([]); + const transformVisualization = useCallback( + (_output: StepOutputInfo, response: ApiResponse) => ({ + structureEntryId: response.data.structureEntryId, + cifString: response.data.cifString, + crosslinks: response.data.crosslinks, + }), + [], + ); + const visualizations = useCertainStepOutputs< + StepOutputInfo, + ApiResponse, + { structureEntryId: string; cifString: string; crosslinks?: CrosslinkerInformation[] } + >({ + available_outputs: availableVisualizations, + endpoint: "get_step_visualizations/", + runName: runName, + stepId: runData.current_step_id, + transform: transformVisualization, + enabled: hasLoadedVisualizations, + }); + + const [availableDownloads, setAvailableDownloads] = useState([]); + const transformDownload = useCallback( + (output: StepOutputInfo, response: ApiResponse) => ({ + title: output.label, + data: response.data.json_downloads, + }), + [], + ); + const downloads = useCertainStepOutputs< + StepOutputInfo, + ApiResponse, + { title: string; data: Record } + >({ + available_outputs: availableDownloads, + endpoint: "get_downloads_from_step/", + runName: runName, + stepId: runData.current_step_id, + transform: transformDownload, + }); + // Static PNGs sent as base64 - const [images, setImages] = useState([]); const [availableImages, setAvailableImages] = useState([]); + const transformImage = useCallback( + (output: StepOutputInfo, response: ApiResponse) => ({ + title: output.label, + alt: output.label, + data: "data:image/png;base64," + response.data.base64image, + }), + [], + ); + const images = useCertainStepOutputs< + StepOutputInfo, + ApiResponse, + { title: string; alt: string; data: string } + >({ + available_outputs: availableImages, + endpoint: "get_png_from_step/", + runName: runName, + stepId: runData.current_step_id, + transform: transformImage, + }); const [isDownloadModalOpen, openDownloadModal, closeDownloadModal] = useToggleableState(false); @@ -127,8 +251,10 @@ export const RunScreen: React.FC = () => { step_id: stepID, }).then(() => { setAvailableTables(undefined); + setAvailableDownloads([]); setPlots(undefined); setAvailableImages([]); + setAvailableVisualizations([]); void getRunData(); void getStepPlots(); @@ -173,13 +299,19 @@ export const RunScreen: React.FC = () => { if (response) { const tableOutputs = []; const imageOutputs = []; + const downloadOutputs = []; + const visualizationOutputs = []; for (const output of response.outputs) { if (output.output_type === "dataframe" || output.output_type === "list") tableOutputs.push(output); else if (output.output_type === "png_base64") imageOutputs.push(output); + else if (output.output_type === "download") downloadOutputs.push(output); + else if (output.output_type === "visualization") visualizationOutputs.push(output); } setAvailableTables(tableOutputs); setAvailableImages(imageOutputs); + setAvailableDownloads(downloadOutputs); + setAvailableVisualizations(visualizationOutputs); } }, [runName]); @@ -195,6 +327,8 @@ export const RunScreen: React.FC = () => { setAvailableTables(undefined); setAvailableImages([]); setPlots(undefined); + setAvailableDownloads([]); + setAvailableVisualizations([]); void getRunData(); void getStepPlots(); void getCurrentStepOutputLabels(); @@ -212,36 +346,6 @@ export const RunScreen: React.FC = () => { plotPlaceholderMessage = "No plot available for this step."; } - useEffect(() => { - const fetchImages = async () => { - const imagePromises = availableImages.map(async (output_info) => { - const response = await callApiWithParameters("get_png_from_step/", { - run_name: runName, - step_id: runData.current_step_id, - output_key: output_info.label, - }); - return { - title: output_info.label, - alt: output_info.label, - data: "data:image/png;base64,".concat(response.data), - }; - }); - - try { - const resolvedImages = await Promise.all(imagePromises); - setImages(resolvedImages); - } catch (error) { - console.error("Failed to fetch image data:", error); - } - }; - - if (availableImages.length > 0) { - void fetchImages(); - } else { - setImages([]); - } - }, [availableImages, runName, runData.current_step_id]); - const plotComponent = ( {plots && plots.length > 0 ? ( @@ -271,6 +375,20 @@ export const RunScreen: React.FC = () => { ); + const visualizationComponent = ( + + {visualizations.length > 0 ? ( + visualizations.map((viz) => ( + + + + )) + ) : ( + + )} + + ); + const singleTableComponent = (tableLabel: string) => ( @@ -288,10 +406,7 @@ export const RunScreen: React.FC = () => { }))} /> ) : ( - + )} ); @@ -315,6 +430,39 @@ export const RunScreen: React.FC = () => { ); + const downloadJson = (filename: string, content: string) => { + const blob = new Blob([content], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + + URL.revokeObjectURL(url); + }; + + const downloadComponent = ( + + {downloads.length > 0 ? ( + downloads.flatMap((download) => + Object.entries(download.data).map(([filename, content]) => ( + { + downloadJson(filename, JSON.stringify(content, null, 2)); + }} + /> + )), + ) + ) : ( + + )} + + ); + const nodeEditorComponent = ( { plots && plots.length > 0 && { name: "Plots", value: plotComponent }, availableTables && availableTables.length > 0 && { name: "Tables", value: tableComponent }, availableImages.length > 0 && { name: "Images", value: imageComponent }, + availableDownloads.length > 0 && { name: "Downloads", value: downloadComponent }, + availableVisualizations.length > 0 && { name: "Visualisations", value: visualizationComponent }, ].filter(Boolean) as { name: string; value: React.ReactNode }[]; + useEffect(() => { + if (components.length > 0 && !selectedOutputTab) { + setSelectedOutputTab(components[0].name); + } + }, [components, selectedOutputTab]); + + useEffect(() => { + setHasLoadedVisualizations(false); + setSelectedOutputTab(""); + }, [runData.current_step_id]); + return (
{ styleProps={{ height: "calc(100% - 3em)" }} components={components} hasCardTitle={false} + selection={selectedOutputTab} + callback={(component) => { + setSelectedOutputTab(component.name); + + if (component.name === "Visualisations" && !hasLoadedVisualizations) { + setHasLoadedVisualizations(true); + } + }} /> ) : ( diff --git a/frontend/src/components/app/settings/other-settings/cl-colors-settings.tsx b/frontend/src/components/app/settings/other-settings/cl-colors-settings.tsx new file mode 100644 index 000000000..a1d542662 --- /dev/null +++ b/frontend/src/components/app/settings/other-settings/cl-colors-settings.tsx @@ -0,0 +1,294 @@ +import { useNotification } from "@protzilla/app"; +import { DeleteModal, Form, SecondaryButton, SectionTitle, Text } from "@protzilla/core"; +import { useToggleableState } from "@protzilla/hooks"; +import { spacing } from "@protzilla/theme"; +import { callApi, callApiWithParameters } from "@protzilla/utils"; +import { useEffect, useState } from "react"; +import { styled } from "styled-components"; + +import { CrosslinkerType } from "../../../core/shared/molstar-viewer/crosslinker-processing"; +import { CROSSLINK_DEFAULT_COLORS } from "../../../core/shared/molstar-viewer/molstar-viewer.config"; + +const CurrentColorsTitle = styled(SectionTitle)` + padding-top: ${spacing("large")}; + padding-bottom: ${spacing("small")}; + + margin: 0; +`; + +const CurrentColorsList = styled.div` + display: flex; + flex-direction: column; + gap: ${spacing("verySmall")}; +`; + +const CurrentColorsHeader = styled.div` + display: flex; + flex-direction: row; + align-items: flex-end; + justify-content: space-between; +`; + +const ColorEntryContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + padding-left: ${spacing("listIndentation")}; + padding-top: ${spacing("verySmall")}; + padding-bottom: ${spacing("verySmall")}; +`; + +const ColorInfo = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: ${spacing("small")}; +`; + +const ColorPreview = styled.div<{ color: string }>` + width: 24px; + height: 24px; + border-radius: 4px; + border: 1px solid black; + + background-color: ${({ color }) => color}; +`; + +interface ColorEntryProps { + label: string; + color: number; +} + +const toHexColor = (color: number) => `#${color.toString(16).padStart(6, "0")}`; + +const ColorEntry = ({ label, color }: ColorEntryProps) => { + return ( + + + + + + + + ); +}; + +const entries = [ + { label: "Valid intra-crosslinks", key: CrosslinkerType.ValidIntra }, + { label: "Invalid intra-crosslinks", key: CrosslinkerType.InvalidIntra }, + { label: "Valid inter-crosslinks", key: CrosslinkerType.ValidInter }, + { label: "Invalid inter-crosslinks", key: CrosslinkerType.InvalidInter }, +]; + +export const CrosslinkColors = () => { + const notify = useNotification(); + const [colors, setColors] = useState(CROSSLINK_DEFAULT_COLORS); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggleableState(false); + const [formKey, setFormKey] = useState(0); + + useEffect(() => { + const loadColors = async () => { + const result = await callApi("get_cl_colors"); + + if (result && Object.keys(result).length > 0) { + setColors(result); + } + }; + + void loadColors(); + }, []); + + const parseColor = (value: unknown): number => { + const str = String(value).trim(); + + if (/^-?\d+$/.test(str)) return Number(str); + + if (str.startsWith("0x")) { + const parsed = parseInt(str, 16); + if (!Number.isNaN(parsed)) return parsed; + } + + if (str.startsWith("#")) { + const parsed = parseInt(str.slice(1), 16); + if (!Number.isNaN(parsed)) return parsed; + } + + if (/^[0-9a-fA-F]{6}$/.test(str)) { + return parseInt(str, 16); + } + + throw new Error("Invalid colour format"); + }; + + const updateColors = (prev: typeof CROSSLINK_DEFAULT_COLORS, data: Record) => { + const update = (key: CrosslinkerType) => { + const input = data[key]; + + // if the field was left empty, we keep the old color + if (input == null || (typeof input === "string" && input.trim() === "")) { + return prev[key]; + } + + // non-empty fields are validated + try { + return parseColor(input); + } catch { + notify({ + title: "Invalid colour input", + message: + `Invalid value for ${key}. ` + + "Please enter a valid colour-code " + + "(e.g. #FF00AA or 0xFF00AA or 6-digit hex code).", + type: "error", + isClosingAutomatically: true, + }); + + throw new Error("Abort update"); + } + }; + + return { + [CrosslinkerType.ValidIntra]: update(CrosslinkerType.ValidIntra), + [CrosslinkerType.InvalidIntra]: update(CrosslinkerType.InvalidIntra), + [CrosslinkerType.ValidInter]: update(CrosslinkerType.ValidInter), + [CrosslinkerType.InvalidInter]: update(CrosslinkerType.InvalidInter), + }; + }; + + const handleChangeColors = async (data: Record) => { + try { + const updated = updateColors(colors, data); + setColors(updated); + + const response = await callApiWithParameters("update_cl_colors", updated); + if (response?.success) { + notify({ + title: "Crosslink colour update", + message: response.message as string, + type: "success", + isClosingAutomatically: true, + }); + } else { + notify({ + title: "Crosslink colour update failed", + message: response.message ?? "Unknown error", + type: "error", + isClosingAutomatically: true, + }); + } + setFormKey((prev) => prev + 1); + } catch { + return; + } + }; + + const handleResetToDefaults = async () => { + const updated = CROSSLINK_DEFAULT_COLORS; + setColors(updated); + + const response = await callApiWithParameters("update_cl_colors", updated); + if (response?.success) { + notify({ + title: "Crosslink colour reset", + message: response.message as string, + type: "success", + isClosingAutomatically: true, + }); + } else { + notify({ + title: "Crosslink colour reset failed", + message: response.message ?? "Unknown error", + type: "error", + isClosingAutomatically: true, + }); + } + closeDeleteModal(); + }; + + const handleDelete = () => { + openDeleteModal(); + }; + + return ( +
+ + + +
{ + handleChangeColors(data).catch(console.error); + }} + /> + + + + + + + + + {entries.map((entry) => ( + + ))} + + + { + void handleResetToDefaults(); + }} + title={ + `All crosslink colours will be reset to the developer defaults.` + + `Your currently selected colours will be permanently deleted. Would you like to proceed?` + } + /> +
+ ); +}; diff --git a/frontend/src/components/app/settings/other-settings/cl-default-settings.tsx b/frontend/src/components/app/settings/other-settings/cl-default-settings.tsx new file mode 100644 index 000000000..d01a854d7 --- /dev/null +++ b/frontend/src/components/app/settings/other-settings/cl-default-settings.tsx @@ -0,0 +1,248 @@ +import { useNotification } from "@protzilla/app"; +import { DeleteModal, Form, SecondaryButton, SectionTitle, Text } from "@protzilla/core"; +import { useToggleableState } from "@protzilla/hooks"; +import { spacing } from "@protzilla/theme"; +import { callApi, callApiWithParameters } from "@protzilla/utils"; +import { useEffect, useState } from "react"; +import { styled } from "styled-components"; + +const CrosslinkDefaultTitle = styled(SectionTitle)` + padding-top: ${spacing("large")}; + padding-bottom: ${spacing("small")}; +`; + +const CrosslinkDefaultList = styled.div` + display: flex; + flex-direction: column; + gap: ${spacing("verySmall")}; +`; + +interface CrosslinkDefaultProps { + cl_name: string; + cl_length: number; + cl_upper_deviation: number; + cl_lower_deviation: number; + handleDelete?: () => void; +} + +type ApiCrosslinkDefaults = Record< + string, + { + cl_length: number; + cl_upper_deviation: number; + cl_lower_deviation: number; + } +>; + +const CrosslinkDefaultContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding-left: ${spacing("listIndentation")}; + padding-top: ${spacing("verySmall")}; + padding-bottom: ${spacing("verySmall")}; +`; + +const CrosslinkDefaultInfo = styled.div` + display: flex; + justify-content: space-between; + align-content: center; + flex-direction: column; + width: 90%; +`; + +const CrosslinkDefaultEntry = ({ + cl_name, + cl_length, + cl_upper_deviation, + cl_lower_deviation, + handleDelete, +}: CrosslinkDefaultProps) => { + return ( + + + + + + + + ); +}; + +export const CrosslinkDefaultUpload = () => { + const notify = useNotification(); + const [crosslinkDefaultList, setCrosslinkDefaultList] = useState([]); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggleableState(false); + const [selectedCrosslinkDefault, setSelectedCrosslinkDefault] = useState(""); + + const fetchCrosslinkDefaults = async () => { + const crosslinkDefaults = (await callApi("get_cl_defaults")) as ApiCrosslinkDefaults | null; + + if (crosslinkDefaults) { + const transformedList: CrosslinkDefaultProps[] = Object.entries(crosslinkDefaults).map( + ([name, properties]) => ({ + cl_name: name, + cl_length: properties.cl_length, + cl_upper_deviation: properties.cl_upper_deviation, + cl_lower_deviation: properties.cl_lower_deviation, + }), + ); + setCrosslinkDefaultList(transformedList); + } + }; + + useEffect(() => { + void fetchCrosslinkDefaults(); + }, []); + + const handleAddCrosslinkDefault = async ( + cl_name: string, + cl_length: number, + cl_upper_deviation: number, + cl_lower_deviation: number, + ) => { + const response = await callApiWithParameters("update_cl_default", { + cl_name: cl_name, + cl_length: cl_length, + cl_upper_deviation: cl_upper_deviation, + cl_lower_deviation: cl_lower_deviation, + }); + if (response?.success) { + notify({ + title: "Crosslink default update", + message: response.message as string, + type: "success", + isClosingAutomatically: true, + }); + } else { + notify({ + title: "Crosslink default update failed", + message: response.message ?? "Unknown error", + type: "error", + isClosingAutomatically: true, + }); + } + void fetchCrosslinkDefaults(); + }; + + const onDeleteCrosslinkDefault = (cl_name: string) => { + openDeleteModal(); + setSelectedCrosslinkDefault(cl_name); + }; + + const handleDeleteCrosslinkDefault = async (cl_name: string) => { + const response = await callApiWithParameters("delete_cl_default", { + cl_name: cl_name, + }); + if (response?.success) { + notify({ + title: "Crosslink default deleted", + message: response.message as string, + type: "success", + isClosingAutomatically: true, + }); + } else { + notify({ + title: "Crosslink default deletion failed", + message: response?.message ?? "Unknown error", + type: "error", + isClosingAutomatically: true, + }); + } + void fetchCrosslinkDefaults(); + closeDeleteModal(); + }; + + return ( +
+ + + + { + void handleAddCrosslinkDefault( + data.cl_name as string, + data.cl_length as number, + data.cl_upper_deviation as number, + data.cl_lower_deviation as number, + ); + }} + /> + + {crosslinkDefaultList.length === 0 ? ( + + ) : ( + + {crosslinkDefaultList.map((ps) => ( + { + onDeleteCrosslinkDefault(ps.cl_name); + }} + /> + ))} + + )} + void handleDeleteCrosslinkDefault(selectedCrosslinkDefault)} + title={ + `Defaults for ` + + `"${selectedCrosslinkDefault}" will permanently be deleted. Would you like to proceed?` + } + /> +
+ ); +}; diff --git a/frontend/src/components/app/settings/other-settings/index.ts b/frontend/src/components/app/settings/other-settings/index.ts index 969a2f042..1cb9d4e23 100644 --- a/frontend/src/components/app/settings/other-settings/index.ts +++ b/frontend/src/components/app/settings/other-settings/index.ts @@ -1,3 +1,8 @@ export * from "./citation"; export * from "./database-settings"; export * from "./github"; +export * from "./ptm-vis-settings"; +export * from "./monomer-structure-upload"; +export * from "./multimer-structure-upload"; +export * from "./cl-default-settings"; +export * from "./cl-colors-settings"; diff --git a/frontend/src/components/app/settings/other-settings/monomer-structure-upload.tsx b/frontend/src/components/app/settings/other-settings/monomer-structure-upload.tsx new file mode 100644 index 000000000..48a9326b1 --- /dev/null +++ b/frontend/src/components/app/settings/other-settings/monomer-structure-upload.tsx @@ -0,0 +1,295 @@ +import { useNotification } from "@protzilla/app"; +import { DeleteModal, Form, SecondaryButton, SectionTitle, Text } from "@protzilla/core"; +import { useToggleableState } from "@protzilla/hooks"; +import { spacing } from "@protzilla/theme"; +import { callApi, callApiWithParameters } from "@protzilla/utils"; +import { useEffect, useState } from "react"; +import { styled } from "styled-components"; + +const MonomerStructureTitle = styled(SectionTitle)` + padding-top: ${spacing("large")}; + padding-bottom: ${spacing("small")}; +`; + +const MonomerStructureList = styled.div` + display: flex; + flex-direction: column; + gap: ${spacing("verySmall")}; +`; + +interface MonomerStructureProps { + entry_id: string; + uniprot_id: string; + date_modified: string; + gene: string; + model_used: string; + handleDelete?: () => void; +} + +const MonomerStructureContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding-left: ${spacing("listIndentation")}; + padding-top: ${spacing("verySmall")}; + padding-bottom: ${spacing("verySmall")}; +`; + +const MonomerStructureInfo = styled.div` + display: flex; + justify-content: space-between; + align-content: center; + flex-direction: column; + width: 90%; +`; + +const MonomerStructureEntry = ({ + entry_id, + uniprot_id, + date_modified, + gene, + model_used, + handleDelete, +}: MonomerStructureProps) => { + return ( + + + + + + + + ); +}; + +export const MonomerStructureUpload = () => { + const notify = useNotification(); + const [monomerStructureList, setMonomerStructureList] = useState([]); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggleableState(false); + const [selectedMonomerStructure, setSelectedMonomerStructure] = useState(""); + + const fetchMonomerStructures = async () => { + const monomerStructures = await callApi("get_monomer_structure"); + if (monomerStructures) { + setMonomerStructureList(monomerStructures); + } + }; + + useEffect(() => { + void fetchMonomerStructures(); + }, []); + + const handleAddMonomerStructure = async ( + uniprot_id: string, + entry_id: string, + model_used: string, + gene: string, + cif_file: string, + confidence: string, + pae: string, + fasta_file: string, + ) => { + const response = await callApiWithParameters("upload_monomer_structure", { + uniprot_id: uniprot_id, + entry_id: entry_id, + model_used: model_used, + gene: gene, + cif_file: cif_file, + confidence: confidence, + pae: pae, + fasta_file: fasta_file, + }); + if (response?.success) { + notify({ + title: "Predicted monomer structure upload", + message: response.message as string, + type: "success", + isClosingAutomatically: true, + }); + } else { + notify({ + title: "Predicted monomer structure upload failed", + message: response.message ?? "Unknown error", + type: "error", + isClosingAutomatically: true, + }); + } + void fetchMonomerStructures(); + }; + + const onDeleteMonomerStructure = (entry_id: string) => { + openDeleteModal(); + setSelectedMonomerStructure(entry_id); + }; + + const handleDeleteMonomerStructure = async (entry_id: string) => { + const response = await callApiWithParameters("delete_monomer_structure", { + entry_id: entry_id, + }); + if (response?.success) { + notify({ + title: "Monomer structure deleted", + message: response.message as string, + type: "success", + isClosingAutomatically: true, + }); + } else { + notify({ + title: "Monomer structure deletion failed", + message: response?.message ?? "Unknown error", + type: "error", + isClosingAutomatically: true, + }); + } + void fetchMonomerStructures(); + closeDeleteModal(); + }; + + return ( +
+ + + + { + void handleAddMonomerStructure( + data.uniprot_id as string, + data.entry_id as string, + data.model_used as string, + data.gene as string, + data.cif_file as string, + data.confidence_file as string, + data.pae_file as string, + data.fasta_file as string, + ); + }} + /> + + {monomerStructureList.length === 0 ? ( + + ) : ( + + {monomerStructureList.map((ps) => ( + { + onDeleteMonomerStructure(ps.entry_id); + }} + /> + ))} + + )} + void handleDeleteMonomerStructure(selectedMonomerStructure)} + title={ + `The uploaded monomer structure prediction with the entry ID ` + + `"${selectedMonomerStructure}" will permanently be deleted. Would you like to proceed?` + } + /> +
+ ); +}; diff --git a/frontend/src/components/app/settings/other-settings/multimer-structure-upload.tsx b/frontend/src/components/app/settings/other-settings/multimer-structure-upload.tsx new file mode 100644 index 000000000..c86b4272f --- /dev/null +++ b/frontend/src/components/app/settings/other-settings/multimer-structure-upload.tsx @@ -0,0 +1,297 @@ +import { useNotification } from "@protzilla/app"; +import { DeleteModal, Form, SecondaryButton, SectionTitle, Text } from "@protzilla/core"; +import { useToggleableState } from "@protzilla/hooks"; +import { spacing } from "@protzilla/theme"; +import { callApi, callApiWithParameters } from "@protzilla/utils"; +import { useEffect, useState } from "react"; +import { styled } from "styled-components"; + +const MultimerStructureTitle = styled(SectionTitle)` + padding-top: ${spacing("large")}; + padding-bottom: ${spacing("small")}; +`; + +const MultimerStructureList = styled.div` + display: flex; + flex-direction: column; + gap: ${spacing("verySmall")}; +`; + +interface MultimerStructureProps { + entry_id: string; + uniprot_ids: string; + date_modified: string; + model_used: string; + handleDelete?: () => void; +} + +const MultimerStructureContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding-left: ${spacing("listIndentation")}; + padding-top: ${spacing("verySmall")}; + padding-bottom: ${spacing("verySmall")}; +`; + +const MultimerStructureInfo = styled.div` + display: flex; + justify-content: space-between; + align-content: center; + flex-direction: column; + width: 90%; +`; + +const MultimerStructureEntry = ({ + entry_id, + uniprot_ids, + date_modified, + model_used, + handleDelete, +}: MultimerStructureProps) => { + return ( + + + + + + + + ); +}; + +export const MultimerStructureUpload = () => { + const notify = useNotification(); + const [multimerStructureList, setMultimerStructureList] = useState([]); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggleableState(false); + const [selectedMultimerStructure, setSelectedMultimerStructure] = useState(""); + + const fetchMultimerStructures = async () => { + const multimerStructures = await callApi("get_multimer_structure"); + if (multimerStructures) { + setMultimerStructureList(multimerStructures); + } + }; + + useEffect(() => { + void fetchMultimerStructures(); + }, []); + + const handleAddMultimerStructure = async ( + entry_id: string, + uniprot_ids: string, + model_used: string, + fasta_file: string, + cif_file: string, + confidence_file: string, + full_data_file: string, + job_request_file: string, + ) => { + const response = await callApiWithParameters("upload_multimer_structure", { + entry_id: entry_id, + uniprot_ids: uniprot_ids, + model_used: model_used, + fasta_file: fasta_file, + cif_file: cif_file, + confidence_file: confidence_file, + full_data_file: full_data_file, + job_request_file: job_request_file, + }); + if (response?.success) { + notify({ + title: "Predicted multimer structure upload", + message: response.message as string, + type: "success", + isClosingAutomatically: true, + }); + } else { + notify({ + title: "Predicted multimer structure upload failed", + message: response.message ?? "Unknown error", + type: "error", + isClosingAutomatically: true, + }); + } + void fetchMultimerStructures(); + }; + + const onDeleteMultimerStructure = (entry_id: string) => { + openDeleteModal(); + setSelectedMultimerStructure(entry_id); + }; + + const handleDeleteMultimerStructure = async (entry_id: string) => { + const response = await callApiWithParameters("delete_multimer_structure", { + entry_id: entry_id, + }); + if (response?.success) { + notify({ + title: "Multimer structure deleted", + message: response.message as string, + type: "success", + isClosingAutomatically: true, + }); + } else { + notify({ + title: "Multimer structure deletion failed", + message: response?.message ?? "Unknown error", + type: "error", + isClosingAutomatically: true, + }); + } + void fetchMultimerStructures(); + closeDeleteModal(); + }; + return ( +
+ + + + { + void handleAddMultimerStructure( + data.entry_id as string, + data.uniprot_ids as string, + data.model_used as string, + data.fasta_file as string, + data.cif_file as string, + data.confidence_file as string, + data.full_data_file as string, + data.job_request_file as string, + ); + }} + /> + + {multimerStructureList.length === 0 ? ( + + ) : ( + + {multimerStructureList.map((ps) => ( + { + onDeleteMultimerStructure(ps.entry_id); + }} + /> + ))} + + )} + void handleDeleteMultimerStructure(selectedMultimerStructure)} + title={ + `The uploaded multimer structure prediction with the entry ID ` + + `"${selectedMultimerStructure}" will permanently be deleted. Would you like to proceed?` + } + /> +
+ ); +}; diff --git a/frontend/src/components/app/settings/settings.tsx b/frontend/src/components/app/settings/settings.tsx index 468db8927..972dd096b 100644 --- a/frontend/src/components/app/settings/settings.tsx +++ b/frontend/src/components/app/settings/settings.tsx @@ -3,8 +3,15 @@ import { spacing } from "@protzilla/theme"; import { useState } from "react"; import { styled } from "styled-components"; -import { DatabaseSettings, GitHub } from "./other-settings/"; -import { PTMVisSettings } from "./other-settings/ptm-vis-settings.tsx"; +import { + CrosslinkColors, + CrosslinkDefaultUpload, + DatabaseSettings, + GitHub, + MonomerStructureUpload, + MultimerStructureUpload, + PTMVisSettings, +} from "./other-settings/"; import { PlotSettingsModal } from "./plot-settings"; import { SettingsProps } from "./settings.props.ts"; import { DiscardModal, Modal, ToggleableButton } from "../../core/"; @@ -111,6 +118,42 @@ export const Settings: React.FC = ({ handleSwitchSection("ptm-vis"); }} /> + { + handleSwitchSection("monomer-structure-upload"); + }} + /> + { + handleSwitchSection("multimer-structure-upload"); + }} + /> + { + handleSwitchSection("crosslink-defaults"); + }} + /> + { + handleSwitchSection("crosslink-colors"); + }} + /> = ({ )} {selectedSetting === "database" && } {selectedSetting === "ptm-vis" && } + {selectedSetting === "monomer-structure-upload" && } + {selectedSetting === "multimer-structure-upload" && } + {selectedSetting === "crosslink-defaults" && } + {selectedSetting === "crosslink-colors" && } {selectedSetting === "github" && } = ({ items: [], }); const [columns, setColumns] = useState([]); + const columnsInitializedRef = useRef(false); + const isFallbackRef = useRef(false); - // necessary for updating which columns exist when switching between tables + // Reset state when switching between tables useEffect(() => { setColumns([]); + setCurrentRows([]); setFilterModel({ items: [] }); setSortModel([]); columnsInitializedRef.current = false; + isFallbackRef.current = false; }, [tableLabel]); - // Fetch data when pagination changes + // Fetch data when pagination, sorts, or filters change useEffect(() => { const fetchData = async () => { + if (isFallbackRef.current) return; + setLoading(true); const startIndex = paginationModel.page * paginationModel.pageSize; @@ -107,7 +113,35 @@ export const DataTable: React.FC = ({ filters: JSON.stringify(filterModel.items), }); - if (response.rows.length > 0 && !columnsInitializedRef.current) { + // 1. Column Generation & Fallback Logic (Only runs once per table) + if (!columnsInitializedRef.current && response.rows.length > 0) { + const numCols = Object.keys(response.rows[0]).length; + + // Handle Too Many Columns (Fallback) + if (numCols > MAX_COLUMNS) { + const generatedColumns = Object.keys(FALLBACK_TOO_MANY_COLUMNS[0]).map((key) => { + return { + field: key, + headerName: key, + flex: 1, + type: "string", + align: "left", + headerAlign: "left", + sortable: false, // Prevent users from sorting fallback rows + filterable: false, // Prevent users from filtering fallback rows + } as GridColDef; + }); + + setColumns(generatedColumns); + setCurrentRows(FALLBACK_TOO_MANY_COLUMNS); + setTotalRowCount(FALLBACK_TOO_MANY_COLUMNS.length); + + columnsInitializedRef.current = true; + isFallbackRef.current = true; + return; + } + + // Handle Normal Columns const generatedColumns = Object.keys(response.rows[0]).map((key) => { const isNumeric = response.rows.every( (row: TableRecord) => typeof row[key] === "number" || row[key] === null, @@ -128,15 +162,15 @@ export const DataTable: React.FC = ({ setColumns(generatedColumns); columnsInitializedRef.current = true; + isFallbackRef.current = false; } - if (response.rows.length > 0 && Object.keys(response.rows[0]).length > MAX_COLUMNS) { - setCurrentRows(FALLBACK_TOO_MANY_COLUMNS); - setTotalRowCount(FALLBACK_TOO_MANY_COLUMNS.length); - } else { + if(!isFallbackRef.current) { setCurrentRows(response.rows); setTotalRowCount(response.total_row_count); } + + } catch (error) { console.error("Failed to fetch table data:", error); } finally { diff --git a/frontend/src/components/core/index.ts b/frontend/src/components/core/index.ts index b2800ef1a..6747fa8a3 100644 --- a/frontend/src/components/core/index.ts +++ b/frontend/src/components/core/index.ts @@ -24,6 +24,7 @@ export * from "./shared/input-fields/info-field"; export * from "./shared/input-fields/header-info-field"; export * from "./shared/modal"; export * from "./shared/plot"; +export * from "./shared/molstar-viewer"; export * from "./shared/section-title"; export * from "./shared/switch"; export * from "./shared/text"; diff --git a/frontend/src/components/core/shared/icon/icons/color_brush.svg b/frontend/src/components/core/shared/icon/icons/color_brush.svg new file mode 100644 index 000000000..e1cdc4386 --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/color_brush.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/frontend/src/components/core/shared/icon/icons/handle_cif.svg b/frontend/src/components/core/shared/icon/icons/handle_cif.svg new file mode 100644 index 000000000..25b1e2525 --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/handle_cif.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/icon/icons/handle_confidence.svg b/frontend/src/components/core/shared/icon/icons/handle_confidence.svg new file mode 100644 index 000000000..ba278382c --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/handle_confidence.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/components/core/shared/icon/icons/handle_crosslinking.svg b/frontend/src/components/core/shared/icon/icons/handle_crosslinking.svg new file mode 100644 index 000000000..dd3080bf7 --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/handle_crosslinking.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/icon/icons/handle_debug_data.svg b/frontend/src/components/core/shared/icon/icons/handle_debug_data.svg new file mode 100644 index 000000000..95303fb47 --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/handle_debug_data.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/icon/icons/handle_full_data.svg b/frontend/src/components/core/shared/icon/icons/handle_full_data.svg new file mode 100644 index 000000000..5685643e8 --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/handle_full_data.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/icon/icons/handle_pae.svg b/frontend/src/components/core/shared/icon/icons/handle_pae.svg new file mode 100644 index 000000000..b996dab84 --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/handle_pae.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/icon/icons/handle_plddt.svg b/frontend/src/components/core/shared/icon/icons/handle_plddt.svg new file mode 100644 index 000000000..c17e33485 --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/handle_plddt.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/icon/icons/handle_structure_metadata.svg b/frontend/src/components/core/shared/icon/icons/handle_structure_metadata.svg new file mode 100644 index 000000000..23b148920 --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/handle_structure_metadata.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/icon/icons/index.ts b/frontend/src/components/core/shared/icon/icons/index.ts index f18ff51a5..ba884b8f0 100644 --- a/frontend/src/components/core/shared/icon/icons/index.ts +++ b/frontend/src/components/core/shared/icon/icons/index.ts @@ -13,6 +13,7 @@ export { default as chevronUp } from "./chevron-up.svg?react"; export { default as clipboard } from "./clipboard.svg?react"; export { default as close } from "./close.svg?react"; export { default as complete } from "./complete.svg?react"; +export { default as colorBrush } from "./color_brush.svg?react"; export { default as data_analysis } from "./data_analysis.svg?react"; export { default as data_integration } from "./data_integration.svg?react"; export { default as data_preprocessing } from "./data_preprocessing.svg?react"; @@ -22,12 +23,20 @@ export { default as edit } from "./edit-icon.svg?react"; export { default as eye } from "./eye.svg?react"; export { default as failed } from "./failed.svg?react"; export { default as github } from "./github.svg?react"; +export { default as handleCifIcon } from "./handle_cif.svg?react"; +export { default as handleConfidenceIcon } from "./handle_confidence.svg?react"; +export { default as handleCrosslinkingIcon } from "./handle_crosslinking.svg?react"; +export { default as handleDebugDataIcon } from "./handle_debug_data.svg?react"; export { default as handleDnaIcon } from "./handle_dna.svg?react"; +export { default as handleFullDataIcon } from "./handle_full_data.svg?react"; export { default as handleMetadataIcon } from "./handle_metadata.svg?react"; +export { default as handlePaeIcon } from "./handle_pae.svg?react"; +export { default as handlePlddtIcon } from "./handle_plddt.svg?react"; export { default as handleSequencesIcon } from "./handle_sequences.svg?react"; export { default as handlePeptidesIcon } from "./handle_peptides.svg?react"; export { default as handleProteinIcon } from "./handle_protein.svg?react"; export { default as handlePsmIcon } from "./handle_psm.svg?react"; +export { default as handleStructureMetadataIcon } from "./handle_structure_metadata.svg?react"; export { default as help } from "./help.svg?react"; export { default as home } from "./home.svg?react"; export { default as importing } from "./importing.svg?react"; @@ -38,6 +47,7 @@ export { default as memory } from "./memory.svg?react"; export { default as outdated } from "./outdated.svg?react"; export { default as play } from "./play-btn.svg?react"; export { default as protzilla } from "./protzillablackwhite.svg?react"; +export { default as prot_structure } from "./prot_structure.svg?react"; export { default as save } from "./save.svg?react"; export { default as reload } from "./reload.svg?react"; export { default as searchLens } from "./search-lens.svg?react"; diff --git a/frontend/src/components/core/shared/icon/icons/notusedicons/cif_02.svg b/frontend/src/components/core/shared/icon/icons/notusedicons/cif_02.svg new file mode 100644 index 000000000..1f9fb8cfe --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/notusedicons/cif_02.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/icon/icons/notusedicons/pae_02.svg b/frontend/src/components/core/shared/icon/icons/notusedicons/pae_02.svg new file mode 100644 index 000000000..e123a22de --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/notusedicons/pae_02.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/icon/icons/notusedicons/structure_metadata_02.svg b/frontend/src/components/core/shared/icon/icons/notusedicons/structure_metadata_02.svg new file mode 100644 index 000000000..9e07b5291 --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/notusedicons/structure_metadata_02.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/icon/icons/prot_structure.svg b/frontend/src/components/core/shared/icon/icons/prot_structure.svg new file mode 100644 index 000000000..f1e6aad97 --- /dev/null +++ b/frontend/src/components/core/shared/icon/icons/prot_structure.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/core/shared/index.ts b/frontend/src/components/core/shared/index.ts index bd6cfa757..2fd442629 100644 --- a/frontend/src/components/core/shared/index.ts +++ b/frontend/src/components/core/shared/index.ts @@ -21,6 +21,7 @@ export * from "./input-fields/info-field"; export * from "./input-fields/header-info-field"; export * from "./modal"; export * from "./plot"; +export * from "./molstar-viewer"; export * from "./section-title"; export * from "./switch"; export * from "./text"; diff --git a/frontend/src/components/core/shared/molstar-viewer/crosslinker-processing.tsx b/frontend/src/components/core/shared/molstar-viewer/crosslinker-processing.tsx new file mode 100644 index 000000000..0a43dc49a --- /dev/null +++ b/frontend/src/components/core/shared/molstar-viewer/crosslinker-processing.tsx @@ -0,0 +1,244 @@ +export interface CrosslinkerInformation { + crosslinkerPosition1: number; + crosslinkerPosition2: number; + chainId1: string; + chainId2: string; + isValid: boolean; + isIntraCrosslink: boolean; + reactiveAtom1?: string; + reactiveAtom2?: string; +} + +interface CrosslinkerAtom { + x: number; + y: number; + z: number; + seqPos: number; + atomId: string; +} + +interface AtomSiteIndices { + atomIdIdx: number; + seqIdIdx: number; + chainIdIdx: number; + xCoordIdx: number; + yCoordIdx: number; + zCoordIdx: number; +} + +export enum CrosslinkerType { + ValidIntra = "valid-intra-crosslink", + InvalidIntra = "invalid-intra-crosslink", + ValidInter = "valid-inter-crosslink", + InvalidInter = "invalid-inter-crosslink", +} + +export function generateCrosslinkCIF( + cifString: string, + crosslinks: CrosslinkerInformation[], +): { crosslinkerCifText: string; crosslinkerGroups: Record } { + const atomLines: string[] = []; + const connectionLines: string[] = []; + let connectionId = 1; + + const crosslinkGroups: Record = Object.values(CrosslinkerType).reduce( + (crosslinkGroups, type) => { + crosslinkGroups[type] = []; + return crosslinkGroups; + }, + {} as Record, + ); + + for (const crosslink of crosslinks) { + const [atom1, atom2] = getCrosslinkerAtoms(cifString, crosslink); + + const atom1Id = `XL${String(connectionId)}A`; + const atom2Id = `XL${String(connectionId)}B`; + + const crosslinkType = getCrosslinkerType(crosslink); + crosslinkGroups[crosslinkType].push(atom1Id, atom2Id); + + // chainId = CL, to enable inter-crosslinks, because connections can only exist within the same chain + // compId (indicating the residue), is unimportant for this representation and can therefore be a placeholder + + const atom1Line = [ + `ATOM ${String(connectionId * 2 - 1)} ${atom1Id} ${atom1Id}`, + `LIN CL ${String(atom1.seqPos)}`, + `${String(atom1.x)} ${String(atom1.y)} ${String(atom1.z)} 1.0 0.0`, + ].join(" "); + + const atom2Line = [ + `ATOM ${String(connectionId * 2)} ${atom2Id} ${atom2Id}`, + `LIN CL ${String(atom2.seqPos)}`, + `${String(atom2.x)} ${String(atom2.y)} ${String(atom2.z)} 1.0 0.0`, + ].join(" "); + + atomLines.push(atom1Line); + atomLines.push(atom2Line); + + const connectionLine = [ + `${String(connectionId)} misc ${atom1Id}`, + `X CL ${String(atom1.seqPos)} ${atom2Id}`, + `X CL ${String(atom2.seqPos)}`, + ].join(" "); + + connectionLines.push(connectionLine); + + connectionId++; + } + + const crosslinkCifText = ` + data_crosslink + + loop_ + _atom_site.group_PDB + _atom_site.id + _atom_site.type_symbol + _atom_site.label_atom_id + _atom_site.label_comp_id + _atom_site.label_asym_id + _atom_site.label_seq_id + _atom_site.Cartn_x + _atom_site.Cartn_y + _atom_site.Cartn_z + _atom_site.occupancy + _atom_site.B_iso_or_equiv + ${atomLines.join("\n")} + + loop_ + _struct_conn.id + _struct_conn.conn_type_id + _struct_conn.ptnr1_label_atom_id + _struct_conn.ptnr1_label_comp_id + _struct_conn.ptnr1_label_asym_id + _struct_conn.ptnr1_label_seq_id + _struct_conn.ptnr2_label_atom_id + _struct_conn.ptnr2_label_comp_id + _struct_conn.ptnr2_label_asym_id + _struct_conn.ptnr2_label_seq_id + ${connectionLines.join("\n")} + `; + + return { crosslinkerCifText: crosslinkCifText, crosslinkerGroups: crosslinkGroups }; +} + +// ------------------------- internal helpers: ------------------------- + +function getReactiveAtom(reactiveAtom?: string): string { + // right now we always return the central C atom + // later we might want to return the reactive atom of the amino acid residue of the specific amino acid type + // then we just have to define a reactiveAtom + if (!reactiveAtom) return "CA"; + const mapping: Record = { + K: "NZ", + S: "OG", + T: "OG1", + }; + return mapping[reactiveAtom] || "CA"; +} + +function findAtomCoordinatesInCif( + cifString: string, + cifIndices: ReturnType, + crosslinkerAtomId: string, + crosslinkerSeqPos: number, + crosslinkerChainId: string, +): CrosslinkerAtom { + const lines = cifString.split(/\r?\n/); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const tokens = trimmed.split(/\s+/); + if (tokens[0] !== "ATOM" && tokens[0] !== "HETATM") continue; + + const lineAtomId = tokens[cifIndices.atomIdIdx]; + const lineSeqPos = parseInt(tokens[cifIndices.seqIdIdx], 10); + const lineChainId = tokens[cifIndices.chainIdIdx]; + + if ( + lineAtomId === crosslinkerAtomId && + lineSeqPos === crosslinkerSeqPos && + lineChainId === crosslinkerChainId + ) { + return { + x: parseFloat(tokens[cifIndices.xCoordIdx]), + y: parseFloat(tokens[cifIndices.yCoordIdx]), + z: parseFloat(tokens[cifIndices.zCoordIdx]), + seqPos: crosslinkerSeqPos, + atomId: crosslinkerAtomId, + }; + } + } + + throw new Error(`No atom found for seq=${String(crosslinkerSeqPos)}, atom=${crosslinkerAtomId}`); +} + +function getCrosslinkerAtoms( + cifString: string, + crosslink: CrosslinkerInformation, +): [CrosslinkerAtom, CrosslinkerAtom] { + const cifIndices = getCifAtomSiteIndices(cifString); + + const reactiveAtom1 = getReactiveAtom(crosslink.reactiveAtom1); + const atom1 = findAtomCoordinatesInCif( + cifString, + cifIndices, + reactiveAtom1, + crosslink.crosslinkerPosition1, + crosslink.chainId1, + ); + + const reactiveAtom2 = getReactiveAtom(crosslink.reactiveAtom2); + const atom2 = findAtomCoordinatesInCif( + cifString, + cifIndices, + reactiveAtom2, + crosslink.crosslinkerPosition2, + crosslink.chainId2, + ); + + return [atom1, atom2]; +} + +function getCifAtomSiteIndices(cifString: string): AtomSiteIndices { + const lines = cifString.split(/\r?\n/); + const atomSiteLines = lines.filter((line) => line.startsWith("_atom_site.")); + + const indices: Record = {}; + atomSiteLines.forEach((line, idx) => { + const colName = line.trim(); + indices[colName] = idx; + }); + + const atomIdColNames = ["_atom_site.label_atom_id", "_atom_site.auth_atom_id"]; + const seqIdColNames = ["_atom_site.label_seq_id", "_atom_site.auth_seq_id"]; + const chainIdColNames = ["_atom_site.label_asym_id", "_atom_site.auth_asym_id"]; + const xCoordColNames = ["_atom_site.Cartn_x"]; + const yCoordColNames = ["_atom_site.Cartn_y"]; + const zCoordColNames = ["_atom_site.Cartn_z"]; + + function findFirst(names: string[]): number { + for (const name of names) { + if (name in indices) return indices[name]; + } + throw new Error(`None of the column names found: ${names.join(", ")}`); + } + + return { + atomIdIdx: findFirst(atomIdColNames), + seqIdIdx: findFirst(seqIdColNames), + chainIdIdx: findFirst(chainIdColNames), + xCoordIdx: findFirst(xCoordColNames), + yCoordIdx: findFirst(yCoordColNames), + zCoordIdx: findFirst(zCoordColNames), + }; +} + +function getCrosslinkerType(crosslink: CrosslinkerInformation): CrosslinkerType { + if (crosslink.isIntraCrosslink) { + return crosslink.isValid ? CrosslinkerType.ValidIntra : CrosslinkerType.InvalidIntra; + } else { + return crosslink.isValid ? CrosslinkerType.ValidInter : CrosslinkerType.InvalidInter; + } +} diff --git a/frontend/src/components/core/shared/molstar-viewer/index.ts b/frontend/src/components/core/shared/molstar-viewer/index.ts new file mode 100644 index 000000000..45fc7b6db --- /dev/null +++ b/frontend/src/components/core/shared/molstar-viewer/index.ts @@ -0,0 +1,2 @@ +export { default as MolstarViewer } from "./molstar-viewer"; +export * from "./molstar-viewer.props"; diff --git a/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.config.ts b/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.config.ts new file mode 100644 index 000000000..1eec67097 --- /dev/null +++ b/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.config.ts @@ -0,0 +1,10 @@ +import { CrosslinkerType } from "./crosslinker-processing"; + +export type CrosslinkColors = Record; + +export const CROSSLINK_DEFAULT_COLORS: CrosslinkColors = { + [CrosslinkerType.ValidIntra]: 0xe03e00, // bright orange-red + [CrosslinkerType.InvalidIntra]: 0xfca311, // pale yellow-orange + [CrosslinkerType.ValidInter]: 0x8a2be2, // bright purple + [CrosslinkerType.InvalidInter]: 0xd8b4ff, // pale violet +}; diff --git a/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.props.tsx b/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.props.tsx new file mode 100644 index 000000000..abf936f4d --- /dev/null +++ b/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.props.tsx @@ -0,0 +1,6 @@ +import { CrosslinkerInformation } from "./crosslinker-processing"; + +export interface MolstarViewerProps { + cifText: string; + crosslinks?: CrosslinkerInformation[]; +} diff --git a/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.service.ts b/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.service.ts new file mode 100644 index 000000000..b5466544a --- /dev/null +++ b/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.service.ts @@ -0,0 +1,176 @@ +import { useNotification } from "@protzilla/app"; +import { callApi } from "@protzilla/utils"; +import { OrderedSet } from "molstar/lib/mol-data/int"; +import { Loci } from "molstar/lib/mol-model/loci"; +import { StructureElement } from "molstar/lib/mol-model/structure"; +import { PluginUIContext } from "molstar/lib/mol-plugin-ui/context"; +import { MolScriptBuilder as MS } from "molstar/lib/mol-script/language/builder"; + +import { + CrosslinkerInformation, + CrosslinkerType, + generateCrosslinkCIF, +} from "./crosslinker-processing"; +import { CROSSLINK_DEFAULT_COLORS, CrosslinkColors } from "./molstar-viewer.config"; + +type PluginWithCrosslinks = PluginUIContext & { + crosslinkerGroups?: Record; +}; + +interface LabelProvider { + label: (loci: Loci) => string | undefined; +} + +export async function addCrosslinks( + plugin: PluginUIContext, + cifText: string, + crosslinks: CrosslinkerInformation[], + crosslinkColors: CrosslinkColors, +) { + const { crosslinkerCifText: crosslinkerCifText, crosslinkerGroups: crosslinkerGroups } = + generateCrosslinkCIF(cifText, crosslinks); + + const lineData = await plugin.builders.data.rawData({ + data: crosslinkerCifText, + label: "line", + }); + const lineTrajectory = await plugin.builders.structure.parseTrajectory(lineData, "mmcif"); + const lineModel = await plugin.builders.structure.createModel(lineTrajectory); + const lineStructure = await plugin.builders.structure.createStructure(lineModel); + + for (const type of Object.values(CrosslinkerType)) { + const atomIds = crosslinkerGroups[type]; + + const expression = MS.struct.generator.atomGroups({ + "atom-test": MS.core.set.has([MS.set(...atomIds), MS.ammp("label_atom_id")]), + }); + + const component = await plugin.builders.structure.tryCreateComponentFromExpression( + lineStructure, + expression, + type, + ); + + if (component) { + await plugin.builders.structure.representation.addRepresentation(component, { + type: "line", + color: "uniform", + colorParams: { value: crosslinkColors[type] }, + }); + } + } + (plugin as PluginWithCrosslinks).crosslinkerGroups = crosslinkerGroups; +} + +export function overrideLabels(plugin: PluginUIContext, crosslinkColors: CrosslinkColors) { + const labelManager = plugin.managers.lociLabels as { + providers: LabelProvider[]; + addProvider: (p: LabelProvider) => void; + }; + + const defaultProviders = [...labelManager.providers]; + labelManager.providers = []; + + const getDefaultLabel = (loci: Loci) => + defaultProviders + .map((p) => p.label(loci)) + .filter(Boolean) + .join(" | "); + + const getAtomIdsFromLoci = (loci: StructureElement.Loci): string[] => { + const ids: string[] = []; + + for (const element of loci.elements) { + const { indices, unit } = element; + const atoms = unit.model.atomicHierarchy.atoms.label_atom_id; + + for (let i = 0; i < OrderedSet.size(indices); i++) { + const idx = OrderedSet.getAt(indices, i); + ids.push(atoms.value(idx)); + } + } + + return [...new Set(ids)]; + }; + + const findMatchingAtomPair = (ids: string[]) => { + // since the atom-pair of one crosslink is always XL...A, XL...B those are the two ids we need + // (there can be atoms of other crosslinks at the exact same place, which is why they are listed here) + const getNumber = (id: string) => /\d+/.exec(id)?.[0]; + + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + if (getNumber(ids[i]) === getNumber(ids[j])) { + return [ids[i], ids[j]] as const; + } + } + } + return undefined; + }; + + labelManager.addProvider({ + label: (loci) => { + if (loci.kind !== "element-loci") { + return getDefaultLabel(loci); + } + + const crosslinkerGroups = (plugin as PluginWithCrosslinks).crosslinkerGroups; + if (!crosslinkerGroups) { + return getDefaultLabel(loci); + } + + const atomIds = getAtomIdsFromLoci(loci); + const pair = findMatchingAtomPair(atomIds); + + if (!pair) { + return getDefaultLabel(loci); + } + + const [atomId1, atomId2] = pair; + + const match = Object.entries(crosslinkerGroups).find( + ([, ids]) => ids.includes(atomId1) && ids.includes(atomId2), + ); + + if (!match) { + return getDefaultLabel(loci); + } + + const [groupName] = match as [CrosslinkerType, string[]]; + const color = `#${crosslinkColors[groupName].toString(16).padStart(6, "0")}`; + return `${groupName}`; + }, + }); +} + +export const initCrosslinkColors = async (): Promise => { + try { + const userColors = await callApi("get_cl_colors"); + + if (userColors && Object.keys(userColors).length > 0) { + return { + ...CROSSLINK_DEFAULT_COLORS, + ...userColors, + }; + } + + return CROSSLINK_DEFAULT_COLORS; + } catch { + return CROSSLINK_DEFAULT_COLORS; + } +}; + +export function handleError( + error: unknown, + errorTitle: string, + notify: ReturnType, +) { + const errorMessage = + typeof error === "string" ? error : error instanceof Error ? error.message : "Unknown error"; + notify({ + title: errorTitle, + message: errorMessage, + type: "error", + isClosingAutomatically: true, + }); +} diff --git a/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.tsx b/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.tsx new file mode 100644 index 000000000..75f638bd5 --- /dev/null +++ b/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.tsx @@ -0,0 +1,91 @@ +import { useNotification } from "@protzilla/app"; +import { SectionTitle } from "@protzilla/core"; +import { createPluginUI } from "molstar/lib/mol-plugin-ui"; +import { PluginUIContext } from "molstar/lib/mol-plugin-ui/context"; +import { renderReact18 } from "molstar/lib/mol-plugin-ui/react18"; +import React, { useEffect, useRef, useState } from "react"; + +import { MolstarViewerProps } from "./molstar-viewer.props"; +import { + addCrosslinks, + handleError, + initCrosslinkColors, + overrideLabels, +} from "./molstar-viewer.service"; +import { LegendOverlay } from "./molstar-viewer.ui"; +import { CanvasWrapper, Container } from "./styles"; +import "molstar/lib/mol-plugin-ui/skin/base/base.scss"; + +const MolstarViewer: React.FC = ({ cifText, crosslinks }) => { + const containerRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const notify = useNotification(); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let plugin: PluginUIContext | null = null; + + const init = async () => { + try { + setIsLoading(true); + + if (!cifText) { + throw new Error("No CIF data provided"); + } + + // initialize Molstar + plugin = await createPluginUI({ + target: container, + render: renderReact18, + }); + + // load structure + const data = await plugin.builders.data.rawData({ + data: cifText, + label: "structure", + }); + const trajectory = await plugin.builders.structure.parseTrajectory(data, "mmcif"); + await plugin.builders.structure.hierarchy.applyPreset(trajectory, "default"); + + // add crosslinks to structure, if available + if (crosslinks !== undefined) { + const crosslinkColors = await initCrosslinkColors(); + await addCrosslinks(plugin, cifText, crosslinks, crosslinkColors); + overrideLabels(plugin, crosslinkColors); + } + + setIsLoading(false); + } catch (error: unknown) { + handleError(error, "MolstarViewer Error:", notify); + setIsLoading(false); + } + }; + + void init(); + + return () => { + if (plugin !== null) { + try { + plugin.dispose(); + } catch (disposeError) { + handleError(disposeError, "Error disposing Molstar plugin:", notify); + } + } + }; + }, [cifText, crosslinks, notify]); + + return ( + + {isLoading && ( + + )} + + + {crosslinks !== undefined && } + + ); +}; + +export default MolstarViewer; diff --git a/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.ui.tsx b/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.ui.tsx new file mode 100644 index 000000000..9912f9438 --- /dev/null +++ b/frontend/src/components/core/shared/molstar-viewer/molstar-viewer.ui.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +import { CrosslinkerType } from "./crosslinker-processing"; +import { CROSSLINK_DEFAULT_COLORS, CrosslinkColors } from "./molstar-viewer.config"; +import { initCrosslinkColors } from "./molstar-viewer.service"; +import { LegendContainer } from "./styles"; + +const legendEntries = Object.values(CrosslinkerType); + +export const LegendOverlay: React.FC = () => { + const [crosslinkerColors, setCrosslinkerColors] = + React.useState(CROSSLINK_DEFAULT_COLORS); + + React.useEffect(() => { + void initCrosslinkColors().then(setCrosslinkerColors); + }, []); + + return ( + + {legendEntries.map((entry) => ( +
+ + ■ + {" "} + {entry} +
+ ))} +
+ ); +}; diff --git a/frontend/src/components/core/shared/molstar-viewer/styles.ts b/frontend/src/components/core/shared/molstar-viewer/styles.ts new file mode 100644 index 000000000..5f147e511 --- /dev/null +++ b/frontend/src/components/core/shared/molstar-viewer/styles.ts @@ -0,0 +1,269 @@ +import { color, font, fontSize, fontWeight } from "@protzilla/theme"; +import { styled } from "styled-components"; + +export const Container = styled.div` + width: 100%; + height: 100vh; + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const molstarTheme = { + primary: color("protzillaDarkBlue"), + surface: color("protzillaLightGray"), + hover: color("secondaryHover"), + border: color("protzillaGray"), + lightText: color("onPrimary"), + darkText: color("text"), + success: color("green"), + error: color("caution"), +}; + +const headers = ` + .msp-plugin .msp-sequence-select > select, + .msp-plugin .msp-control-group-header > button, + .msp-plugin .msp-control-group-header div, + .msp-plugin .msp-sequence, + .msp-plugin .msp-log-entry-info, + .msp-plugin .msp-log-entry-warning, + .msp-plugin .msp-section-header, + .msp-plugin .msp-sequence-select, + .msp-plugin ::-webkit-scrollbar-thumb, + .msp-plugin .msp-slider-base-handle +`; + +const controls = ` + .msp-plugin .msp-control-row button, + .msp-plugin .msp-btn, + .msp-plugin .msp-btn-link-toggle-off, + .msp-plugin .msp-btn-link-toggle-off:active, + .msp-plugin .msp-btn-link-toggle-off:focus, + .msp-plugin .msp-log .msp-log-entry, + .msp-plugin ::-webkit-scrollbar-track, + .msp-plugin .msp-semi-transparent-background +`; + +const lightSurfaces = ` + .msp-plugin .msp-form-control, + .msp-plugin .msp-control-row select, + .msp-plugin .msp-control-row input[type=text], + .msp-plugin .msp-btn-link-toggle-on, + .msp-plugin .msp-log li, + .msp-plugin, + .msp-plugin .msp-sequence-wrapper-non-empty, + .msp-plugin .msp-control-row, + .msp-plugin .msp-control-row > div, + .msp-plugin .msp-help-text, + .msp-plugin .msp-flex-row, + .msp-plugin .msp-state-image-row, + .msp-plugin .msp-image-preview, + .msp-plugin .msp-left-panel-controls-buttons, + .msp-plugin .msp-layout-right, + .msp-plugin .msp-layout-left, + .msp-plugin .msp-highlight-info, +`; + +const layoutBlocks = ` + .msp-plugin .msp-log, + .msp-plugin .msp-viewport, + .msp-plugin .msp-layout-right, + .msp-plugin .msp-layout-left, + .msp-plugin .msp-slider-base-rail, + .msp-plugin + .msp-viewport-top-left-controls + .msp-animation-viewport-controls + .msp-animation-viewport-controls-select, + .msp-plugin .msp-viewport-controls-panel, + .msp-plugin .msp-no-webgl +`; + +const elementsWithDarkText = ` + .msp-plugin .msp-viewport-controls-buttons .msp-btn-link-toggle-off, + .msp-plugin-content, + .msp-plugin .msp-log, + .msp-plugin .msp-log .msp-log-timestamp, + .msp-plugin .msp-btn-link-toggle-on, + .msp-plugin .msp-sequence-wrapper .msp-sequence-number, + .msp-plugin .msp-control-row > span.msp-control-row-label, + .msp-plugin .msp-control-row > button.msp-control-button-label, + .msp-plugin .msp-help-text > div, + .msp-plugin .msp-btn-action, + .msp-plugin .msp-btn-action:active, + .msp-plugin .msp-btn-action:focus, + .msp-plugin .msp-25-lower-contrast-text, + .msp-plugin .msp-highlight-info, + .msp-plugin .msp-form-control:hover, + .msp-plugin .msp-control-row select:hover, + .msp-plugin .msp-control-row button:hover, + .msp-plugin .msp-control-row input[type=text]:hover, + .msp-plugin .msp-btn:hover, + .msp-plugin .msp-btn-link-toggle-off, + .msp-plugin .msp-btn-link-toggle-off:active, + .msp-plugin .msp-btn-link-toggle-off:focus, + ::placeholder +`; + +const elementsWithLightText = ` + .msp-plugin .msp-sequence-select, + .msp-plugin .msp-control-group-header > button, + .msp-plugin .msp-control-group-header div, + .msp-plugin .msp-section-header +`; + +const hoverElements = ` + .msp-plugin .msp-btn-link-toggle-off:hover, + .msp-plugin .msp-control-group-expander .msp-icon, + .msp-plugin .msp-form-control:hover, + .msp-plugin .msp-control-row select:hover, + .msp-plugin .msp-control-row button:hover, + .msp-plugin .msp-control-row input[type=text]:hover, + .msp-plugin .msp-btn:hover, + .msp-plugin .msp-help:hover span +`; + +export const CanvasWrapper = styled.div` + flex: 1; + position: relative; + top: 12vh; + + && { + /* ================= BACKGROUNDS ================= */ + + ${headers} { + background: ${molstarTheme.primary} !important; + } + + ${controls} { + background: ${molstarTheme.surface}; + } + + ${lightSurfaces} { + background: ${molstarTheme.surface} !important; + } + + ${layoutBlocks} { + background: ${molstarTheme.border} !important; + } + + /* ================= TEXT COLORS ================= */ + + ${elementsWithDarkText} { + color: ${molstarTheme.primary} !important; + } + + ${elementsWithLightText} { + color: ${molstarTheme.lightText} !important; + } + + .msp-plugin .msp-sequence-wrapper .msp-sequence-present { + color: ${molstarTheme.darkText} !important; + } + + /* ================= HOVER ================= */ + + ${hoverElements} { + background: ${molstarTheme.hover} !important; + outline: 1px solid ${molstarTheme.border} !important; + } + + /* ================= BORDERS ================= */ + + ::-webkit-scrollbar-thumb { + border: 4px solid ${molstarTheme.primary}; + } + + .msp-plugin .msp-select-toggle::after { + border-top-color: ${molstarTheme.primary} !important; + } + + .msp-plugin .msp-accent-offset, + .msp-plugin .msp-state-list > li > button:first-child { + border-left-color: ${molstarTheme.primary} !important; + } + + .msp-plugin .msp-transform-header-brand-purple, + .msp-plugin .msp-transform-header-brand-blue { + border-bottom-color: ${molstarTheme.primary} !important; + } + + .msp-plugin .msp-slider-base-handle { + border: 4px solid ${molstarTheme.surface} !important; + } + + .msp-plugin .msp-layout-standard-outside .msp-layout-left { + border-top-color: ${molstarTheme.surface} !important; + } + + .msp-plugin .msp-layout-standard, + .msp-plugin .msp-layout-standard-outside .msp-layout-top, + .msp-plugin .msp-layout-standard-outside .msp-layout-bottom { + border: 1px solid ${molstarTheme.border} !important; + } + + .msp-plugin .msp-layout-standard-outside .msp-layout-left, + .msp-plugin .msp-layout-standard-outside .msp-layout-right { + border-top: 1px solid ${molstarTheme.border} !important; + } + + .msp-plugin .msp-layout-standard-outside .msp-layout-right { + border-left: 1px solid ${molstarTheme.border} !important; + } + + .msp-plugin .msp-log li:not(:last-child), + .msp-plugin .msp-layout-standard-outside .msp-layout-bottom { + border-bottom: 1px solid ${molstarTheme.border} !important ; + } + + .msp-plugin .msp-form-control:hover, + .msp-plugin .msp-control-row select:hover, + .msp-plugin .msp-control-row button:hover, + .msp-plugin .msp-control-row input[type="text"]:hover, + .msp-plugin .msp-btn:hover { + outline: 1px solid ${molstarTheme.border}!important; + } + + /* ================= SPECIAL ================= */ + + .msp-plugin .msp-transform-header-brand svg { + stroke: ${molstarTheme.primary} !important; + } + + .msp-svg-text, + .msp-plugin .msp-transform-header-brand svg { + fill: ${molstarTheme.primary} !important; + } + + /* ================= SIGNAL ================= */ + + .msp-plugin .msp-btn-commit-on, + .msp-plugin .msp-btn-commit-on:active, + .msp-plugin .msp-btn-commit-on:focus, + .msp-plugin .msp-log-entry-message { + color: ${molstarTheme.success} !important; + } + + .msp-plugin .msp-log-entry-error { + background: ${molstarTheme.error} !important; + } + } +`; + +export const LegendContainer = styled.div` + position: absolute; + bottom: 0vh; + right: 1.5vh; + + background: ${molstarTheme.surface}; + color: ${molstarTheme.primary}; + + padding: 1vh; + font-family: ${font("defaultWithFallbacks")}; + font-size: ${fontSize("h6")}; + font-weight: ${fontWeight("default")}; + border-radius: 0px; + + pointer-events: none; + z-index: 10; +`; diff --git a/frontend/src/utils/protzilla-types.ts b/frontend/src/utils/protzilla-types.ts index ce3fa4453..0b347d27a 100644 --- a/frontend/src/utils/protzilla-types.ts +++ b/frontend/src/utils/protzilla-types.ts @@ -1,6 +1,8 @@ import { GridValidRowModel } from "@mui/x-data-grid"; import type { Edge } from "@xyflow/react"; +import { CrosslinkerInformation } from "../components/core/shared/molstar-viewer/crosslinker-processing.tsx"; + export interface UIStateProps { isDisabled?: boolean; } @@ -17,10 +19,26 @@ export interface StepOutputInfo { display_name: string; } +export interface ApiResponse { + success: boolean; + message: string; + data: T; +} + export interface Image { title: string; alt: string; - data: string; + base64image: string; +} + +export interface Download { + json_downloads: Record; +} + +export interface Visualization { + structureEntryId: string; + cifString: string; + crosslinks?: CrosslinkerInformation[]; } // We assume these are the only data types we receive for tables diff --git a/requirements.txt b/requirements.txt index 92819b74e..31426de7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ dash-bio==1.0.2 debugpy==1.8.18 Django==5.2.8 django-cors-headers==4.9.0 +gemmi==0.6.6 gseapy==1.2.1 isort==7.0.0 joblib==1.2.0