diff --git a/.gitignore b/.gitignore index 66e1db9b..251fde10 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ src/api_service/experiments/venv docker-compose.yml src/secretkey.txt docker-compose.mauri_dev.yml -venv \ No newline at end of file +venv +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6b106de2..8d900f25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,47 +1,64 @@ FROM python:3.12-slim-bookworm # Docker Files Vars -ARG LISTEN_PORT 8000 -ARG LISTEN_IP "0.0.0.0" +ARG LISTEN_PORT=8000 +ARG LISTEN_IP="0.0.0.0" # Default values for deploying with multiomix image -ENV LISTEN_PORT $LISTEN_PORT -ENV LISTEN_IP $LISTEN_IP -ENV DJANGO_SETTINGS_MODULE "multiomics_intermediate.settings_prod" -ENV RESULT_DATAFRAME_LIMIT_ROWS 500 -ENV TABLE_PAGE_SIZE 10 +ENV LISTEN_PORT=$LISTEN_PORT +ENV LISTEN_IP=$LISTEN_IP +ENV DJANGO_SETTINGS_MODULE="multiomics_intermediate.settings_prod" +ENV RESULT_DATAFRAME_LIMIT_ROWS=500 +ENV TABLE_PAGE_SIZE=10 # Modulector connection parameters -ENV MODULECTOR_HOST "127.0.0.1" -ENV MODULECTOR_PORT "8001" +ENV MODULECTOR_HOST="127.0.0.1" +ENV MODULECTOR_PORT="8001" # BioAPI connection parameters -ENV BIOAPI_HOST "127.0.0.1" -ENV BIOAPI_PORT "8002" +ENV BIOAPI_HOST="127.0.0.1" +ENV BIOAPI_PORT="8002" # PostgreSQL DB connection parameters -ENV POSTGRES_USERNAME "multiomics" -ENV POSTGRES_PASSWORD "multiomics" -ENV POSTGRES_HOST "db" -ENV POSTGRES_PORT 5432 -ENV POSTGRES_DB "multiomics" +ENV POSTGRES_USERNAME="multiomics" +ENV POSTGRES_PASSWORD="multiomics" +ENV POSTGRES_HOST="db" +ENV POSTGRES_PORT=5432 +ENV POSTGRES_DB="multiomics" # Mongo DB connection parameters -ENV MONGO_USERNAME "multiomics" -ENV MONGO_PASSWORD "multiomics" -ENV MONGO_HOST "mongo" -ENV MONGO_PORT 27017 -ENV MONGO_DB "multiomics" +ENV MONGO_USERNAME="multiomics" +ENV MONGO_PASSWORD="multiomics" +ENV MONGO_HOST="mongo" +ENV MONGO_PORT=27017 +ENV MONGO_DB="multiomics" # Redis -ENV REDIS_HOST "redis" -ENV REDIS_PORT 6379 +ENV REDIS_HOST="redis" +ENV REDIS_PORT=6379 # Installs system dependencies and Node.js RUN apt-get update && apt-get install -y python3-pip curl libcurl4-openssl-dev libssl-dev libxml2-dev \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs && mkdir /config \ && mkdir /src +# Install R 4.4.2 from CRAN (Debian bookworm) and system dependencies +RUN set -eux; \ + echo "deb http://deb.debian.org/debian sid main" > /etc/apt/sources.list.d/debian-unstable.list; \ + printf 'APT::Default-Release "%s";\n' "bookworm" > /etc/apt/apt.conf.d/00default-release; \ + echo 'APT::Install-Recommends "false";' > /etc/apt/apt.conf.d/90no-recommends; \ + printf 'Package: *\nPin: release a=unstable\nPin-Priority: 50\n' > /etc/apt/preferences.d/99pin-unstable; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg build-essential gfortran libblas-dev liblapack-dev git \ + libssl-dev libcurl4-openssl-dev libxml2-dev; \ + apt-get install -y \ + r-base=4.2.2.20221110-2 r-base-dev=4.2.2.20221110-2 r-recommended=4.2.2.20221110-2; \ + rm -rf /var/lib/apt/lists/* + +# Install Bioconductor limma (stats and base come with R) +RUN R -q -e 'options(repos=c(CRAN="https://cloud.r-project.org")); install.packages("BiocManager"); BiocManager::install(version="3.16", ask=FALSE); BiocManager::install("limma", ask=FALSE, update=FALSE)' + # Installs Python dependencies and compiles the frontend ADD config/requirements.txt /config/ WORKDIR /src @@ -58,4 +75,3 @@ HEALTHCHECK --interval=5m --timeout=30s CMD ["/bin/bash", "-c", "/src/tools/chec ENTRYPOINT ["/bin/bash", "-c", "/src/entrypoint.sh"] EXPOSE $LISTEN_PORT - diff --git a/Dockerfile-celery b/Dockerfile-celery index ea55bcba..89b892b9 100644 --- a/Dockerfile-celery +++ b/Dockerfile-celery @@ -1,47 +1,64 @@ FROM python:3.12-slim-bookworm # Docker Files Vars -ARG LISTEN_PORT 8000 -ARG LISTEN_IP "0.0.0.0" +ARG LISTEN_PORT=8000 +ARG LISTEN_IP="0.0.0.0" # Default values for deploying with multiomix image -ENV LISTEN_PORT $LISTEN_PORT -ENV LISTEN_IP $LISTEN_IP -ENV DJANGO_SETTINGS_MODULE "multiomics_intermediate.settings_prod" -ENV RESULT_DATAFRAME_LIMIT_ROWS 500 -ENV TABLE_PAGE_SIZE 10 +ENV LISTEN_PORT=$LISTEN_PORT +ENV LISTEN_IP=$LISTEN_IP +ENV DJANGO_SETTINGS_MODULE="multiomics_intermediate.settings_prod" +ENV RESULT_DATAFRAME_LIMIT_ROWS=500 +ENV TABLE_PAGE_SIZE=10 # Modulector connection parameters -ENV MODULECTOR_HOST "127.0.0.1" -ENV MODULECTOR_PORT "8001" +ENV MODULECTOR_HOST="127.0.0.1" +ENV MODULECTOR_PORT="8001" # BioAPI connection parameters -ENV BIOAPI_HOST "127.0.0.1" -ENV BIOAPI_PORT "8002" +ENV BIOAPI_HOST="127.0.0.1" +ENV BIOAPI_PORT="8002" # PostgreSQL DB connection parameters -ENV POSTGRES_USERNAME "multiomics" -ENV POSTGRES_PASSWORD "multiomics" -ENV POSTGRES_HOST "db" -ENV POSTGRES_PORT 5432 -ENV POSTGRES_DB "multiomics" +ENV POSTGRES_USERNAME="multiomics" +ENV POSTGRES_PASSWORD="multiomics" +ENV POSTGRES_HOST="db" +ENV POSTGRES_PORT=5432 +ENV POSTGRES_DB="multiomics" # Mongo DB connection parameters -ENV MONGO_USERNAME "multiomics" -ENV MONGO_PASSWORD "multiomics" -ENV MONGO_HOST "mongo" -ENV MONGO_PORT 27017 -ENV MONGO_DB "multiomics" +ENV MONGO_USERNAME="multiomics" +ENV MONGO_PASSWORD="multiomics" +ENV MONGO_HOST="mongo" +ENV MONGO_PORT=27017 +ENV MONGO_DB="multiomics" # Redis -ENV REDIS_HOST "redis" -ENV REDIS_PORT 6379 +ENV REDIS_HOST="redis" +ENV REDIS_PORT=6379 # Installs system dependencies RUN apt-get update && apt-get install -y python3-pip curl libcurl4-openssl-dev libssl-dev libxml2-dev \ && mkdir /config \ && mkdir /src +# Install R 4.4.2 from CRAN (Debian bookworm) and system dependencies +RUN set -eux; \ + echo "deb http://deb.debian.org/debian sid main" > /etc/apt/sources.list.d/debian-unstable.list; \ + printf 'APT::Default-Release "%s";\n' "bookworm" > /etc/apt/apt.conf.d/00default-release; \ + echo 'APT::Install-Recommends "false";' > /etc/apt/apt.conf.d/90no-recommends; \ + printf 'Package: *\nPin: release a=unstable\nPin-Priority: 50\n' > /etc/apt/preferences.d/99pin-unstable; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg build-essential gfortran libblas-dev liblapack-dev git \ + libssl-dev libcurl4-openssl-dev libxml2-dev; \ + apt-get install -y \ + r-base=4.2.2.20221110-2 r-base-dev=4.2.2.20221110-2 r-recommended=4.2.2.20221110-2; \ + rm -rf /var/lib/apt/lists/* + +# Install Bioconductor limma (stats and base come with R) +RUN R -q -e 'options(repos=c(CRAN="https://cloud.r-project.org")); install.packages("BiocManager"); BiocManager::install(version="3.16", ask=FALSE); BiocManager::install("limma", ask=FALSE, update=FALSE)' + # Installs Python dependencies ADD config/requirements_celery.txt /config/requirements.txt RUN pip install --upgrade pip && pip3 install -r /config/requirements.txt diff --git a/README.md b/README.md index 874a5f58..e354cd0a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Multiomix logo +Multiomix logo # Multiomix @@ -15,6 +15,7 @@ This document is focused on the **development** of the system. If you are lookin - Node JS >= `20.x` (tested version: `20.x`) - [Modulector][modulector] `2.2.0` - [BioAPI][bioapi] `1.2.1` +- R `4.4.2` (required for `differential-expression`) ## Installation @@ -66,6 +67,7 @@ Every time you want to work with Multiomix, you need to follow the below steps: 1. `python3 -m celery -A multiomics_intermediate worker -l info -Q stats` 1. `python3 -m celery -A multiomics_intermediate worker -l info -Q inference` 1. `python3 -m celery -A multiomics_intermediate worker -l info -Q sync_datasets` + 1. `python3 -m celery -A multiomics_intermediate worker -l info -Q differential_expression` 1. If you want to check Task in the GUI you can run [Flower](https://flower.readthedocs.io/en/latest/index.html) `python3 -m celery -A multiomics_intermediate flower` **NOTE:** maybe in Windows is needed to add `--pool=solo` to the previous commands. Example: `python3 -m celery -A multiomics_intermediate worker -l info -Q correlation_analysis --concurrency 1 --pool=solo` diff --git a/config/requirements.txt b/config/requirements.txt index 253e7564..1279b8de 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -28,3 +28,5 @@ scipy==1.13.0 statsmodels==0.14.2 xlrd==2.0.1 openpyxl==3.1.5 +rpy2==3.6.1 +urllib3==2.5.0 \ No newline at end of file diff --git a/config/requirements_celery.txt b/config/requirements_celery.txt index 859c0823..5c6b30bc 100644 --- a/config/requirements_celery.txt +++ b/config/requirements_celery.txt @@ -25,3 +25,5 @@ scipy==1.13.0 statsmodels==0.14.2 xlrd==2.0.1 openpyxl==3.1.5 +rpy2==3.6.1 +urllib3==2.5.0 \ No newline at end of file diff --git a/docker-compose_dist.yml b/docker-compose_dist.yml index ca1ac3db..b8aa0abe 100644 --- a/docker-compose_dist.yml +++ b/docker-compose_dist.yml @@ -233,6 +233,21 @@ services: # REDIS_HOST: 'redis' # REDIS_PORT: 6379 + # Celery worker for differential expression + differential-expression-worker: + image: omicsdatascience/multiomix:5.6.0-celery + restart: 'always' + depends_on: + - db + - mongo + volumes: + - media_data:/src/media + environment: + <<: *common-variables + QUEUE_NAME: 'differential_expression' # This MUST NOT be changed + CONCURRENCY: 2 + # PostgreSQL, Mongo y Redis usan los valores por defecto del resto de servicios + # Django backend service multiomix: image: omicsdatascience/multiomix:5.6.0 @@ -290,6 +305,7 @@ services: - stats-worker - inference-worker - sync-datasets-worker + - differential-expression-worker volumes: mongo_data: diff --git a/src/api_service/migrations/0062_alter_experiment_clinical_source_and_more.py b/src/api_service/migrations/0062_alter_experiment_clinical_source_and_more.py new file mode 100644 index 00000000..c86db344 --- /dev/null +++ b/src/api_service/migrations/0062_alter_experiment_clinical_source_and_more.py @@ -0,0 +1,142 @@ +# Generated by Django 4.2.19 on 2026-01-14 21:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "datasets_synchronization", + "0036_alter_cgdsstudy_clinical_patient_dataset_and_more", + ), + ("genes", "0002_auto_20210114_2331"), + ("user_files", "0015_alter_userfile_options"), + ("api_service", "0061_alter_experiment_shared_users"), + ] + + operations = [ + migrations.AlterField( + model_name="experiment", + name="clinical_source", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="experiments_as_clinical_source", + to="api_service.experimentclinicalsource", + ), + ), + migrations.AlterField( + model_name="experiment", + name="gem_source", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="experiments_as_gem_source", + to="api_service.experimentsource", + ), + ), + migrations.AlterField( + model_name="experiment", + name="mRNA_source", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="experiments_as_mrna_source", + to="api_service.experimentsource", + ), + ), + migrations.AlterField( + model_name="experimentclinicalsource", + name="extra_cgds_dataset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="experiment_clinical_sources_as_extra_cgds_dataset", + to="datasets_synchronization.cgdsdataset", + ), + ), + migrations.AlterField( + model_name="experimentsource", + name="cgds_dataset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="experiment_sources_as_cgds_dataset", + to="datasets_synchronization.cgdsdataset", + ), + ), + migrations.AlterField( + model_name="experimentsource", + name="user_file", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="experiment_sources_as_user_file", + to="user_files.userfile", + ), + ), + migrations.AlterField( + model_name="genecnacombination", + name="experiment", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to="api_service.experiment", + ), + ), + migrations.AlterField( + model_name="genecnacombination", + name="gene", + field=models.ForeignKey( + db_column="gene", + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="%(class)ss_as_gene", + to="genes.gene", + ), + ), + migrations.AlterField( + model_name="genemethylationcombination", + name="experiment", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to="api_service.experiment", + ), + ), + migrations.AlterField( + model_name="genemethylationcombination", + name="gene", + field=models.ForeignKey( + db_column="gene", + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="%(class)ss_as_gene", + to="genes.gene", + ), + ), + migrations.AlterField( + model_name="genemirnacombination", + name="experiment", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)ss", + to="api_service.experiment", + ), + ), + migrations.AlterField( + model_name="genemirnacombination", + name="gene", + field=models.ForeignKey( + db_column="gene", + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="%(class)ss_as_gene", + to="genes.gene", + ), + ), + ] diff --git a/src/api_service/models.py b/src/api_service/models.py index e18ec18e..a6227a9b 100644 --- a/src/api_service/models.py +++ b/src/api_service/models.py @@ -335,7 +335,9 @@ def number_of_rows(self) -> int: """ if self.user_file: return self.user_file.number_of_rows - return self.__get_cgds_datasets_joined_df().shape[0] + if self.cgds_dataset: + return self.__get_cgds_datasets_joined_df().shape[0] + return 0 @property def number_of_samples(self) -> int: @@ -345,7 +347,9 @@ def number_of_samples(self) -> int: """ if self.user_file: return self.user_file.number_of_samples - return self.__get_cgds_datasets_joined_df().shape[1] + if self.cgds_dataset: + return self.__get_cgds_datasets_joined_df().shape[1] + return 0 class Experiment(models.Model): @@ -396,10 +400,8 @@ class Experiment(models.Model): # TODO: this can be stored in the Methylation type entity. Set the corresponding nullity in the new schema correlate_with_all_genes: bool = models.BooleanField(blank=False, null=False, default=True) - shared_institutions = models.ManyToManyField(Institution, blank=True, - related_name='shared_correlation_analysis') - shared_users = models.ManyToManyField(User, blank=True, - related_name='shared_users_correlation_analysis') + shared_institutions = models.ManyToManyField(Institution, blank=True, related_name='shared_correlation_analysis') + shared_users = models.ManyToManyField(User, blank=True, related_name='shared_users_correlation_analysis') is_public = models.BooleanField(blank=False, null=False, default=False) @property @@ -417,7 +419,7 @@ def get_combination_class(self): """ return get_combination_class(self.type) - def get_clinical_columns(self): + def get_clinical_columns(self) -> list[str]: """ Gets a list of columns from the clinical data @return: List of fields in clinical data diff --git a/src/api_service/utils.py b/src/api_service/utils.py index 511cc9fa..ff242e33 100644 --- a/src/api_service/utils.py +++ b/src/api_service/utils.py @@ -131,5 +131,7 @@ def get_cgds_dataset(cgds_study: CGDSStudy, file_type: FileType) -> Optional[CGD return cgds_study.cna_dataset elif file_type == FileType.METHYLATION: return cgds_study.methylation_dataset + elif file_type == FileType.CLINICAL: + return cgds_study.clinical_patient_dataset else: return None diff --git a/src/api_service/websocket_functions.py b/src/api_service/websocket_functions.py index 237e34b9..9fc51255 100644 --- a/src/api_service/websocket_functions.py +++ b/src/api_service/websocket_functions.py @@ -40,7 +40,7 @@ def send_update_cgds_studies_command(): def send_update_biomarkers_command(user_id: int): """ - Sends a message indicating that an Biomarker's state update has occurred + Sends a message indicating that a Biomarker's state update has occurred """ user_group_name = f'notifications_{user_id}' message = { @@ -51,7 +51,7 @@ def send_update_biomarkers_command(user_id: int): def send_update_user_file_command(user_id: int): """ - Sends a message indicating that an user file's state update has occurred + Sends a message indicating that a user file's state update has occurred """ user_group_name = f'notifications_{user_id}' message = { @@ -84,6 +84,18 @@ def send_update_trained_models_command(user_id: int): send_message(user_group_name, message) +def send_update_differential_expression_experiments_command(user_id: int): + """ + Sends a message indicating that a DifferentialExpressionExperiment state update has occurred + @param user_id: DifferentialExpressionExperiment's user's id to send the WS message + """ + user_group_name = f'notifications_{user_id}' + message = { + 'command': 'update_differential_expression_experiments' + } + send_message(user_group_name, message) + + def send_update_prediction_experiment_command(user_id: int): """ Sends a message indicating that a InferenceExperiment state update has occurred @@ -107,9 +119,10 @@ def send_update_cluster_label_set_command(user_id: int): } send_message(user_group_name, message) + def send_update_institutions_command(user_id: int): """ - Sends a message indicating that a Institution state update has occurred + Sends a message indicating that an Institution state update has occurred @param user_id: Institution's user's id to send the WS message """ user_group_name = f'notifications_{user_id}' @@ -118,13 +131,14 @@ def send_update_institutions_command(user_id: int): } send_message(user_group_name, message) + def send_update_user_for_institution_command(user_id: int): """ - Sends a message indicating that a Institution_user state update has occurred + Sends a message indicating that an Institution_user state update has occurred @param user_id: Institution's user's id to send the WS message """ user_group_name = f'notifications_{user_id}' message = { 'command': 'update_user_for_institution' } - send_message(user_group_name, message) \ No newline at end of file + send_message(user_group_name, message) diff --git a/src/datasets_synchronization/admin.py b/src/datasets_synchronization/admin.py index 670b8c4c..63341976 100644 --- a/src/datasets_synchronization/admin.py +++ b/src/datasets_synchronization/admin.py @@ -42,9 +42,9 @@ def delete_queryset(self, request, queryset): 'mirna_dataset__name', 'mrna_dataset__name') - class SurvivalColumnsTupleAdmin(admin.ModelAdmin): """Useful for SurvivalColumnsTupleCGDSDataset and SurvivalColumnsTupleUserFile models.""" + @staticmethod @admin.display(description='CGDS Dataset') def dataset(obj: Union[SurvivalColumnsTupleCGDSDataset, SurvivalColumnsTupleUserFile]) -> str: @@ -53,6 +53,7 @@ def dataset(obj: Union[SurvivalColumnsTupleCGDSDataset, SurvivalColumnsTupleUser list_display = ('pk', 'dataset', 'time_column', 'event_column') search_fields = ('time_column', 'event_column') + # IMPORTANT: these models should be managed in the CGDS Panel in the frontend! admin.site.register(CGDSStudy, CGDSStudyAdmin) admin.site.register(CGDSDataset, CGDSDatasetAdmin) diff --git a/src/datasets_synchronization/migrations/0036_alter_cgdsstudy_clinical_patient_dataset_and_more.py b/src/datasets_synchronization/migrations/0036_alter_cgdsstudy_clinical_patient_dataset_and_more.py new file mode 100644 index 00000000..189242c2 --- /dev/null +++ b/src/datasets_synchronization/migrations/0036_alter_cgdsstudy_clinical_patient_dataset_and_more.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.19 on 2026-01-14 21:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("datasets_synchronization", "0035_auto_20230922_2356"), + ] + + operations = [ + migrations.AlterField( + model_name="cgdsstudy", + name="clinical_patient_dataset", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cgds_studies_as_clinical_patient_dataset", + to="datasets_synchronization.cgdsdataset", + ), + ), + migrations.AlterField( + model_name="cgdsstudy", + name="clinical_sample_dataset", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cgds_studies_as_clinical_sample_dataset", + to="datasets_synchronization.cgdsdataset", + ), + ), + migrations.AlterField( + model_name="cgdsstudy", + name="cna_dataset", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cgds_studies_as_cna_dataset", + to="datasets_synchronization.cgdsdataset", + ), + ), + migrations.AlterField( + model_name="cgdsstudy", + name="methylation_dataset", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cgds_studies_as_methylation_dataset", + to="datasets_synchronization.cgdsdataset", + ), + ), + migrations.AlterField( + model_name="cgdsstudy", + name="mirna_dataset", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cgds_studies_as_mirna_dataset", + to="datasets_synchronization.cgdsdataset", + ), + ), + migrations.AlterField( + model_name="cgdsstudy", + name="mrna_dataset", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cgds_studies_as_mrna_dataset", + to="datasets_synchronization.cgdsdataset", + ), + ), + ] diff --git a/src/datasets_synchronization/models.py b/src/datasets_synchronization/models.py index 41482be8..2ed80a33 100644 --- a/src/datasets_synchronization/models.py +++ b/src/datasets_synchronization/models.py @@ -104,6 +104,7 @@ def __get_reverse_study(self) -> Optional['CGDSDataset']: return cast(Optional['CGDSDataset'], self.clinical_patient_dataset) elif hasattr(self, 'clinical_sample_dataset'): return cast(Optional['CGDSDataset'], self.clinical_sample_dataset) + return None @property def study(self) -> Optional['CGDSDataset']: @@ -111,7 +112,7 @@ def study(self) -> Optional['CGDSDataset']: def __str__(self) -> str: study_name = self.study.name if self.study else '-' - return f'File: {self.file_path} | Col: {self.mongo_collection_name} | Assigned to study: {study_name}' + return f'PK: {self.pk} | File: {self.file_path} | Col: {self.mongo_collection_name} | Assigned to study: {study_name}' def __compute_number_of_row_and_samples_and_save(self) -> None: """ diff --git a/src/datasets_synchronization/serializers.py b/src/datasets_synchronization/serializers.py index 26bd47fe..337e716f 100644 --- a/src/datasets_synchronization/serializers.py +++ b/src/datasets_synchronization/serializers.py @@ -28,7 +28,7 @@ class Meta: fields = ['id', 'time_column', 'event_column'] def get_fields(self, *args, **kwargs): - fields = super(SurvivalColumnsTupleCGDSSimpleSerializer, self).get_fields(*args, **kwargs) + fields = super(SurvivalColumnsTupleCGDSSimpleSerializer, self).get_fields() request = self.context.get('request', None) if request and getattr(request, 'method', None) == "POST": fields['id'].required = False @@ -123,9 +123,9 @@ def __check_collection_name(mongo_collection_name: str, editing_cgds_dataset_id: }) def __update_cgds_dataset( - self, - cgds_dataset_instance: CGDSDataset, - validated_data_pop + self, + cgds_dataset_instance: CGDSDataset, + validated_data_pop ) -> Optional[CGDSDataset]: """ Updates a CGDSDataset instance from a request data @@ -186,10 +186,9 @@ def __update_survival_columns(cgds_dataset_instance: CGDSDataset, validated_data # If there's an existing id, updates the element if 'id' in survival_column: try: - survival_column_obj: SurvivalColumnsTupleCGDSDataset = SurvivalColumnsTupleCGDSDataset. \ - objects.get( - pk=survival_column['id'] - ) + survival_column_obj: SurvivalColumnsTupleCGDSDataset = SurvivalColumnsTupleCGDSDataset.objects.get( + pk=survival_column['id'] + ) survival_column_obj.time_column = survival_column['time_column'] survival_column_obj.event_column = survival_column['event_column'] survival_column_obj.save() @@ -309,21 +308,17 @@ def update(self, instance: CGDSStudy, validated_data): class SimpleCGDSDatasetSerializer(serializers.ModelSerializer): + """CGDSDataset serializer with few fields for list views.""" + name = serializers.CharField(source='study.name', read_only=True) + description = serializers.CharField(source='study.description', read_only=True) + version = serializers.CharField(source='study.version', read_only=True) + file_obj = serializers.SerializerMethodField(method_name='get_file_obj') + class Meta: model = CGDSDataset - fields = [] - - def to_representation(self, instance): - # Gets the file content for user_file - data = super(SimpleCGDSDatasetSerializer, self).to_representation(instance) - - # Serialize the study - study = instance.study - data['name'] = study.name - data['description'] = study.description - data['version'] = study.version - data['date_last_synchronization'] = instance.date_last_synchronization - data['file_type'] = instance.file_type - data['file_obj'] = None - - return data + fields = ['id', 'name', 'description', 'version', 'date_last_synchronization', 'file_type', 'file_obj'] + + @staticmethod + def get_file_obj(_instance: CGDSDataset): + """Returns None to avoid sending the file in list views.""" + return None diff --git a/src/datasets_synchronization/urls.py b/src/datasets_synchronization/urls.py index 595a6b54..e5f71a5d 100644 --- a/src/datasets_synchronization/urls.py +++ b/src/datasets_synchronization/urls.py @@ -7,6 +7,9 @@ # CGDS Studies path('studies', views.CGDSStudyList.as_view(), name='cgds_studies'), path('studies//', views.CGDSStudyDetail.as_view()), + # CGDS Datasets + path('cgds-dataset-clinical-attributes/', views.CGDSDatasetClinicalAttributes.as_view(), name='cgds_dataset_clinical_attributes'), + path('cgds-dataset-clinical-attributes//', views.CGDSDatasetClinicalAttributes.as_view()), # Synchronization path('sync', views.SyncCGDSStudy.as_view(), name='sync_cgds_study'), path('stop-sync', views.StopCGDSSync.as_view(), name='stop_cgds_study_sync') diff --git a/src/datasets_synchronization/views.py b/src/datasets_synchronization/views.py index 2818b2c6..e5ca5990 100644 --- a/src/datasets_synchronization/views.py +++ b/src/datasets_synchronization/views.py @@ -11,11 +11,13 @@ from common.pagination import StandardResultsSetPagination from common.response import ResponseStatus from .enums import SyncCGDSStudyResponseCode, SyncStrategy -from .models import CGDSStudy, CGDSDatasetSynchronizationState, CGDSStudySynchronizationState +from .models import CGDSStudy, CGDSDatasetSynchronizationState, CGDSStudySynchronizationState, CGDSDataset from rest_framework import generics, permissions, filters from user_files.models_choices import FileType from .serializers import CGDSStudySerializer -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404 +from api_service.utils import get_cgds_dataset +from user_files.models_choices import FileType @login_required @@ -214,3 +216,17 @@ def get(request: Request): # Formats to JSON the ResponseStatus object return Response(response) + + +class CGDSDatasetClinicalAttributes(APIView): + """REST endpoint: list for CGDSDataset header.""" + permission_classes = [permissions.IsAuthenticated] + + @staticmethod + def get(request, pk: int): + """Gets the clinical attributes of a CGDSDataset.""" + cgds_study = get_object_or_404(CGDSStudy, pk=pk) + # Gets the corresponding Study's Dataset + cgds_dataset = get_cgds_dataset(cgds_study, FileType.CLINICAL) + list_of_samples = cgds_dataset.get_column_names() + return Response(list_of_samples) diff --git a/src/differential_expression/admin.py b/src/differential_expression/admin.py index f1244222..99df8606 100644 --- a/src/differential_expression/admin.py +++ b/src/differential_expression/admin.py @@ -1 +1,91 @@ from django.contrib import admin +from .models import ( + DifferentialExpressionExperiment, + DifferentialExpressionExperimentResult +) + + +@admin.register(DifferentialExpressionExperiment) +class DifferentialExpressionExperimentAdmin(admin.ModelAdmin): + """Admin configuration for DifferentialExpressionExperiment model.""" + + list_display = ( + 'id', 'name', 'user', 'state', 'clinical_attribute', 'tool', + 'threshold_percentile', 'threshold', 'top', 'execution_time', 'created_at' + ) + list_filter = ( + 'state', 'is_public', 'clinical_attribute', 'created_at' + ) + search_fields = ('name', 'description', 'user__username', 'clinical_attribute') + readonly_fields = ( + 'id', 'task_id', 'execution_time', 'attempt', 'created_at', 'updated_at', + 'get_results_count' + ) + filter_horizontal = ('shared_institutions', 'shared_users') + + fieldsets = ( + ('Basic Information', { + 'fields': ('id', 'name', 'description', 'user') + }), + ('Data Sources', { + 'fields': ('clinical_source', 'mrna_source') + }), + ('Analysis Parameters', { + 'fields': ('clinical_attribute', 'tool', 'threshold_percentile', 'threshold', 'top') + }), + ('Execution Information', { + 'fields': ('state', 'task_id', 'execution_time', 'attempt', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ('Results Statistics', { + 'fields': ('get_results_count',), + 'classes': ('collapse',) + }), + ('Sharing', { + 'fields': ('is_public', 'shared_institutions', 'shared_users'), + 'classes': ('collapse',) + }), + ) + + def get_results_count(self, obj): + """Returns the total number of results.""" + try: + return obj.results.count() + except: + return 0 + get_results_count.short_description = 'Total Results' + + +@admin.register(DifferentialExpressionExperimentResult) +class DifferentialExpressionExperimentResultAdmin(admin.ModelAdmin): + """Admin configuration for DifferentialExpressionExperimentResult model.""" + + list_display = ( + 'id', 'experiment', 'gene', 'adj_p_val', 'log_fc', + 'p_value', 'ave_expr', 'is_significant_display' + ) + list_filter = ('experiment__state', 'experiment__user') + search_fields = ('gene', 'experiment__name', 'experiment__user__username') + readonly_fields = ('id', 'is_significant_display') + + fieldsets = ( + ('Basic Information', { + 'fields': ('id', 'experiment', 'gene') + }), + ('Statistical Results', { + 'fields': ('p_value', 'adj_p_val', 'log_fc', 'ave_expr', 't_statistic', 'b_statistic') + }), + ('Significance', { + 'fields': ('is_significant_display',), + 'classes': ('collapse',) + }), + ) + + def is_significant_display(self, obj): + """Display if the gene is significant with default thresholds.""" + try: + return obj.is_significant() + except: + return False + is_significant_display.short_description = 'Is Significant (p<0.05, |logFC|>1)' + is_significant_display.boolean = True diff --git a/src/differential_expression/migrations/0001_initial.py b/src/differential_expression/migrations/0001_initial.py new file mode 100644 index 00000000..7aacd5d0 --- /dev/null +++ b/src/differential_expression/migrations/0001_initial.py @@ -0,0 +1,242 @@ +# Generated by Django 4.2.19 on 2025-08-09 18:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("institutions", "0005_alter_institution_options_and_more"), + ("user_files", "0015_alter_userfile_options"), + ("datasets_synchronization", "0035_auto_20230922_2356"), + ] + + operations = [ + migrations.CreateModel( + name="DifferentialExpressionSource", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "cgds_dataset", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="differential_expression_sources", + to="datasets_synchronization.cgdsdataset", + ), + ), + ( + "user_file", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="differential_expression_sources", + to="user_files.userfile", + ), + ), + ], + ), + migrations.CreateModel( + name="DifferentialExpressionClinicalSource", + fields=[ + ( + "differentialexpressionsource_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="differential_expression.differentialexpressionsource", + ), + ), + ( + "extra_cgds_dataset", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="differential_expression_clinical_sources_extra", + to="datasets_synchronization.cgdsdataset", + ), + ), + ], + bases=("differential_expression.differentialexpressionsource",), + ), + migrations.CreateModel( + name="DifferentialExpressionExperiment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=300)), + ("description", models.TextField(blank=True, null=True)), + ("clinical_attribute", models.CharField(max_length=100)), + ("threshold_percentile", models.FloatField(default=0.15)), + ("threshold", models.FloatField(default=0.0001)), + ( + "execution_time", + models.FloatField( + blank=True, + default=0.0, + help_text="Execution time in seconds", + null=True, + ), + ), + ( + "task_id", + models.CharField( + blank=True, + help_text="Celery Task ID", + max_length=100, + null=True, + ), + ), + ( + "attempt", + models.PositiveSmallIntegerField( + default=0, + help_text="Number of attempts to prevent a buggy experiment running forever", + ), + ), + ( + "state", + models.IntegerField( + choices=[ + (1, "Completed"), + (2, "Finished With Error"), + (3, "In Process"), + (4, "Waiting For Queue"), + (5, "No Samples In Common"), + (6, "Stopping"), + (7, "Stopped"), + (8, "Reached Attempts Limit"), + (9, "No Features Found"), + (10, "Empty Dataset"), + (11, "Timeout Exceeded"), + ], + default=4, + help_text="Current state of the differential expression experiment", + ), + ), + ("is_public", models.BooleanField(default=False)), + ( + "mrna_source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="differential_expression_experiments_as_mrna", + to="differential_expression.differentialexpressionsource", + ), + ), + ( + "shared_institutions", + models.ManyToManyField( + blank=True, + related_name="shared_differential_expression", + to="institutions.institution", + ), + ), + ( + "shared_users", + models.ManyToManyField( + blank=True, + related_name="shared_users_differential_expression", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "clinical_source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="differential_expression_experiments_as_clinical", + to="differential_expression.differentialexpressionclinicalsource", + ), + ), + ], + ), + migrations.CreateModel( + name="DifferentialExpressionExperimentResult", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("gene", models.CharField(help_text="Gene identifier", max_length=100)), + ("ave_expr", models.FloatField(help_text="Average expression level")), + ( + "p_value", + models.FloatField(help_text="P-value from statistical test"), + ), + ( + "adj_p_val", + models.FloatField(help_text="Adjusted P-value (FDR corrected)"), + ), + ("log_fc", models.FloatField(help_text="Log fold change")), + ( + "t_statistic", + models.FloatField(help_text="t-statistic from the test"), + ), + ( + "b_statistic", + models.FloatField( + help_text="B-statistic (log-odds of differential expression)" + ), + ), + ( + "experiment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="results", + to="differential_expression.differentialexpressionexperiment", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["experiment", "adj_p_val"], + name="differentia_experim_c0aff3_idx", + ), + models.Index( + fields=["experiment", "log_fc"], + name="differentia_experim_084601_idx", + ), + models.Index(fields=["gene"], name="differentia_gene_7c8c66_idx"), + ], + "unique_together": {("experiment", "gene")}, + }, + ), + ] diff --git a/src/differential_expression/migrations/0002_differentialexpressionexperiment_created_at_and_more.py b/src/differential_expression/migrations/0002_differentialexpressionexperiment_created_at_and_more.py new file mode 100644 index 00000000..ca3040ca --- /dev/null +++ b/src/differential_expression/migrations/0002_differentialexpressionexperiment_created_at_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.19 on 2025-08-09 19:01 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("differential_expression", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="differentialexpressionexperiment", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + help_text="When the experiment was created", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="differentialexpressionexperiment", + name="updated_at", + field=models.DateTimeField( + auto_now=True, help_text="When the experiment was last updated" + ), + ), + ] diff --git a/src/differential_expression/migrations/0003_differentialexpressionexperiment_top_and_more.py b/src/differential_expression/migrations/0003_differentialexpressionexperiment_top_and_more.py new file mode 100644 index 00000000..918ccde1 --- /dev/null +++ b/src/differential_expression/migrations/0003_differentialexpressionexperiment_top_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.19 on 2025-08-14 02:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "differential_expression", + "0002_differentialexpressionexperiment_created_at_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="differentialexpressionexperiment", + name="top", + field=models.IntegerField( + default=100, + help_text="Number of significant results to keep (max 1000)", + ), + ), + migrations.AlterField( + model_name="differentialexpressionexperiment", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + help_text="When the experiment was created", + null=True, + ), + ), + ] diff --git a/src/differential_expression/migrations/0004_differentialexpressionexperiment_tool.py b/src/differential_expression/migrations/0004_differentialexpressionexperiment_tool.py new file mode 100644 index 00000000..a3925d57 --- /dev/null +++ b/src/differential_expression/migrations/0004_differentialexpressionexperiment_tool.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.19 on 2025-10-02 23:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "differential_expression", + "0003_differentialexpressionexperiment_top_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="differentialexpressionexperiment", + name="tool", + field=models.CharField( + choices=[("DESEQ", "Deseq"), ("LIMMA", "Limma")], + default="DESEQ", + help_text="Tool to use for differential expression analysis", + max_length=10, + ), + ), + ] diff --git a/src/differential_expression/migrations/0005_remove_differentialexpressionsource_cgds_dataset_and_more.py b/src/differential_expression/migrations/0005_remove_differentialexpressionsource_cgds_dataset_and_more.py new file mode 100644 index 00000000..508eaeb8 --- /dev/null +++ b/src/differential_expression/migrations/0005_remove_differentialexpressionsource_cgds_dataset_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.19 on 2026-01-14 22:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("api_service", "0062_alter_experiment_clinical_source_and_more"), + ("differential_expression", "0004_differentialexpressionexperiment_tool"), + ] + + operations = [ + migrations.RemoveField( + model_name="differentialexpressionsource", + name="cgds_dataset", + ), + migrations.RemoveField( + model_name="differentialexpressionsource", + name="user_file", + ), + migrations.AlterField( + model_name="differentialexpressionexperiment", + name="clinical_source", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="differential_expression_experiments_as_clinical", + to="api_service.experimentclinicalsource", + ), + ), + migrations.AlterField( + model_name="differentialexpressionexperiment", + name="mrna_source", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="differential_expression_experiments_as_mrna", + to="api_service.experimentsource", + ), + ), + migrations.DeleteModel( + name="DifferentialExpressionClinicalSource", + ), + migrations.DeleteModel( + name="DifferentialExpressionSource", + ), + ] diff --git a/src/differential_expression/models.py b/src/differential_expression/models.py index 5482a0c3..d07f4d44 100644 --- a/src/differential_expression/models.py +++ b/src/differential_expression/models.py @@ -1 +1,240 @@ from django.db import models +from django.contrib.auth import get_user_model +from institutions.models import Institution +from django.contrib.auth.models import User +import pandas as pd +import math +from django.db.models import Q, QuerySet + +from api_service.websocket_functions import send_update_differential_expression_experiments_command +from api_service.models import ExperimentSource, ExperimentClinicalSource + + +class DifferentialExpressionExperimentState(models.IntegerChoices): + """All the possible states of a Differential Expression Experiment.""" + COMPLETED = 1 + FINISHED_WITH_ERROR = 2 + IN_PROCESS = 3 + WAITING_FOR_QUEUE = 4 + NO_SAMPLES_IN_COMMON = 5 + STOPPING = 6 + STOPPED = 7 + REACHED_ATTEMPTS_LIMIT = 8 + NO_FEATURES_FOUND = 9 + EMPTY_DATASET = 10 + TIMEOUT_EXCEEDED = 11 + + +class DifferentialExpressionTool(models.TextChoices): + """Tool choices for differential expression analysis.""" + DESEQ = 'DESEQ' + LIMMA = 'LIMMA' + + +class DifferentialExpressionExperiment(models.Model): + """ + Model to create and manage differential expression data. + """ + results: QuerySet['DifferentialExpressionExperimentResult'] + + name = models.CharField(max_length=300) + description = models.TextField(blank=True, null=True) + + # Clinical and mRNA sources + # These are used to link the experiment to the clinical and mRNA data sources + clinical_source = models.ForeignKey( + 'api_service.ExperimentClinicalSource', + on_delete=models.CASCADE, + null=False, + blank=False, + related_name='differential_expression_experiments_as_clinical' + ) + + mrna_source = models.ForeignKey( + 'api_service.ExperimentSource', + on_delete=models.CASCADE, + null=False, + blank=False, + related_name='differential_expression_experiments_as_mrna' + ) + + clinical_attribute = models.CharField(max_length=100, blank=False, null=False) + + tool = models.CharField( + max_length=10, + choices=DifferentialExpressionTool.choices, + default=DifferentialExpressionTool.DESEQ, + blank=False, + null=False, + help_text='Tool to use for differential expression analysis' + ) + + threshold_percentile = models.FloatField(default=0.15, blank=False, null=False) + + threshold = models.FloatField(default=0.0001, blank=False, null=False) + + top = models.IntegerField( + default=100, + blank=False, + null=False, + help_text='Number of significant results to keep (max 1000)' + ) + + # Celery task related fields + # This is used to track the execution of the task and its state + execution_time = models.FloatField(default=0.0, blank=True, null=True, help_text='Execution time in seconds') + task_id = models.CharField(max_length=100, blank=True, null=True, help_text='Celery Task ID') + attempt = models.PositiveSmallIntegerField(default=0, help_text='Number of attempts to prevent a buggy experiment ' + 'running forever') + state = models.IntegerField( + choices=DifferentialExpressionExperimentState.choices, + default=DifferentialExpressionExperimentState.WAITING_FOR_QUEUE, + help_text='Current state of the differential expression experiment' + ) + + # Timestamp fields + created_at = models.DateTimeField(auto_now_add=True, null=True, blank=True, + help_text='When the experiment was created') + updated_at = models.DateTimeField(auto_now=True, help_text='When the experiment was last updated') + + # User and sharing information + # This is used to track the user who created the experiment and to share it with other users or institutions + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + is_public = models.BooleanField(blank=False, null=False, default=False) + shared_institutions = models.ManyToManyField(Institution, related_name='shared_differential_expression', blank=True) + shared_users = models.ManyToManyField(User, blank=True, + related_name='shared_users_differential_expression') + + def __str__(self): + return f"Differential Expression Experiment: {self.name}" + + def save(self, *args, **kwargs): + """ + Every time the experiment status changes, uses websockets to update state in the frontend. + """ + if not self.name: + raise ValueError("Experiment name cannot be empty.") + + super().save(*args, **kwargs) + + # Sends a websockets message to update the experiment state in the frontend + send_update_differential_expression_experiments_command(self.user.id) + + def save_results(self, dataframe): + """ + Save the differential expression DataFrame results. + @param dataframe: pandas DataFrame with differential expression results. + """ + # Clear existing results + self.results.all().delete() + + # Create new result records + results_to_create = [] + for gene_name, row in dataframe.iterrows(): + # Handle NaN values by replacing them with appropriate defaults + def safe_float(value, default=0.0): + """Convert value to float, handling NaN and inf values.""" + try: + if pd.isna(value) or math.isinf(float(value)): + return default + return float(value) + except (ValueError, TypeError): + return default + + result = DifferentialExpressionExperimentResult( + experiment=self, + gene=str(gene_name), # The gene identifier from the DataFrame index + ave_expr=safe_float(row.get('AveExpr', 0.0), 0.0), + p_value=safe_float(row.get('P.Value', 1.0), 1.0), + adj_p_val=safe_float(row.get('adj.P.Val', 1.0), 1.0), + log_fc=safe_float(row.get('logFC', 0.0), 0.0), + t_statistic=safe_float(row.get('t', 0.0), 0.0), + b_statistic=safe_float(row.get('B', 0.0), 0.0) + ) + results_to_create.append(result) + + # Bulk create for efficiency + DifferentialExpressionExperimentResult.objects.bulk_create(results_to_create) + + def get_results_dataframe(self): + """ + Retrieve results as a pandas DataFrame. + """ + results = self.results.all() + if not results.exists(): + return None + + data = [{ + 'gene': result.gene, + 'AveExpr': result.ave_expr, + 'P.Value': result.p_value, + 'adj.P.Val': result.adj_p_val, + 'logFC': result.log_fc, + 't': result.t_statistic, + 'B': result.b_statistic + } for result in results] + + df = pd.DataFrame(data) + df.set_index('gene', inplace=True) + return df + + def get_significant_genes(self, p_value_threshold=0.05, log_fc_threshold=1.0): + """ + Get significantly differentially expressed genes. + @param p_value_threshold: Maximum adjusted p-value (default 0.05). + @param log_fc_threshold: Minimum absolute log fold change (default 1.0). + @return QuerySet of DifferentialExpressionExperimentResult objects meeting the criteria. + """ + + return self.results.filter( + Q(adj_p_val__lte=p_value_threshold) & + (Q(log_fc__gte=log_fc_threshold) | Q(log_fc__lte=-log_fc_threshold)) + ) + + def delete(self, *args, **kwargs): + """ + Deletes the instance and sends a websockets message to update state in the frontend + """ + user_id = self.user.id # Store user_id before deletion + super().delete(*args, **kwargs) + + # Sends a websockets message to update the experiment state in the frontend + send_update_differential_expression_experiments_command(user_id) + + +class DifferentialExpressionExperimentResult(models.Model): + """ + Model to store individual differential expression results for each gene. + """ + + experiment = models.ForeignKey( + 'DifferentialExpressionExperiment', + on_delete=models.CASCADE, + related_name='results' + ) + gene = models.CharField(max_length=100, help_text='Gene identifier') + ave_expr = models.FloatField(help_text='Average expression level') + p_value = models.FloatField(help_text='P-value from statistical test') + adj_p_val = models.FloatField(help_text='Adjusted P-value (FDR corrected)') + log_fc = models.FloatField(help_text='Log fold change') + t_statistic = models.FloatField(help_text='t-statistic from the test') + b_statistic = models.FloatField(help_text='B-statistic (log-odds of differential expression)') + + class Meta: + unique_together = ('experiment', 'gene') + indexes = [ + models.Index(fields=['experiment', 'adj_p_val']), + models.Index(fields=['experiment', 'log_fc']), + models.Index(fields=['gene']), + ] + + def __str__(self): + return f"{self.gene} - {self.experiment.name}" + + def is_significant(self, p_threshold=0.05, fc_threshold=1.0): + """Check if this gene is significantly differentially expressed.""" + return (self.adj_p_val is not None and + self.log_fc is not None and + self.adj_p_val <= p_threshold and + abs(self.log_fc) >= fc_threshold) + diff --git a/src/differential_expression/serializers.py b/src/differential_expression/serializers.py new file mode 100644 index 00000000..461aea0a --- /dev/null +++ b/src/differential_expression/serializers.py @@ -0,0 +1,147 @@ +from django.contrib.auth.models import User +from rest_framework import serializers + +from api_service.serializers import ExperimentSourceSerializer, ExperimentClinicalSourceSerializer +from differential_expression.models import ( + DifferentialExpressionExperiment, + DifferentialExpressionExperimentResult +) +from institutions.models import Institution + + +class UserSimpleForDiffExpExperiments(serializers.ModelSerializer): + """Simple serializer of User model for some differential expression analysis serializers.""" + + class Meta: + model = User + fields = ['id', 'username', 'first_name', 'last_name', 'email'] + read_only_fields = ['id', 'username', 'first_name', 'last_name', 'email'] + + +class InstitutionSimpleForDiffExpExperiments(serializers.ModelSerializer): + """Simple serializer of Institution model for some differential expression analysis serializers.""" + + class Meta: + model = Institution + fields = ['id', 'name'] + read_only_fields = ['id', 'name'] + + +class DifferentialExpressionExperimentResultSerializer(serializers.ModelSerializer): + """ + Serializer for Differential Expression Experiment Results. + """ + is_significant = serializers.SerializerMethodField() + + class Meta: + model = DifferentialExpressionExperimentResult + fields = ['id', 'gene', 'ave_expr', 'p_value', 'adj_p_val', 'log_fc', 't_statistic', 'b_statistic', + 'is_significant'] + + @staticmethod + def get_is_significant(obj): + """Check if this gene is significantly differentially expressed.""" + return obj.adj_p_val <= 0.05 and abs(obj.log_fc) >= 1.0 + + +class DifferentialExpressionVolcanoPlotSerializer(serializers.ModelSerializer): + """ + Serializer for volcano plot visualization. + Returns minimal fields needed for plotting: id, label (gene name), log2FC, and pValue (adj.P.Val). + """ + label = serializers.CharField(source='gene', read_only=True) + log2FC = serializers.FloatField(source='log_fc', read_only=True) + pValue = serializers.FloatField(source='adj_p_val', read_only=True) + + class Meta: + model = DifferentialExpressionExperimentResult + fields = ['id', 'label', 'log2FC', 'pValue'] + + +class DifferentialExpressionExperimentListSerializer(serializers.ModelSerializer): + """ + Optimized serializer for differential expression experiments list view. + Returns essential fields for table display: id, user, name, description, created_at, + state, clinical_source, mrna_source, and is_public. + Uses compatible source serializers that match frontend DjangoExperimentSource interface. + """ + # Sources - using standard API serializers compatible with frontend interfaces + clinical_source = ExperimentClinicalSourceSerializer(read_only=True) + mrna_source = ExperimentSourceSerializer(read_only=True) + user = UserSimpleForDiffExpExperiments(read_only=True) + + # State information + state_display = serializers.CharField(source='get_state_display', read_only=True) + + class Meta: + model = DifferentialExpressionExperiment + fields = [ + 'id', # Experiment ID + 'user', # User ID (compatible with frontend expectations) + 'name', # Experiment name + 'description', # Experiment description + 'created_at', # Creation date + 'state', # Experiment state + 'state_display', # Human-readable state + 'clinical_source', # Clinical data source (ExperimentClinicalSourceSerializer) + 'mrna_source', # mRNA data source (ExperimentSourceSerializer) + 'is_public', # Public visibility flag + 'tool' + ] + read_only_fields = [ + 'id', 'created_at', 'state', 'state_display' + ] + + +class DifferentialExpressionExperimentSerializer(serializers.ModelSerializer): + """ + Serializer for Differential Expression Experiment (General view with all fields). + """ + user = UserSimpleForDiffExpExperiments(read_only=True) + clinical_source = ExperimentClinicalSourceSerializer(read_only=True) + mrna_source = ExperimentSourceSerializer(read_only=True) + + # Computed fields + has_results = serializers.SerializerMethodField(method_name='get_has_results') + results_count = serializers.SerializerMethodField(method_name='get_results_count') + significant_genes_count = serializers.SerializerMethodField(method_name='get_significant_genes_count') + state_display = serializers.CharField(source='get_state_display', read_only=True) + + class Meta: + model = DifferentialExpressionExperiment + fields = [ + 'id', 'name', 'description', 'user', 'clinical_source', 'mrna_source', + 'clinical_attribute', 'tool', 'threshold_percentile', 'threshold', 'top', 'state', 'state_display', + 'execution_time', 'created_at', 'updated_at', 'is_public', + 'has_results', 'results_count', 'significant_genes_count' + ] + read_only_fields = [ + 'id', 'execution_time', 'created_at', 'updated_at', + 'has_results', 'results_count', 'significant_genes_count' + ] + + @staticmethod + def get_has_results(obj: DifferentialExpressionExperiment) -> bool: + """Check if the experiment has results.""" + return obj.results.exists() + + @staticmethod + def get_results_count(obj: DifferentialExpressionExperiment) -> int: + """Get the total number of genes in results.""" + return obj.results.count() + + @staticmethod + def get_significant_genes_count(obj: DifferentialExpressionExperiment) -> int: + """Get the number of significant genes (adj.P.Val <= 0.05 and |logFC| >= 1.0).""" + return obj.get_significant_genes().count() + + +class DifferentialExpressionExperimentDetailSerializer(serializers.ModelSerializer): + """ + Detailed serializer for Differential Expression Experiment (Detail view). + Includes additional information like shared users and institutions. + """ + + class Meta:#DifferentialExpressionExperimentSerializer.Meta): + fields = '__all__' + model = DifferentialExpressionExperimentResult \ No newline at end of file diff --git a/src/differential_expression/service.py b/src/differential_expression/service.py new file mode 100644 index 00000000..6480e209 --- /dev/null +++ b/src/differential_expression/service.py @@ -0,0 +1,359 @@ +from common.exceptions import EmptyDataset +from common.typing import AbortEvent +from differential_expression.models import DifferentialExpressionExperiment +import logging +import warnings +from itertools import combinations + +import numpy as np +import pandas as pd + +# Filter R warnings BEFORE importing rpy2 +warnings.filterwarnings('ignore', + message='Environment variable "XPC_SERVICE_NAME" redefined by R', + category=UserWarning) +warnings.filterwarnings('ignore', + message='Environment variable "R_SESSION_TMPDIR" redefined by R and overriding existing variable.', + category=UserWarning) + +import rpy2.robjects as robjects +from rpy2.robjects import pandas2ri +from rpy2.robjects.conversion import get_conversion, localconverter +from rpy2.robjects.pandas2ri import converter +from rpy2.robjects.packages import importr + +class DifferentialExpressionService: + def __init__(self, experiment: DifferentialExpressionExperiment, is_aborted: AbortEvent): + self.experiment = experiment + self.is_aborted = is_aborted + + def perform_differential_expression(self) -> pd.DataFrame: + clinical_df, mrna_df = self._process_datasets() + + sample_column = 'SAMPLE_ID' + if sample_column in clinical_df.columns: + common_samples = sorted(set(mrna_df.columns) & set(clinical_df[sample_column])) + + # Check if there are samples in common + if len(common_samples) == 0: + from differential_expression.models import DifferentialExpressionExperimentState + raise ValueError("NO_SAMPLES_IN_COMMON") + + df_RNAseq_filtered = mrna_df[common_samples] + df_clinical_filtered = clinical_df[clinical_df[sample_column].isin(common_samples)] + df_clinical_filtered = df_clinical_filtered.set_index(sample_column).reindex(common_samples).reset_index() + else: + df_RNAseq_filtered = mrna_df + df_clinical_filtered = clinical_df + + # Check if we have any features (genes) left after filtering + if df_RNAseq_filtered.empty or df_RNAseq_filtered.shape[0] == 0: + raise ValueError("NO_FEATURES_FOUND") + + try: + # Import R packages + limma = importr('limma') # limma for differential expression + stats = importr('stats') # stats for model matrix + base = importr('base') # base R functions + + # 1. Validation: Ensure the clinical attribute has at least two categories + # This is necessary because differential expression requires at least two groups to compare. + unique_values = df_clinical_filtered[self.experiment.clinical_attribute].dropna().unique() + if len(unique_values) < 2: + raise ValueError(f"At least two categories are required in '{self.experiment.clinical_attribute}'.") + + # 2. Group vector creation + # Converts the clinical attribute to a categorical variable (factor in R). + # This tells the model to treat the values as groups, not as numeric values. + group_values = df_clinical_filtered[self.experiment.clinical_attribute].astype(str).values + group_levels = sorted(np.unique(group_values)) # Get all unique group names sorted + group_dict = {k: i for i, k in enumerate(group_levels)} # Map group names to indices (not strictly needed) + group_factor = pd.Categorical(group_values, categories=group_levels) + + # 3. Conversion to R objects + # Converts the filtered log-expression data (Pandas DataFrame) to an R matrix. + with localconverter(get_conversion() + converter): + r_log_data = pandas2ri.py2rpy(df_RNAseq_filtered) + # Convert the group factor to an R factor vector + r_group = robjects.FactorVector(group_factor) + + # 4. Design matrix construction (no intercept) + # The design matrix encodes the group structure for the linear model. + # Using '~ 0 + group' means no intercept: each group gets its own column. + formula = robjects.Formula('~ 0 + group') + env = robjects.Environment() + env['group'] = r_group + r_design = stats.model_matrix(formula, env) + r_design.colnames = robjects.StrVector(group_levels) # Set column names to group names + + # 5. Linear model fitting with limma + # Fit the linear model to estimate mean expression for each gene in each group. + fit = limma.lmFit(r_log_data, r_design) + + # 6. Create all possible pairwise contrasts (all-vs-all) + # For each pair of groups, create a contrast expression like 'groupB - groupA'. + pares = list(combinations(group_levels, 2)) + contrastes = [f"{b} - {a}" for a, b in pares] + contrast_matrix = limma.makeContrasts( + contrasts=robjects.StrVector(contrastes), + levels=r_design + ) + + # 7. Apply contrasts and empirical Bayes moderation + # Apply the contrasts to the fitted model, then use eBayes to stabilize variance estimates. + fit2 = limma.contrasts_fit(fit, contrast_matrix) + fit2 = limma.eBayes(fit2) + + # 8. Extract results for the first contrast + # Get the table of differential expression results for the first contrast (logFC, p-value, adjusted p-value, etc.). + results = limma.topTable( + fit2, + coef=1, # First contrast + number=robjects.r('Inf'), # All genes + adjust_method="BH" # Benjamini-Hochberg adjustment + ) + + # Convert the R data frame to a Pandas DataFrame for further analysis in Python. + results_df = pandas2ri.rpy2py(results) + + # Return top genes sorted by adjusted p-value, keeping gene names as index + top_genes = results_df.nsmallest(self.experiment.top, 'adj.P.Val') + + return top_genes + + except Exception as e: + logging.error(f"Error occurred during differential expression analysis: {e}") + raise + + def _process_datasets(self): + """Process clinical and mRNA datasets for differential expression analysis.""" + clinical_df = self.experiment.clinical_source.get_df() + mrna_df = self.experiment.mrna_source.get_df() + + if clinical_df.empty or mrna_df.empty: + raise EmptyDataset("One or both datasets are empty.") + + data_processing_service = DataProcessingService( + clinical_df=clinical_df, + mrna_df=mrna_df, + clinical_attribute=self.experiment.clinical_attribute, + threshold_percentile=self.experiment.threshold_percentile, + threshold=self.experiment.threshold + ) + + return data_processing_service.process_datasets() + + +class DataProcessingService: + """ + Service for processing clinical and RNA-Seq datasets. + """ + + def __init__(self, + clinical_df: pd.DataFrame, + mrna_df: pd.DataFrame, + clinical_attribute: str, + threshold_percentile: float = 0.15, + threshold : float = 1e-4): + + self.clinical_df = clinical_df + self.mrna_df = mrna_df + self.clinical_attribute = clinical_attribute + self.threshold_percentile = threshold_percentile + self.threshold = threshold + + def validate_datasets(self): + """Validate the clinical and mRNA datasets. + + Raises: + EmptyDataset: If either dataset is empty. + """ + if self.clinical_df.empty: + raise EmptyDataset("Clinical dataset is empty.") + if self.mrna_df.empty: + raise EmptyDataset("mRNA dataset is empty.") + + def _process_clinical_data(self) -> None: + """Process the clinical dataset. + This method filters the clinical dataset to keep only the relevant columns, + removes rows with NA values in the clinical attribute, and ensures that + the clinical attribute is in uppercase if it is of string type. + """ + + ###### Procesar datos clínicos ##### + # Reset index para convertir PATIENT_ID de índice a columna + clinical_df_reset = self.clinical_df.reset_index() + + # Create sample dataframe with SAMPLE_ID and PATIENT_ID + df_clinical_sample = clinical_df_reset[['SAMPLE_ID', 'PATIENT_ID']] + + # Create patient dataframe with PATIENT_ID and clinical attribute, removing NA values + df_clinical_patient = clinical_df_reset[['PATIENT_ID', self.clinical_attribute]].dropna(subset=[self.clinical_attribute]) + + # Si la columna es de tipo texto, limpiar y convertir a mayúsculas + if df_clinical_patient[self.clinical_attribute].dtype == 'object': + # Eliminar espacios al inicio y al final, luego convertir a mayúsculas + df_clinical_patient[self.clinical_attribute] = df_clinical_patient[self.clinical_attribute].astype( + str).str.strip().str.upper() + + # Eliminar filas duplicadas + df_clinical_patient = df_clinical_patient.drop_duplicates() + + ##### Procesar datos de muestra ##### + # df_clinical_sample = self.clinical_df[['SAMPLE_ID', 'PATIENT_ID']] + + ##### Fusionar datos clínicos y de muestra ##### + df_clinical = pd.merge(df_clinical_sample, df_clinical_patient, on='PATIENT_ID', how='inner') + df_clinical = df_clinical[['SAMPLE_ID', self.clinical_attribute]] + + df_clinical = df_clinical.drop_duplicates() + df_clinical['SAMPLE_ID'] = df_clinical['SAMPLE_ID'].str.replace('-', '.') + + self.clinical_df = df_clinical + logging.info("Clinical data processed successfully.") + + + def _process_mrna_data(self) -> None: + """Process the mRNA dataset. + This method processes the mRNA dataset by renaming columns, removing duplicates, + filtering samples based on the clinical dataset, and cleaning up NA values. + """ + + mrna_dataset = self.mrna_df.copy() + + mrna_dataset.reset_index(inplace=True) + + # SOLUCIÓN: Reemplazar guiones por puntos en los nombres de las columnas sino no puede machear con SAMPLE_ID + columns_to_rename = {} + for col in mrna_dataset.columns: + if col not in 'Standard_Symbol': + columns_to_rename[col] = col.replace('-', '.') + + mrna_dataset = mrna_dataset.rename(columns=columns_to_rename) + + mrna_dataset = mrna_dataset.drop_duplicates(subset=['Standard_Symbol'], keep='first') + + # # Si Standard_Symbol está como índice, convertirlo a columna + # if mrna_dataset.index.name == 'Standard_Symbol' in str(mrna_dataset.index.name): + # mrna_dataset = mrna_dataset.reset_index() + + # Si Standard_Symbol no está como columna pero está en el índice + # if 'Standard_Symbol' not in mrna_dataset.columns and mrna_dataset.index.name is not None: + # mrna_dataset = mrna_dataset.reset_index() + # # Renombrar la primera columna a Standard_Symbol si es necesario + # if mrna_dataset.columns[0] != 'Standard_Symbol': + # mrna_dataset = mrna_dataset.rename(columns={mrna_dataset.columns[0]: 'Standard_Symbol'}) + + + # Eliminar duplicados manteniendo la primera ocurrencia + # mrna_dataset = self.mrna_df.drop_duplicates(subset=['Standard_Symbol'], keep='first') + + # Antes de la intersección, normalizar los SAMPLE_ID extrayendo solo la parte base + self.clinical_df['SAMPLE_ID'] = self.clinical_df['SAMPLE_ID'].str.rsplit('.', n=1).str[0] + + # Normalizar también las columnas del mrna_dataset para machear con SAMPLE_ID + # Crear un mapeo de columnas normalizadas a columnas originales + normalized_columns = {} + for col in mrna_dataset.columns: + if col != 'Standard_Symbol': + # Extraer la parte base del nombre de la columna + normalized_col = col.rsplit('.', 1)[0] if '.' in col else col + normalized_columns[col] = normalized_col + + # Obtener valores de SAMPLE_ID del DataFrame de metadatos + valid_sample_ids = set(self.clinical_df['SAMPLE_ID']) + valid_columns = [col for col, norm_col in normalized_columns.items() if norm_col in valid_sample_ids] + + # Filtrar el dataset de RNA-Seq para mantener solo las columnas válidas + mrna_dataset = mrna_dataset[['Standard_Symbol'] + valid_columns] + + # Renombrar las columnas para que coincidan con los SAMPLE_ID normalizados + rename_mapping = {col: normalized_columns[col] for col in valid_columns} + mrna_dataset = mrna_dataset.rename(columns=rename_mapping) + + # Eliminar filas con NA en los datos de expresión génica + # Eliminar filas donde Standard_Symbol es NA + mrna_dataset = mrna_dataset.dropna(subset=['Standard_Symbol']) + + # Establecer Standard_Symbol como índice con el nombre Hugo_Symbol y eliminar la columna + mrna_dataset = mrna_dataset.set_index('Standard_Symbol') + mrna_dataset.index.name = 'Hugo_Symbol' + + self.mrna_df = mrna_dataset + logging.info("mRNA data processed successfully.") + + def _transform_to_log2(self) -> None: + """Transform mRNA data to log2 scale. + This method applies a log2 transformation to the mRNA dataset. + """ + + if isinstance(self.mrna_df, pd.DataFrame): + has_negative = (self.mrna_df < 0).any().any() + else: + has_negative = np.any(self.mrna_df < 0) + + if has_negative: + logging.warning("Negative values found in mRNA dataset. Skipping log2 transformation.") + + # Apply log2 transformation + mrna_df_log2 = np.log2(self.mrna_df + 1) + + self.mrna_df = mrna_df_log2 + logging.info("Log2 transformation applied to mRNA data.") + + def _filter_low_expression_genes(self) -> None: + """Filter out low-expression genes from the mRNA dataset. + This method removes genes whose maximum expression across samples is below + a specified percentile threshold. + """ + + # Calculate the average expression for each gene (across all samples) + avg_expression = self.mrna_df.mean(axis=1) + + # Calculate the threshold based on the percentile + threshold = np.percentile(avg_expression, self.threshold_percentile * 100) + + # Filter genes with average expression above or equal to the threshold + genes_to_keep = avg_expression >= threshold + self.mrna_df = self.mrna_df[genes_to_keep] + + self.mrna_df = self.mrna_df + logging.info(f"Filtered low-expression genes. Threshold: {threshold:.2f}") + + + def _filter_genes_by_variance(self) -> None: + """Filter genes based on variance. + This method removes genes whose variance across samples is below + a specified percentile threshold. + """ + + # Calculate variance for each gene (row) + variances = self.mrna_df.var(axis=1) + + # Filter out genes with variance below the threshold + genes_to_keep = variances > self.threshold + self.mrna_df = self.mrna_df[genes_to_keep] + + # Check if we still have features after filtering + if self.mrna_df.empty or self.mrna_df.shape[0] == 0: + raise ValueError("NO_FEATURES_FOUND") + + logging.info(f"Filtered genes by variance. Threshold: {self.threshold:.2f}") + + + def process_datasets(self) -> tuple[pd.DataFrame, pd.DataFrame]: + """Process the clinical and mRNA datasets. + This method orchestrates the processing steps for both datasets. + + Returns: + Tuple containing the processed clinical and mRNA datasets. + """ + + self.validate_datasets() + self._process_clinical_data() + self._process_mrna_data() + self._transform_to_log2() + self._filter_low_expression_genes() + self._filter_genes_by_variance() + + return self.clinical_df, self.mrna_df \ No newline at end of file diff --git a/src/differential_expression/tasks.py b/src/differential_expression/tasks.py new file mode 100644 index 00000000..4f17819e --- /dev/null +++ b/src/differential_expression/tasks.py @@ -0,0 +1,117 @@ +import logging +import time + +from celery.contrib.abortable import AbortableTask +from celery.exceptions import SoftTimeLimitExceeded +from django.conf import settings +from multiomics_intermediate.celery import app + +from common.exceptions import EmptyDataset, ExperimentStopped +from differential_expression.models import ( + DifferentialExpressionExperiment, + DifferentialExpressionExperimentState, +) +from .service import DifferentialExpressionService + +@app.task(bind=True, base=AbortableTask, acks_late=True, reject_on_worker_lost=True, + soft_time_limit=settings.FS_SOFT_TIME_LIMIT) +def eval_differential_expression_experiment(self, experiment_pk: int, ): + """Evaluate differential expression for a given experiment. + + @param self: Self instance of the Celery task (available due to bind=True). + @param experiment_pk: Primary key of the experiment to evaluate. + """ + # Check if the experiment exists + try: + experiment : DifferentialExpressionExperiment = DifferentialExpressionExperiment.objects.get(pk=experiment_pk) + except DifferentialExpressionExperiment.DoesNotExist: + logging.error(f'DifferentialExpressionExperiment {experiment_pk} does not exist') + return + + # Check if the experiment has reached the limit of attempts + if experiment.attempt >= 3: + logging.warning(f'DifferentialExpressionExperiment {experiment.pk} has reached attempts limit.') + experiment.state = DifferentialExpressionExperimentState.REACHED_ATTEMPTS_LIMIT + experiment.save(update_fields=['state']) + return + + # Increment the attempt and set the state of the experiment to IN_PROCESS + experiment.attempt += 1 + experiment.state = DifferentialExpressionExperimentState.IN_PROCESS + experiment.save(update_fields=['attempt', 'state']) + + try: + logging.warning(f'Starting evaluation for DifferentialExpressionExperiment ID -> {experiment.pk}') + + # Compute the differential expression experiment + start = time.time() + + compute_differential_expression = DifferentialExpressionService(experiment, is_aborted=self.is_aborted) + result = compute_differential_expression.perform_differential_expression() + + total_execution_time = time.time() - start + logging.info(f'DifferentialExpressionExperiment {experiment.pk} processed in {total_execution_time:.2f} seconds.') + + # If user cancel the experiment, discard changes + if self.is_aborted(): + experiment.state = DifferentialExpressionExperimentState.STOPPING + experiment.save(update_fields=['state']) + raise ExperimentStopped + + # Save the results to the database if we got results + if result is not None: + experiment.save_results(result) + logging.info(f'Results saved for DifferentialExpressionExperiment {experiment.pk}') + + experiment.execution_time = total_execution_time + experiment.save(update_fields=['execution_time']) + + experiment.state = DifferentialExpressionExperimentState.COMPLETED + experiment.save(update_fields=['state']) + + except EmptyDataset: + logging.error(f'Empty dataset error for DifferentialExpressionExperiment {experiment.pk}') + experiment.state = DifferentialExpressionExperimentState.EMPTY_DATASET + experiment.save(update_fields=['state']) + return + + except SoftTimeLimitExceeded as e: + # If celery soft time limit is exceeded, sets the experiment as TIMEOUT_EXCEEDED + logging.warning(f'DifferentialExpressionExperiment {experiment.pk} has exceeded the soft time limit') + logging.exception(e) + experiment.state = DifferentialExpressionExperimentState.TIMEOUT_EXCEEDED + experiment.save(update_fields=['state']) + return + + except ValueError as e: + error_msg = str(e) + if error_msg == "NO_SAMPLES_IN_COMMON": + logging.error(f'No samples in common for DifferentialExpressionExperiment {experiment.pk}') + experiment.state = DifferentialExpressionExperimentState.NO_SAMPLES_IN_COMMON + experiment.save(update_fields=['state']) + return + elif error_msg == "NO_FEATURES_FOUND": + logging.error(f'No features found after filtering for DifferentialExpressionExperiment {experiment.pk}') + experiment.state = DifferentialExpressionExperimentState.NO_FEATURES_FOUND + experiment.save(update_fields=['state']) + return + else: + # Handle other ValueError cases + logging.error(f'ValueError during evaluation of DifferentialExperimentExperiment {experiment.pk}: {e}') + experiment.state = DifferentialExpressionExperimentState.FINISHED_WITH_ERROR + experiment.save(update_fields=['state']) + raise e + + except Exception as e: + logging.error(f'Error during evaluation of DifferentialExpressionExperiment {experiment.pk}: {e}') + experiment.state = DifferentialExpressionExperimentState.FINISHED_WITH_ERROR + experiment.save(update_fields=['state']) + raise e + finally: + # Ensure that the experiment is marked as finished + if self.is_aborted(): + logging.info(f'Evaluation for DifferentialExpressionExperiment {experiment.pk} was aborted.') + experiment.state = DifferentialExpressionExperimentState.STOPPED + experiment.save(update_fields=['state']) + else: + logging.info(f'Evaluation for DifferentialExpressionExperiment {experiment.pk} completed successfully.') \ No newline at end of file diff --git a/src/differential_expression/tests/__init__.py b/src/differential_expression/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/differential_expression/tests/test_integration.py b/src/differential_expression/tests/test_integration.py new file mode 100644 index 00000000..2f0ca64a --- /dev/null +++ b/src/differential_expression/tests/test_integration.py @@ -0,0 +1,224 @@ +""" +Integration tests for differential expression analysis. +These tests execute the full pipeline including the Celery task and R/limma analysis. +""" +from unittest.mock import patch +from django.test import TestCase, override_settings +from django.contrib.auth.models import User +from common.tests_utils import create_user_file +from differential_expression.models import ( + DifferentialExpressionExperiment, + DifferentialExpressionExperimentState, + DifferentialExpressionExperimentResult +) +from differential_expression.tests.test_utils import ( + get_test_file_path, + create_differential_expression_source, + create_differential_expression_clinical_source, + create_test_differential_expression_experiment +) +from differential_expression.tasks import eval_differential_expression_experiment +from user_files.models_choices import FileType + + +def mock_is_aborted(): + """Mock is_aborted to always return False for testing.""" + return False + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CELERY_TASK_EAGER_PROPAGATES=True +) +@patch.object(eval_differential_expression_experiment, 'is_aborted', mock_is_aborted) +class DifferentialExpressionIntegrationTestCase(TestCase): + """ + Integration tests that execute the full differential expression pipeline. + Uses CELERY_TASK_ALWAYS_EAGER to run Celery tasks synchronously. + """ + + def setUp(self): + """Test setup""" + # Create test user + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + # Create test files with real TCGA data + self.mrna_file = create_user_file( + get_test_file_path('mrna_test.csv'), + 'mRNA Test', + FileType.MRNA, + self.user + ) + self.clinical_file = create_user_file( + get_test_file_path('clinical_test.csv'), + 'Clinical Test', + FileType.CLINICAL, + self.user + ) + + # Create sources + self.mrna_source = create_differential_expression_source(self.mrna_file) + self.clinical_source = create_differential_expression_clinical_source(self.clinical_file) + + def test_full_differential_expression_analysis_by_sex(self): + """ + Test the complete differential expression analysis pipeline. + Analyzes gene expression differences between Male and Female samples. + """ + # Create experiment with SEX as the clinical attribute + experiment = create_test_differential_expression_experiment( + name='Integration Test - Sex Analysis', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user, + clinical_attribute='SEX', + state=DifferentialExpressionExperimentState.WAITING_FOR_QUEUE + ) + + # Verify initial state + self.assertEqual(experiment.state, DifferentialExpressionExperimentState.WAITING_FOR_QUEUE) + self.assertEqual(experiment.attempt, 0) + + # Execute the Celery task synchronously + eval_differential_expression_experiment(experiment.pk) + + # Refresh from database + experiment.refresh_from_db() + + # Verify the experiment completed successfully + self.assertEqual( + experiment.state, + DifferentialExpressionExperimentState.COMPLETED, + f"Experiment should be COMPLETED but is {experiment.get_state_display()}" + ) + self.assertEqual(experiment.attempt, 1) + self.assertGreater(experiment.execution_time, 0) + + # Verify results were saved + results_count = experiment.results.count() + self.assertGreater(results_count, 0, "Should have differential expression results") + + # Verify result structure + first_result = experiment.results.first() + self.assertIsNotNone(first_result.gene) + self.assertIsNotNone(first_result.p_value) + self.assertIsNotNone(first_result.adj_p_val) + self.assertIsNotNone(first_result.log_fc) + + def test_differential_expression_results_dataframe(self): + """ + Test that results can be retrieved as a pandas DataFrame. + """ + # Create and run experiment + experiment = create_test_differential_expression_experiment( + name='Integration Test - DataFrame Results', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user, + clinical_attribute='SEX', + state=DifferentialExpressionExperimentState.WAITING_FOR_QUEUE + ) + + # Execute the task + eval_differential_expression_experiment(experiment.pk) + + # Refresh and get results as DataFrame + experiment.refresh_from_db() + results_df = experiment.get_results_dataframe() + + # Verify DataFrame structure + self.assertIsNotNone(results_df) + self.assertGreater(len(results_df), 0) + + # Check expected columns + expected_columns = ['AveExpr', 'P.Value', 'adj.P.Val', 'logFC', 't', 'B'] + for col in expected_columns: + self.assertIn(col, results_df.columns, f"Missing column: {col}") + + # Verify genes are in the index + self.assertTrue(len(results_df.index) > 0, "DataFrame should have genes as index") + + def test_differential_expression_significant_genes(self): + """ + Test filtering for significant differentially expressed genes. + """ + # Create and run experiment + experiment = create_test_differential_expression_experiment( + name='Integration Test - Significant Genes', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user, + clinical_attribute='SEX', + state=DifferentialExpressionExperimentState.WAITING_FOR_QUEUE + ) + + # Execute the task + eval_differential_expression_experiment(experiment.pk) + + # Refresh from database + experiment.refresh_from_db() + self.assertEqual(experiment.state, DifferentialExpressionExperimentState.COMPLETED) + + # Test get_significant_genes with relaxed thresholds for test data + significant_genes = experiment.get_significant_genes( + p_value_threshold=1.0, # Relaxed for small test dataset + log_fc_threshold=0.0 + ) + + # Should return some results (we use relaxed thresholds) + self.assertIsNotNone(significant_genes) + + def test_experiment_state_transitions(self): + """ + Test that experiment goes through correct state transitions. + """ + # Create experiment + experiment = create_test_differential_expression_experiment( + name='Integration Test - State Transitions', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user, + clinical_attribute='SEX', + state=DifferentialExpressionExperimentState.WAITING_FOR_QUEUE + ) + + # Initial state + self.assertEqual(experiment.state, DifferentialExpressionExperimentState.WAITING_FOR_QUEUE) + + # Execute task + eval_differential_expression_experiment(experiment.pk) + + # Final state should be COMPLETED + experiment.refresh_from_db() + self.assertEqual(experiment.state, DifferentialExpressionExperimentState.COMPLETED) + + # Attempt should be incremented + self.assertEqual(experiment.attempt, 1) + + def test_experiment_execution_time_recorded(self): + """ + Test that execution time is recorded after analysis. + """ + # Create experiment + experiment = create_test_differential_expression_experiment( + name='Integration Test - Execution Time', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user, + clinical_attribute='SEX', + state=DifferentialExpressionExperimentState.WAITING_FOR_QUEUE + ) + + # Initial execution time should be 0 + self.assertEqual(experiment.execution_time, 0.0) + + # Execute task + eval_differential_expression_experiment(experiment.pk) + + # Execution time should be recorded + experiment.refresh_from_db() + self.assertGreater(experiment.execution_time, 0) \ No newline at end of file diff --git a/src/differential_expression/tests/test_models.py b/src/differential_expression/tests/test_models.py new file mode 100644 index 00000000..6a312dfe --- /dev/null +++ b/src/differential_expression/tests/test_models.py @@ -0,0 +1,294 @@ +from django.test import TestCase +from django.contrib.auth.models import User +from common.tests_utils import create_user_file +from differential_expression.models import ( + DifferentialExpressionExperiment, + DifferentialExpressionExperimentState, + DifferentialExpressionExperimentResult +) +from differential_expression.tests.test_utils import ( + get_test_file_path, + create_differential_expression_source, + create_differential_expression_clinical_source, + create_test_differential_expression_experiment +) +from user_files.models_choices import FileType +import pandas as pd + + +class DifferentialExpressionExperimentModelTestCase(TestCase): + """Tests for DifferentialExpressionExperiment model""" + + def setUp(self): + """Test setup""" + # Create test user + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + # Create test files + self.mrna_file = create_user_file( + get_test_file_path('mrna_test.csv'), + 'mRNA Test', + FileType.MRNA, + self.user + ) + self.clinical_file = create_user_file( + get_test_file_path('clinical_test.csv'), + 'Clinical Test', + FileType.CLINICAL, + self.user + ) + + # Create sources + self.mrna_source = create_differential_expression_source(self.mrna_file) + self.clinical_source = create_differential_expression_clinical_source(self.clinical_file) + + def test_create_experiment(self): + """Test creating a differential expression experiment""" + experiment = create_test_differential_expression_experiment( + name='Test Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user + ) + + self.assertIsNotNone(experiment.id) + self.assertEqual(experiment.name, 'Test Experiment') + self.assertEqual(experiment.state, DifferentialExpressionExperimentState.WAITING_FOR_QUEUE) + self.assertEqual(experiment.user, self.user) + + def test_experiment_str_representation(self): + """Test string representation of experiment""" + experiment = create_test_differential_expression_experiment( + name='Test Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user + ) + + self.assertEqual( + str(experiment), + 'Differential Expression Experiment: Test Experiment' + ) + + def test_experiment_default_values(self): + """Test experiment default values""" + experiment = create_test_differential_expression_experiment( + name='Test Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user + ) + + self.assertEqual(experiment.threshold_percentile, 0.15) + self.assertEqual(experiment.threshold, 0.0001) + self.assertEqual(experiment.top, 100) + self.assertEqual(experiment.attempt, 0) + self.assertEqual(experiment.execution_time, 0.0) + self.assertFalse(experiment.is_public) + + def test_save_and_retrieve_results(self): + """Test saving and retrieving differential expression results""" + experiment = create_test_differential_expression_experiment( + name='Test Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Create test results dataframe + results_data = { + 'AveExpr': [5.2, 6.1, 7.3], + 'P.Value': [0.001, 0.05, 0.0001], + 'adj.P.Val': [0.01, 0.1, 0.001], + 'logFC': [2.5, -1.8, 3.2], + 't': [4.5, -3.2, 5.1], + 'B': [2.1, 1.5, 3.2] + } + results_df = pd.DataFrame(results_data, index=['GENE_1', 'GENE_2', 'GENE_3']) + + # Save results + experiment.save_results(results_df) + + # Retrieve results + retrieved_df = experiment.get_results_dataframe() + + # Assert results were saved correctly + self.assertIsNotNone(retrieved_df) + self.assertEqual(len(retrieved_df), 3) + self.assertEqual(set(retrieved_df.index), {'GENE_1', 'GENE_2', 'GENE_3'}) + + # Check specific values + self.assertAlmostEqual(retrieved_df.loc['GENE_1', 'AveExpr'], 5.2, places=1) + self.assertAlmostEqual(retrieved_df.loc['GENE_1', 'logFC'], 2.5, places=1) + + def test_get_significant_genes(self): + """Test getting significant genes""" + experiment = create_test_differential_expression_experiment( + name='Test Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Create test results + DifferentialExpressionExperimentResult.objects.create( + experiment=experiment, + gene='GENE_1', + ave_expr=5.2, + p_value=0.001, + adj_p_val=0.01, + log_fc=2.5, + t_statistic=4.5, + b_statistic=2.1 + ) + DifferentialExpressionExperimentResult.objects.create( + experiment=experiment, + gene='GENE_2', + ave_expr=6.1, + p_value=0.05, + adj_p_val=0.1, + log_fc=-0.5, + t_statistic=-1.2, + b_statistic=1.5 + ) + DifferentialExpressionExperimentResult.objects.create( + experiment=experiment, + gene='GENE_3', + ave_expr=7.3, + p_value=0.0001, + adj_p_val=0.001, + log_fc=3.2, + t_statistic=5.1, + b_statistic=3.2 + ) + + # Get significant genes with default thresholds (p_value <= 0.05, |log_fc| >= 1.0) + significant_genes = experiment.get_significant_genes( + p_value_threshold=0.05, + log_fc_threshold=1.0 + ) + + # Should return GENE_1 and GENE_3 (both have adj_p_val <= 0.05 and |log_fc| >= 1.0) + self.assertEqual(significant_genes.count(), 2) + gene_names = [result.gene for result in significant_genes] + self.assertIn('GENE_1', gene_names) + self.assertIn('GENE_3', gene_names) + + def test_delete_experiment_cascades(self): + """Test that deleting experiment cascades to results""" + experiment = create_test_differential_expression_experiment( + name='Test Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Create test results + DifferentialExpressionExperimentResult.objects.create( + experiment=experiment, + gene='GENE_1', + ave_expr=5.2, + p_value=0.001, + adj_p_val=0.01, + log_fc=2.5, + t_statistic=4.5, + b_statistic=2.1 + ) + + experiment_id = experiment.id + + # Delete experiment + experiment.delete() + + # Assert experiment and results were deleted + self.assertFalse( + DifferentialExpressionExperiment.objects.filter(pk=experiment_id).exists() + ) + self.assertFalse( + DifferentialExpressionExperimentResult.objects.filter( + experiment_id=experiment_id + ).exists() + ) + + +class DifferentialExpressionSourceModelTestCase(TestCase): + """Tests for DifferentialExpressionSource models""" + + def setUp(self): + """Test setup""" + # Create test user + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + # Create test files + self.mrna_file = create_user_file( + get_test_file_path('mrna_test.csv'), + 'mRNA Test', + FileType.MRNA, + self.user + ) + self.clinical_file = create_user_file( + get_test_file_path('clinical_test.csv'), + 'Clinical Test', + FileType.CLINICAL, + self.user + ) + + def test_create_mrna_source(self): + """Test creating an mRNA source""" + source = create_differential_expression_source(self.mrna_file) + + self.assertIsNotNone(source.id) + self.assertEqual(source.user_file, self.mrna_file) + + def test_create_clinical_source(self): + """Test creating a clinical source""" + source = create_differential_expression_clinical_source(self.clinical_file) + + self.assertIsNotNone(source.id) + self.assertEqual(source.user_file, self.clinical_file) + + def test_get_samples_from_mrna_source(self): + """Test getting samples from mRNA source""" + source = create_differential_expression_source(self.mrna_file) + + samples = source.get_samples() + + # Based on mrna_test.csv (6 TCGA samples as columns) + self.assertEqual(len(samples), 6) + self.assertIn('TCGA-OR-A5J1-01', samples) + self.assertIn('TCGA-OR-A5J8-01', samples) + + def test_get_samples_from_clinical_source(self): + """Test getting samples from clinical source""" + source = create_differential_expression_clinical_source(self.clinical_file) + + samples = source.get_samples() + + # Based on clinical_test.csv (6 TCGA samples as row indices - PATIENT_ID) + self.assertEqual(len(samples), 6) + self.assertIn('TCGA-OR-A5J1', samples) + self.assertIn('TCGA-OR-A5J8', samples) + + def test_get_clinical_attributes(self): + """Test getting clinical attributes""" + source = create_differential_expression_clinical_source(self.clinical_file) + + attributes = source.get_attributes() + + # Based on clinical_test.csv (OTHER_PATIENT_ID, SEX, OS_STATUS, OS_MONTHS, SAMPLE_ID, OTHER_SAMPLE_ID) + self.assertEqual(len(attributes), 6) + self.assertIn('SEX', attributes) + self.assertIn('OS_STATUS', attributes) + self.assertIn('OS_MONTHS', attributes) + self.assertIn('SAMPLE_ID', attributes) diff --git a/src/differential_expression/tests/test_utils.py b/src/differential_expression/tests/test_utils.py new file mode 100644 index 00000000..dbd59d63 --- /dev/null +++ b/src/differential_expression/tests/test_utils.py @@ -0,0 +1,90 @@ +import os +from typing import Optional +from django.contrib.auth.models import User +from api_service.models import ExperimentSource, ExperimentClinicalSource +from common.tests_utils import create_user_file +from differential_expression.models import ( + DifferentialExpressionExperiment, + DifferentialExpressionExperimentState, + DifferentialExpressionTool +) +from user_files.models import UserFile +from user_files.models_choices import FileType + + +def get_test_file_path(filename: str) -> str: + """ + Gets the absolute file's path in test folder + @param filename: File's name + @return: Absolute path to the file in test folder + """ + dir_name = os.path.dirname(__file__) + file_path = os.path.join(dir_name, f'tests_files/{filename}') + return file_path + + +def create_differential_expression_source(user_file: UserFile) -> ExperimentSource: + """ + Creates a new instance of ExperimentSource saved in DB + @param user_file: UserFile to create the ExperimentSource + @return: ExperimentSource saved instance + """ + source = ExperimentSource.objects.create(user_file=user_file) + return source + + +def create_differential_expression_clinical_source(user_file: UserFile) -> ExperimentClinicalSource: + """ + Creates a new instance of ExperimentClinicalSource saved in DB + @param user_file: UserFile to create the ExperimentClinicalSource + @return: ExperimentClinicalSource saved instance + """ + source = ExperimentClinicalSource.objects.create(user_file=user_file) + return source + + +def create_test_differential_expression_experiment( + name: str, + clinical_source: ExperimentClinicalSource, + mrna_source: ExperimentSource, + user: User, + state: DifferentialExpressionExperimentState = DifferentialExpressionExperimentState.WAITING_FOR_QUEUE, + description: str = 'Test experiment', + clinical_attribute: str = 'SEX', + tool: DifferentialExpressionTool = DifferentialExpressionTool.DESEQ, + threshold_percentile: float = 0.15, + threshold: float = 0.0001, + top: int = 100, + task_id: Optional[str] = None +) -> DifferentialExpressionExperiment: + """ + Create a test DifferentialExpressionExperiment object + @param name: Experiment name + @param clinical_source: Clinical data source + @param mrna_source: mRNA data source + @param user: User who owns the experiment + @param state: Initial experiment state + @param description: Experiment description + @param clinical_attribute: Clinical attribute to use for analysis + @param tool: Tool to use for differential expression analysis + @param threshold_percentile: Threshold percentile for filtering + @param threshold: Threshold for filtering + @param top: Number of top results to keep + @param task_id: Optional Celery task ID + @return: Saved DifferentialExpressionExperiment instance + """ + experiment = DifferentialExpressionExperiment.objects.create( + name=name, + description=description, + clinical_source=clinical_source, + mrna_source=mrna_source, + clinical_attribute=clinical_attribute, + tool=tool, + threshold_percentile=threshold_percentile, + threshold=threshold, + top=top, + state=state, + user=user, + task_id=task_id + ) + return experiment diff --git a/src/differential_expression/tests/test_views.py b/src/differential_expression/tests/test_views.py new file mode 100644 index 00000000..1d7f17ae --- /dev/null +++ b/src/differential_expression/tests/test_views.py @@ -0,0 +1,718 @@ +from django.test import TestCase +from django.contrib.auth.models import User +from rest_framework.test import APIClient +from rest_framework import status +from unittest.mock import patch, MagicMock +from common.tests_utils import create_user_file +from differential_expression.models import ( + DifferentialExpressionExperiment, + DifferentialExpressionExperimentState +) +from differential_expression.tests.test_utils import ( + get_test_file_path, + create_differential_expression_source, + create_differential_expression_clinical_source, + create_test_differential_expression_experiment +) +from user_files.models_choices import FileType + + +class DifferentialExpressionDeleteTestCase(TestCase): + """Tests for DifferentialExpressionDelete endpoint""" + + def setUp(self): + """Test setup""" + # Create test users + self.owner = User.objects.create_user( + username='owner', + email='owner@test.com', + password='testpass123' + ) + self.other_user = User.objects.create_user( + username='other', + email='other@test.com', + password='testpass123' + ) + + # Create test files + self.mrna_file = create_user_file( + get_test_file_path('mrna_test.csv'), + 'mRNA Test', + FileType.MRNA, + self.owner + ) + self.clinical_file = create_user_file( + get_test_file_path('clinical_test.csv'), + 'Clinical Test', + FileType.CLINICAL, + self.owner + ) + + # Create sources + self.mrna_source = create_differential_expression_source(self.mrna_file) + self.clinical_source = create_differential_expression_clinical_source(self.clinical_file) + + # Create API client + self.client = APIClient() + + def test_delete_experiment_success(self): + """Test successfully deleting a completed experiment""" + # Create a completed experiment + experiment = create_test_differential_expression_experiment( + name='Test Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Delete the experiment + url = f'/differential-expression/delete/{experiment.pk}/' + response = self.client.delete(url) + + # Assert response + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['ok']) + + # Assert experiment was deleted + self.assertFalse( + DifferentialExpressionExperiment.objects.filter(pk=experiment.pk).exists() + ) + + def test_delete_experiment_not_owner(self): + """Test that non-owner cannot delete experiment""" + # Create experiment owned by owner + experiment = create_test_differential_expression_experiment( + name='Test Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Authenticate as other user + self.client.force_authenticate(user=self.other_user) + + # Try to delete the experiment + url = f'/differential-expression/delete/{experiment.pk}/' + response = self.client.delete(url) + + # Assert forbidden + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(response.data['ok']) + + # Assert experiment still exists + self.assertTrue( + DifferentialExpressionExperiment.objects.filter(pk=experiment.pk).exists() + ) + + def test_delete_experiment_running_in_process(self): + """Test that running experiment cannot be deleted""" + # Create a running experiment + experiment = create_test_differential_expression_experiment( + name='Running Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.IN_PROCESS, + task_id='test-task-id' + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Try to delete the experiment + url = f'/differential-expression/delete/{experiment.pk}/' + response = self.client.delete(url) + + # Assert bad request + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data['ok']) + self.assertIn('running', response.data['detail'].lower()) + + # Assert experiment still exists + self.assertTrue( + DifferentialExpressionExperiment.objects.filter(pk=experiment.pk).exists() + ) + + def test_delete_experiment_waiting_for_queue(self): + """Test that experiment waiting for queue cannot be deleted""" + # Create experiment waiting for queue + experiment = create_test_differential_expression_experiment( + name='Waiting Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.WAITING_FOR_QUEUE, + task_id='test-task-id' + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Try to delete the experiment + url = f'/differential-expression/delete/{experiment.pk}/' + response = self.client.delete(url) + + # Assert bad request + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data['ok']) + + # Assert experiment still exists + self.assertTrue( + DifferentialExpressionExperiment.objects.filter(pk=experiment.pk).exists() + ) + + def test_delete_experiment_stopping(self): + """Test that experiment being stopped cannot be deleted""" + # Create experiment being stopped + experiment = create_test_differential_expression_experiment( + name='Stopping Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.STOPPING, + task_id='test-task-id' + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Try to delete the experiment + url = f'/differential-expression/delete/{experiment.pk}/' + response = self.client.delete(url) + + # Assert bad request + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data['ok']) + + # Assert experiment still exists + self.assertTrue( + DifferentialExpressionExperiment.objects.filter(pk=experiment.pk).exists() + ) + + def test_delete_experiment_stopped_success(self): + """Test that stopped experiment can be deleted""" + # Create stopped experiment + experiment = create_test_differential_expression_experiment( + name='Stopped Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.STOPPED + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Delete the experiment + url = f'/differential-expression/delete/{experiment.pk}/' + response = self.client.delete(url) + + # Assert success + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['ok']) + + # Assert experiment was deleted + self.assertFalse( + DifferentialExpressionExperiment.objects.filter(pk=experiment.pk).exists() + ) + + def test_delete_experiment_with_error_success(self): + """Test that experiment with error can be deleted""" + # Create experiment with error + experiment = create_test_differential_expression_experiment( + name='Error Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.FINISHED_WITH_ERROR + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Delete the experiment + url = f'/differential-expression/delete/{experiment.pk}/' + response = self.client.delete(url) + + # Assert success + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['ok']) + + # Assert experiment was deleted + self.assertFalse( + DifferentialExpressionExperiment.objects.filter(pk=experiment.pk).exists() + ) + + def test_delete_experiment_not_authenticated(self): + """Test that unauthenticated user cannot delete experiment""" + # Create experiment + experiment = create_test_differential_expression_experiment( + name='Test Experiment', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Don't authenticate + + # Try to delete the experiment + url = f'/differential-expression/delete/{experiment.pk}/' + response = self.client.delete(url) + + # Assert unauthorized + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # Assert experiment still exists + self.assertTrue( + DifferentialExpressionExperiment.objects.filter(pk=experiment.pk).exists() + ) + + def test_delete_experiment_not_found(self): + """Test deleting non-existent experiment returns 404""" + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Try to delete non-existent experiment + url = '/differential-expression/delete/99999/' + response = self.client.delete(url) + + # Assert not found + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @patch('differential_expression.views.AbortableAsyncResult') + def test_delete_experiment_with_active_task(self, mock_async_result): + """Test that experiment with active Celery task gets aborted before deletion""" + # Mock the AbortableAsyncResult + mock_result = MagicMock() + mock_result.state = 'STARTED' + mock_result.abort.return_value = True + mock_async_result.return_value = mock_result + + # Create completed experiment with task_id + experiment = create_test_differential_expression_experiment( + name='Test Experiment with Task', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED, + task_id='active-task-id' + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Delete the experiment + url = f'/differential-expression/delete/{experiment.pk}/' + response = self.client.delete(url) + + # Assert success + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['ok']) + + # Assert AbortableAsyncResult was called + mock_async_result.assert_called_once_with('active-task-id') + mock_result.abort.assert_called_once() + + # Assert experiment was deleted + self.assertFalse( + DifferentialExpressionExperiment.objects.filter(pk=experiment.pk).exists() + ) + + +class DifferentialExpressionListTestCase(TestCase): + """Tests for DifferentialExpressionList endpoint""" + + def setUp(self): + """Test setup""" + # Create test user + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + # Create test files + self.mrna_file = create_user_file( + get_test_file_path('mrna_test.csv'), + 'mRNA Test', + FileType.MRNA, + self.user + ) + self.clinical_file = create_user_file( + get_test_file_path('clinical_test.csv'), + 'Clinical Test', + FileType.CLINICAL, + self.user + ) + + # Create sources + self.mrna_source = create_differential_expression_source(self.mrna_file) + self.clinical_source = create_differential_expression_clinical_source(self.clinical_file) + + # Create API client + self.client = APIClient() + + def test_list_experiments_authenticated(self): + """Test listing experiments when authenticated""" + # Create some experiments + create_test_differential_expression_experiment( + name='Experiment 1', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user, + state=DifferentialExpressionExperimentState.COMPLETED + ) + create_test_differential_expression_experiment( + name='Experiment 2', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.user, + state=DifferentialExpressionExperimentState.IN_PROCESS + ) + + # Authenticate + self.client.force_authenticate(user=self.user) + + # Get list + response = self.client.get('/differential-expression/') + + # Assert success + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 2) + + def test_list_experiments_not_authenticated(self): + """Test that unauthenticated users cannot list experiments""" + # Don't authenticate + response = self.client.get('/differential-expression/') + + # Assert forbidden (DRF returns 403 for unauthenticated requests) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class DifferentialExpressionUpdateTestCase(TestCase): + """Tests for DifferentialExpressionUpdate endpoint""" + + def setUp(self): + """Test setup""" + # Create test users + self.owner = User.objects.create_user( + username='owner', + email='owner@test.com', + password='testpass123' + ) + self.other_user = User.objects.create_user( + username='other', + email='other@test.com', + password='testpass123' + ) + + # Create test files + self.mrna_file = create_user_file( + get_test_file_path('mrna_test.csv'), + 'mRNA Test', + FileType.MRNA, + self.owner + ) + self.clinical_file = create_user_file( + get_test_file_path('clinical_test.csv'), + 'Clinical Test', + FileType.CLINICAL, + self.owner + ) + + # Create sources + self.mrna_source = create_differential_expression_source(self.mrna_file) + self.clinical_source = create_differential_expression_clinical_source(self.clinical_file) + + # Create API client + self.client = APIClient() + + def test_update_experiment_name_and_description_success(self): + """Test successfully updating both name and description""" + # Create experiment + experiment = create_test_differential_expression_experiment( + name='Original Name', + description='Original Description', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Update the experiment + url = f'/differential-expression/update/{experiment.pk}/' + data = { + 'name': 'Updated Name', + 'description': 'Updated Description' + } + response = self.client.patch(url, data, format='json') + + # Assert response + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['ok']) + self.assertEqual(response.data['data']['name'], 'Updated Name') + self.assertEqual(response.data['data']['description'], 'Updated Description') + + # Assert experiment was updated in database + experiment.refresh_from_db() + self.assertEqual(experiment.name, 'Updated Name') + self.assertEqual(experiment.description, 'Updated Description') + + def test_update_experiment_name_only_success(self): + """Test successfully updating only the name""" + # Create experiment + experiment = create_test_differential_expression_experiment( + name='Original Name', + description='Original Description', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Update only the name + url = f'/differential-expression/update/{experiment.pk}/' + data = {'name': 'New Name Only'} + response = self.client.patch(url, data, format='json') + + # Assert response + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['ok']) + self.assertEqual(response.data['data']['name'], 'New Name Only') + self.assertEqual(response.data['data']['description'], 'Original Description') + + # Assert experiment was updated in database + experiment.refresh_from_db() + self.assertEqual(experiment.name, 'New Name Only') + self.assertEqual(experiment.description, 'Original Description') + + def test_update_experiment_description_only_success(self): + """Test successfully updating only the description""" + # Create experiment + experiment = create_test_differential_expression_experiment( + name='Original Name', + description='Original Description', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Update only the description + url = f'/differential-expression/update/{experiment.pk}/' + data = {'description': 'New Description Only'} + response = self.client.patch(url, data, format='json') + + # Assert response + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['ok']) + self.assertEqual(response.data['data']['name'], 'Original Name') + self.assertEqual(response.data['data']['description'], 'New Description Only') + + # Assert experiment was updated in database + experiment.refresh_from_db() + self.assertEqual(experiment.name, 'Original Name') + self.assertEqual(experiment.description, 'New Description Only') + + def test_update_experiment_not_owner(self): + """Test that non-owner cannot update experiment""" + # Create experiment owned by owner + experiment = create_test_differential_expression_experiment( + name='Original Name', + description='Original Description', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Authenticate as other user + self.client.force_authenticate(user=self.other_user) + + # Try to update the experiment + url = f'/differential-expression/update/{experiment.pk}/' + data = {'name': 'Hacked Name'} + response = self.client.patch(url, data, format='json') + + # Assert forbidden + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(response.data['ok']) + + # Assert experiment was not updated + experiment.refresh_from_db() + self.assertEqual(experiment.name, 'Original Name') + + def test_update_experiment_no_fields_provided(self): + """Test that update fails when no fields are provided""" + # Create experiment + experiment = create_test_differential_expression_experiment( + name='Original Name', + description='Original Description', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Try to update with no fields + url = f'/differential-expression/update/{experiment.pk}/' + data = {} + response = self.client.patch(url, data, format='json') + + # Assert bad request + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data['ok']) + self.assertIn('at least one field', response.data['detail'].lower()) + + # Assert experiment was not updated + experiment.refresh_from_db() + self.assertEqual(experiment.name, 'Original Name') + self.assertEqual(experiment.description, 'Original Description') + + def test_update_experiment_not_authenticated(self): + """Test that unauthenticated user cannot update experiment""" + # Create experiment + experiment = create_test_differential_expression_experiment( + name='Original Name', + description='Original Description', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Don't authenticate + + # Try to update the experiment + url = f'/differential-expression/update/{experiment.pk}/' + data = {'name': 'Hacked Name'} + response = self.client.patch(url, data, format='json') + + # Assert unauthorized + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # Assert experiment was not updated + experiment.refresh_from_db() + self.assertEqual(experiment.name, 'Original Name') + + def test_update_experiment_not_found(self): + """Test updating non-existent experiment returns 404""" + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Try to update non-existent experiment + url = '/differential-expression/update/99999/' + data = {'name': 'New Name'} + response = self.client.patch(url, data, format='json') + + # Assert not found + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_experiment_empty_name(self): + """Test that updating experiment with empty name fails""" + # Create experiment + experiment = create_test_differential_expression_experiment( + name='Original Name', + description='Original Description', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Try to update with empty name + url = f'/differential-expression/update/{experiment.pk}/' + data = {'name': ''} + response = self.client.patch(url, data, format='json') + + # Assert bad request + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data['ok']) + self.assertIn('cannot be empty', response.data['detail'].lower()) + + # Assert experiment was not updated + experiment.refresh_from_db() + self.assertEqual(experiment.name, 'Original Name') + + def test_update_experiment_empty_description(self): + """Test that updating experiment with empty description succeeds""" + # Create experiment + experiment = create_test_differential_expression_experiment( + name='Original Name', + description='Original Description', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.COMPLETED + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Update with empty description (should be allowed) + url = f'/differential-expression/update/{experiment.pk}/' + data = {'description': ''} + response = self.client.patch(url, data, format='json') + + # Assert success (empty description is valid) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['ok']) + self.assertEqual(response.data['data']['name'], 'Original Name') + self.assertEqual(response.data['data']['description'], '') + + # Assert experiment was updated in database + experiment.refresh_from_db() + self.assertEqual(experiment.name, 'Original Name') + self.assertEqual(experiment.description, '') + + def test_update_running_experiment(self): + """Test that running experiment can be updated (only name/description, not execution)""" + # Create a running experiment + experiment = create_test_differential_expression_experiment( + name='Running Experiment', + description='Running Description', + clinical_source=self.clinical_source, + mrna_source=self.mrna_source, + user=self.owner, + state=DifferentialExpressionExperimentState.IN_PROCESS, + task_id='test-task-id' + ) + + # Authenticate as owner + self.client.force_authenticate(user=self.owner) + + # Update the experiment + url = f'/differential-expression/update/{experiment.pk}/' + data = {'name': 'Updated Name While Running'} + response = self.client.patch(url, data, format='json') + + # Assert success (name/description can be updated even while running) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['ok']) + self.assertEqual(response.data['data']['name'], 'Updated Name While Running') + + # Assert experiment was updated in database but state is still IN_PROCESS + experiment.refresh_from_db() + self.assertEqual(experiment.name, 'Updated Name While Running') + self.assertEqual(experiment.state, DifferentialExpressionExperimentState.IN_PROCESS) diff --git a/src/differential_expression/tests/tests_files/clinical_test.csv b/src/differential_expression/tests/tests_files/clinical_test.csv new file mode 100644 index 00000000..87d9867a --- /dev/null +++ b/src/differential_expression/tests/tests_files/clinical_test.csv @@ -0,0 +1,7 @@ +PATIENT_ID,OTHER_PATIENT_ID,SEX,OS_STATUS,OS_MONTHS,SAMPLE_ID,OTHER_SAMPLE_ID +TCGA-OR-A5J1,B3164F7B-C826-4E08-9EE6-8FF96D29B913,Male,1:DECEASED,44.51,TCGA-OR-A5J1-01,E4038EBB-6E6D-44B1-84AD-E35AAFCA7B70 +TCGA-OR-A5J2,8E7C2E31-D085-4B75-A970-162526DD07A0,Female,1:DECEASED,55.09,TCGA-OR-A5J2-01,46B7EB7C-E5F7-476D-A68C-5972F947445F +TCGA-OR-A5J3,DFD687BC-6E69-42F7-AF94-D17FC150D1A1,Female,0:LIVING,68.69,TCGA-OR-A5J3-01,1FB59B6F-53C0-4B14-82CC-77CD55C67AD6 +TCGA-OR-A5J5,802DBD0D-EF07-4C91-AB8D-1DD39532E947,Male,1:DECEASED,11.99,TCGA-OR-A5J5-01,C6214F9B-35C8-424C-B6FE-BEC1A9317C0A +TCGA-OR-A5J6,C8898B42-B704-45A0-9829-144B98F416E0,Female,0:LIVING,88.8,TCGA-OR-A5J6-01,C0C9DD1B-98B1-4A20-8927-3C0B75A4559E +TCGA-OR-A5J8,08E0D412-D4D8-4D13-B792-A4DD0BD9EC2B,Male,1:DECEASED,19.02,TCGA-OR-A5J8-01,2F47F06E-8F76-440E-BBCE-B7C90635A59E diff --git a/src/differential_expression/tests/tests_files/mrna_test.csv b/src/differential_expression/tests/tests_files/mrna_test.csv new file mode 100644 index 00000000..ac8c756e --- /dev/null +++ b/src/differential_expression/tests/tests_files/mrna_test.csv @@ -0,0 +1,11 @@ +Standard_Symbol,TCGA-OR-A5J1-01,TCGA-OR-A5J2-01,TCGA-OR-A5J3-01,TCGA-OR-A5J5-01,TCGA-OR-A5J6-01,TCGA-OR-A5J8-01 +LOC100130426,0.0,0.0,0.0,0.0,0.0,0.0 +UBE2Q2P3,3.2661,2.6815,1.7301,0.0,0.0,1.4422 +HMGB1P1,149.1354,81.0777,86.4879,53.9117,66.9063,94.9316 +TIMM23,2034.1018,1304.9251,1054.6586,2350.8908,1257.9864,995.0269 +MOXD2,0.0,0.0,0.0,0.0,0.0,0.0 +LOC155060,274.2555,199.302,348.3928,439.1944,149.2147,377.9528 +RNU12-2P,1.4409,0.0,0.5925,0.7746,0.0,1.6577 +EZHIP,17.7714,0.4026,0.5925,0.7746,0.0,1.2433 +EFCAB8,0.0,0.4026,0.5925,0.7746,2.7943,0.8288 +SRP14P1,11.5274,5.2342,7.7026,6.1967,10.6183,5.3875 diff --git a/src/differential_expression/urls.py b/src/differential_expression/urls.py new file mode 100644 index 00000000..19c38e54 --- /dev/null +++ b/src/differential_expression/urls.py @@ -0,0 +1,21 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.DifferentialExpressionList.as_view(), name='differential_expression_list'), + path('results/', views.DifferentialExpressionDetail.as_view(), name='differential_expression_results'), + path('results//', views.DifferentialExpressionResults.as_view()), + path('volcano-data', views.DifferentialExpressionVolcanoData.as_view(), name='differential_expression_volcano_data'), + path('volcano-data//', views.DifferentialExpressionVolcanoData.as_view()), + path('submit-experiment', views.DifferentialExpressionSubmit.as_view(), name='differential_expression_submit'), + path('stop-experiment', views.DifferentialExpressionStop.as_view(), name='differential_expression_stop'), + path('update', views.DifferentialExpressionUpdate.as_view(), name='differential_expression_update'), + path('update//', views.DifferentialExpressionUpdate.as_view()), + path('delete', views.DifferentialExpressionDelete.as_view(), name='differential_expression_delete'), + path('delete//', views.DifferentialExpressionDelete.as_view()), + path('download-results', views.download_differential_expression_results, name='download_differential_expression_results'), + path('download-results//', views.download_differential_expression_results), + path('get-common-samples-differential-experiment', views.GetCommonSamplesDifferentialExperiment.as_view(), name='get_common_samples_differential_experiment'), + path('get-common-samples-one-front-differential-experiment', views.GetCommonSamplesDifferentialOneFrontExperiment.as_view(), name='get_common_samples_one_front_differential_experiment'), + path('switch-institution-public-view', views.ToggleDiffExperimentPublicView.as_view(), name='switch-diff-experiment-public-view'), + ] \ No newline at end of file diff --git a/src/differential_expression/views.py b/src/differential_expression/views.py index 200df7ba..d0629550 100644 --- a/src/differential_expression/views.py +++ b/src/differential_expression/views.py @@ -1 +1,820 @@ -from django.shortcuts import render +import logging +from typing import Optional, Dict, Tuple, List + +import numpy as np +import pandas as pd +from celery.contrib.abortable import AbortableAsyncResult +from django.contrib.auth.decorators import login_required +from django.core.files.base import ContentFile +from django.db import transaction +from django.db.models import Q +from django.http import HttpResponse +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, generics, permissions, status +from rest_framework.exceptions import ValidationError +from rest_framework.generics import get_object_or_404 +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from api_service.enums import SourceType, CommonSamplesStatusErrorCode +from api_service.utils import get_cgds_dataset +from common.enums import ResponseCode +from common.functions import get_enum_from_value, get_intersection, encode_json_response_status +from common.pagination import StandardResultsSetPagination +from common.response import ResponseStatus +from api_service.models import ExperimentClinicalSource, ExperimentSource +from datasets_synchronization.models import CGDSDataset +from datasets_synchronization.models import CGDSStudy +from differential_expression.models import ( + DifferentialExpressionExperiment, + DifferentialExpressionExperimentState, +) +from differential_expression.serializers import ( + DifferentialExpressionExperimentDetailSerializer, + DifferentialExpressionExperimentResultSerializer, + DifferentialExpressionExperimentListSerializer, + DifferentialExpressionVolcanoPlotSerializer, +) +from user_files.models import UserFile +from user_files.models_choices import FileType +from user_files.views import get_an_user_file +from .tasks import eval_differential_expression_experiment + + +def create_differential_expression_source( + source_type: int, + request: Request, + file_type: FileType, + prefix: str +) -> tuple[ + ExperimentSource | ExperimentClinicalSource | None, ExperimentClinicalSource | None +]: + """ + Creates a Source object for differential expression experiments. + """ + is_clinical = prefix == 'clinical' + source = ExperimentClinicalSource() if is_clinical else ExperimentSource() + clinical_source = None + + if source_type == SourceType.NEW_DATASET.value: + # Adds a new User's file and uses it + source_file = getattr(request, 'FILES', {}).get(f'{prefix}File') + if source_file is None: + return None, None + + user_file = UserFile( + name=source_file.name, + description=None, + file_obj=source_file, + file_type=file_type, + user=request.user + ) + user_file.save() + user_file.compute_post_saved_field() + source.user_file = user_file + + elif source_type == SourceType.CGDS.value: + # Gets the CGDS Study + post_data = getattr(request, 'data', {}) or getattr(request, 'POST', {}) + cgds_study_pk = post_data.get(f'{prefix}CGDSStudyPk') + if not cgds_study_pk: + return None, None + + cgds_study = CGDSStudy.objects.get(pk=int(cgds_study_pk)) + + if is_clinical: + # For clinical sources, we need both datasets + if cgds_study.clinical_patient_dataset and cgds_study.clinical_sample_dataset: + source.cgds_dataset = cgds_study.clinical_patient_dataset + source.extra_cgds_dataset = cgds_study.clinical_sample_dataset + else: + return None, None + else: + cgds_dataset = get_cgds_dataset(cgds_study, file_type) + if cgds_dataset: + source.cgds_dataset = cgds_dataset + else: + return None, None + + else: + # Uses an existing User's file + post_data = getattr(request, 'data', {}) or getattr(request, 'POST', {}) + existing_file_pk = post_data.get(f'{prefix}ExistingFilePk') + if not existing_file_pk: + return None, None + + user_file = get_an_user_file(user=request.user, user_file_pk=int(existing_file_pk)) + source.user_file = user_file + + source.save() + return source, clinical_source + + +class DifferentialExpressionDetail(generics.RetrieveAPIView): + """ + Endpoint to retrieve a differential expression experiment. + """ + + def get_queryset(self) -> DifferentialExpressionExperiment: + """ + Retrieve the differential expression experiment by its ID. + """ + user = self.request.user + experiment_id = self.kwargs.get('pk') + experiment = get_object_or_404(DifferentialExpressionExperiment, pk=experiment_id) + # Check if the user has access to the experiment + if not (experiment.is_public or + experiment.user == user or + experiment.shared_institutions.filter(institutionadministration__user=user).exists() or + experiment.shared_users.filter(id=user.id).exists()): + raise ValidationError('You do not have permission to access this experiment.') + + return experiment.results.all() + filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend] + search_fields = ['gene'] + serializer_class = DifferentialExpressionExperimentDetailSerializer + permission_classes = [permissions.IsAuthenticated] + + +class DifferentialExpressionList(generics.ListAPIView): + """ + Endpoint to list all differential expression experiments. + Returns only the fields: id, Name, Description, Date, State, Sources and if it is public. + """ + + def get_queryset(self): + """ + Endpoint to list all differential expression experiments. + """ + user = self.request.user + experiments = DifferentialExpressionExperiment.objects.filter( + Q(is_public=True) | + Q(user=user) | + Q(shared_institutions__institutionadministration__user=user) | + Q(shared_users=user) + ).distinct() + + return experiments + + serializer_class = DifferentialExpressionExperimentListSerializer + permission_classes = [permissions.IsAuthenticated] + pagination_class = StandardResultsSetPagination + filter_backends = [filters.OrderingFilter, filters.SearchFilter, DjangoFilterBackend] + search_fields = ['name', 'description'] + + +class DifferentialExpressionRetrieve(generics.RetrieveAPIView): + """ + Endpoint to retrieve a single differential expression experiment by ID. + """ + serializer_class = DifferentialExpressionExperimentListSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + """ + Returns experiments the user has access to. + """ + user = self.request.user + return DifferentialExpressionExperiment.objects.filter( + Q(is_public=True) | + Q(user=user) | + Q(shared_institutions__institutionadministration__user=user) | + Q(shared_users=user) + ).distinct() + + def get_object(self): + """ + Retrieves the experiment and checks permissions. + """ + experiment = get_object_or_404(DifferentialExpressionExperiment, pk=self.kwargs.get('pk')) + user = self.request.user + + # Check if the user has access to the experiment + if not (experiment.is_public or + experiment.user == user or + experiment.shared_institutions.filter(institutionadministration__user=user).exists() or + experiment.shared_users.filter(id=user.id).exists()): + raise ValidationError('You do not have permission to access this experiment.') + + return experiment + filterset_fields = ['tool'] + + +class DifferentialExpressionSubmit(APIView): + """ + Endpoint to submit a differential expression experiment. + """ + + permission_classes = [permissions.IsAuthenticated] + + @staticmethod + def post(request: Request): + """ + Endpoint to submit a differential expression experiment. + """ + with transaction.atomic(): + # Get basic experiment data + post_data = getattr(request, 'data', {}) or getattr(request, 'POST', {}) + + name = post_data.get('name', 'Differential Expression Experiment') + description = post_data.get('description', '') + + # Clinical source + clinical_source_type = post_data.get('clinicalType') + + if clinical_source_type: + clinical_source_type = int(clinical_source_type) + clinical_source, clinical_aux = create_differential_expression_source( + clinical_source_type, request, FileType.CLINICAL, 'clinical') + # Select the valid one (if it's a CGDSStudy it needs clinical_aux as it has both needed CGDSDatasets) + clinical_source = clinical_aux if clinical_aux is not None else clinical_source + else: + clinical_source = None + + if clinical_source is None: + raise ValidationError('Invalid clinical source') + + # mRNA source + mrna_source_type = post_data.get('mRNAType') + if mrna_source_type: + mrna_source_type = int(mrna_source_type) + mrna_source, _mrna_clinical = create_differential_expression_source( + mrna_source_type, request, FileType.MRNA, 'mRNA') + else: + mrna_source = None + + if mrna_source is None: + raise ValidationError('Invalid mRNA source') + + # Clinical attribute + clinical_attribute = post_data.get('clinicalAttribute') + if not clinical_attribute: + raise ValidationError('Clinical attribute is required') + + # Threshold percentile + try: + threshold_percentile_str = post_data.get('thresholdPercentile', '0.15') + threshold_percentile = float(threshold_percentile_str) + except (ValueError, TypeError) as exc: + raise ValidationError('Invalid threshold percentile value') from exc + + if threshold_percentile < 0 or threshold_percentile > 1: + raise ValidationError('Threshold percentile must be between 0 and 1') + + # Threshold + try: + threshold_str = post_data.get('threshold', '0.0001') + threshold = float(threshold_str) + except (ValueError, TypeError) as exc: + raise ValidationError('Invalid threshold value') from exc + + if threshold < 0 or threshold > 1: + raise ValidationError('Threshold must be between 0 and 1') + + # Top parameter + try: + top_str = post_data.get('top', '100') + top = int(top_str) + except (ValueError, TypeError) as exc: + raise ValidationError('Invalid top value') from exc + + if top < 1 or top > 1000: + raise ValidationError('Top must be between 1 and 1000') + + # Create the differential expression experiment + experiment = DifferentialExpressionExperiment.objects.create( + name=name, + description=description, + clinical_source=clinical_source, + mrna_source=mrna_source, + clinical_attribute=clinical_attribute, + threshold_percentile=threshold_percentile, + threshold=threshold, + top=top, + user=request.user, + ) + + # Start the async task + async_res = eval_differential_expression_experiment.apply_async( + (experiment.pk,), queue='differential_expression') + + experiment.task_id = async_res.task_id + experiment.save(update_fields=['task_id']) + + return Response({'ok': True}) + + +class DifferentialExpressionResults(generics.ListAPIView): + """ + Endpoint to get results for a differential expression experiment. + Supports filtering by p-value and fold-change thresholds via query parameters. + """ + serializer_class = DifferentialExpressionExperimentResultSerializer + permission_classes = [permissions.IsAuthenticated] + pagination_class = StandardResultsSetPagination + filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend] + search_fields = ['gene'] + ordering_fields = ['adj_p_val', 'log_fc', 'p_value', 'ave_expr'] + ordering = ['adj_p_val'] # Default ordering by adjusted p-value + + def get_queryset(self): + experiment_id = self.kwargs.get('pk') + experiment = get_object_or_404(DifferentialExpressionExperiment, pk=experiment_id) + + # Check permissions + user = self.request.user + if not (experiment.is_public or + experiment.user == user or + experiment.shared_institutions.filter(institutionadministration__user=user).exists() or + experiment.shared_users.filter(id=user.id).exists()): + raise ValidationError('You do not have permission to access this experiment.') + + # Get query parameters for filtering (optional) + p_threshold = self.request.query_params.get('p_threshold') + fc_threshold = self.request.query_params.get('fc_threshold') + + # If filtering parameters are provided, apply filtering + if p_threshold is not None or fc_threshold is not None: + try: + p_threshold = float(p_threshold) if p_threshold is not None else 0.05 + fc_threshold = float(fc_threshold) if fc_threshold is not None else 2.0 + except ValueError as exc: + raise ValidationError('Invalid threshold values. Must be numeric.') from exc + + # Validate thresholds + if p_threshold < 0 or p_threshold > 1: + raise ValidationError('p_threshold must be between 0 and 1') + if fc_threshold < 0: + raise ValidationError('fc_threshold must be positive') + + return experiment.get_significant_genes(p_threshold, fc_threshold) + + # Return all results if no filtering parameters + return experiment.results.all() + + +class DifferentialExpressionVolcanoData(generics.ListAPIView): + """ + Endpoint to get all results for a volcano plot visualization. + Returns all results without pagination in a format suitable for volcano plots. + Format: [{id, label, log2FC, pValue}, ...] + """ + serializer_class = DifferentialExpressionVolcanoPlotSerializer + permission_classes = [permissions.IsAuthenticated] + pagination_class = None # Disable pagination for volcano plot + + def get_queryset(self): + """Get all results for the specified experiment with permission checks.""" + pk = self.kwargs.get('pk') + experiment = get_object_or_404(DifferentialExpressionExperiment, pk=pk) + + # Check permissions + user = self.request.user + if not (experiment.is_public or + experiment.user == user or + experiment.shared_institutions.filter(institutionadministration__user=user).exists() or + experiment.shared_users.filter(id=user.id).exists()): + raise ValidationError('You do not have permission to access this experiment.') + + # Return all results (no pagination) + return experiment.results.all() + + +class DifferentialExpressionStop(APIView): + """ + Endpoint to stop a differential expression experiment. + """ + permission_classes = [permissions.IsAuthenticated] + + @staticmethod + def get(request: Request): + experiment_id = request.GET.get('experimentId') + if not experiment_id: + raise ValidationError('experimentId is required.') + + experiment = get_object_or_404(DifferentialExpressionExperiment, pk=experiment_id) + + user = request.user + if not (experiment.is_public or + experiment.user == user or + experiment.shared_institutions.filter(institutionadministration__user=user).exists() or + experiment.shared_users.filter(id=user.id).exists()): + raise ValidationError('You do not have permission to access this experiment.') + + # Task state and validation + if not experiment.task_id: + return Response({'ok': False, 'detail': 'The experiment does not have an associated task.'}) + + # If it's no running, there's nothing to stop + if experiment.state in [ + DifferentialExpressionExperimentState.COMPLETED, + DifferentialExpressionExperimentState.STOPPED, + DifferentialExpressionExperimentState.FINISHED_WITH_ERROR, + DifferentialExpressionExperimentState.TIMEOUT_EXCEEDED, + DifferentialExpressionExperimentState.REACHED_ATTEMPTS_LIMIT, + DifferentialExpressionExperimentState.EMPTY_DATASET, + DifferentialExpressionExperimentState.NO_SAMPLES_IN_COMMON, + DifferentialExpressionExperimentState.NO_FEATURES_FOUND, + ]: + return Response({'ok': False, 'detail': f'The experiment is not running. Status: {experiment.state}'}) + + # Try to abort the AbortableTask + try: + async_res = AbortableAsyncResult(experiment.task_id) + aborted = async_res.abort() # Signals the task to abort (self.is_aborted() == True) + except Exception as e: + return Response({'ok': False, 'detail': f'The task could not be stopped: {e}'}) + + # Mark the experiment as STOPPING; the task will mark it as STOPPED in the finally block + experiment.state = DifferentialExpressionExperimentState.STOPPING + experiment.save(update_fields=['state']) + + return Response({'ok': bool(aborted)}) + + +class GetCommonSamplesDifferentialExperiment(APIView): + """Gets the number of in common samples between two datasets""" + + permission_classes = [permissions.IsAuthenticated] + + @staticmethod + def get(request: Request): + mrna_source_id = request.GET.get('mRNASourceId') + mrna_source_type = request.GET.get('mRNASourceType') + clinical_source_id = request.GET.get('clinicalSourceId') + clinical_source_type = request.GET.get('clinicalSourceType') + if None in [mrna_source_id, mrna_source_type, clinical_source_id, + clinical_source_type]: + response = { + 'status': ResponseStatus( + ResponseCode.ERROR, + message='Invalid request params', + internal_code=CommonSamplesStatusErrorCode.INVALID_PARAMS + ), + } + else: + # Cast parameters + mrna_source_id = int(mrna_source_id) + mrna_source_type = get_enum_from_value( + int(mrna_source_type), SourceType) + + clinical_source_id = int(clinical_source_id) + clinical_source_type = get_enum_from_value(int(clinical_source_type), SourceType) + + # Gets df + samples_list_mrna, response = get_samples_list( + mrna_source_id, + mrna_source_type, + FileType.MRNA, + request.user + ) + + # Response will be != None if an error occurred + if response is None: + samples_list_clinical, response = get_samples_list( + clinical_source_id, + clinical_source_type, + FileType.CLINICAL, + request.user + ) + intersection = get_intersection(samples_list_mrna, samples_list_clinical) + # Gets intersection + + if response is None: + response = { + 'status': ResponseStatus(ResponseCode.SUCCESS), + 'data': { + 'number_samples_mrna': len(samples_list_mrna) if samples_list_mrna is not None else 0, + 'number_samples_clinical': len( + samples_list_clinical) if samples_list_clinical is not None else 0, + 'number_samples_in_common': intersection.size + } + } + + # Formats to JSON the ResponseStatus object + return encode_json_response_status(response) + + +class GetCommonSamplesDifferentialOneFrontExperiment(APIView): + permission_classes = [permissions.IsAuthenticated] + """Gets the number of in common samples between two datasets, one in the backend and other in the frontend""" + + @staticmethod + def post(request: Request): + post_data = getattr(request, 'data', {}) or getattr(request, 'POST', {}) + headers_in_front: Optional[List[str]] = post_data.get('headersColumnsNames') + other_source_id = post_data.get('otherSourceId') + other_source_type = post_data.get('otherSourceType') + other_source_file_type = post_data.get('otherSourceFileType') + + if headers_in_front is None or other_source_id is None or other_source_type is None or other_source_file_type is None: + response = { + 'status': ResponseStatus( + ResponseCode.ERROR, + message='Invalid request params', + internal_code=CommonSamplesStatusErrorCode.INVALID_PARAMS + ), + } + else: + # Cast parameters + other_source_id = int(other_source_id) + other_source_type = get_enum_from_value( + int(other_source_type), SourceType) + + # Gets df + samples_list_1, response = get_samples_list( + other_source_id, + other_source_type, + other_source_file_type, + request.user + ) + + # Response will be != None if an error occurred + if response is None: + intersection: np.ndarray = get_intersection( + samples_list_1, headers_in_front) + response = { + 'status': ResponseStatus(ResponseCode.SUCCESS), + 'data': { + 'number_samples_backend': len(samples_list_1) if samples_list_1 else 0, + 'number_samples_in_common': intersection.size + } + } + + # Formats to JSON the ResponseStatus object + return encode_json_response_status(response) + + +class ToggleDiffExperimentPublicView(APIView): + """ + API endpoint to toggle the 'is_public' field of an experiment. + Only the owner of the experiment can perform this action. + """ + permission_classes = [permissions.IsAuthenticated] + + @staticmethod + def post(request): + """ + Toggle the 'is_public' field of the experiment. + """ + data = request.data + experiment_id = data.get('experimentId') + experiment = get_object_or_404(DifferentialExpressionExperiment, id=experiment_id) + if experiment.user.id != request.user.id: + return Response( + {"error": "You do not have permission to modify this experiment."}, + status=status.HTTP_403_FORBIDDEN + ) + + experiment.is_public = not experiment.is_public + experiment.save(update_fields=['is_public']) + + return Response( + {"id": experiment.id, "is_public": experiment.is_public} + ) + + +class DifferentialExpressionUpdate(APIView): + """ + Endpoint to update name and description of a differential expression experiment. + Only the owner can update the experiment. + """ + permission_classes = [permissions.IsAuthenticated] + + @staticmethod + def patch(request: Request, pk: int): + """ + Update name and/or description of a differential expression experiment. + """ + experiment = get_object_or_404(DifferentialExpressionExperiment, pk=pk) + + # Only the owner can update the experiment + if experiment.user.id != request.user.id: + return Response( + {'ok': False, 'detail': 'You do not have permission to update this experiment.'}, + status=403 + ) + + # Get the data from request + data = request.data + name = data.get('name') + description = data.get('description') + + # Validate that at least one field is provided + if name is None and description is None: + return Response( + {'ok': False, 'detail': 'At least one field (name or description) must be provided.'}, + status=400 + ) + + # Validate that name is not empty if provided + if name is not None and not name: + return Response( + {'ok': False, 'detail': 'Experiment name cannot be empty.'}, + status=400 + ) + + # Update fields if provided + fields_to_update = [] + if name is not None: + experiment.name = name + fields_to_update.append('name') + if description is not None: + experiment.description = description + fields_to_update.append('description') + + # Save the experiment + experiment.save(update_fields=fields_to_update) + + return Response({ + 'ok': True, + 'data': { + 'id': experiment.id, + 'name': experiment.name, + 'description': experiment.description + } + }) + + +class DifferentialExpressionDelete(APIView): + """ + Endpoint to delete a differential expression experiment. + Only the owner can delete the experiment. + The experiment must not be running (must be completed, stopped, or in an error state). + """ + permission_classes = [permissions.IsAuthenticated] + + @staticmethod + def delete(request: Request, pk: int): + """ + Delete a differential expression experiment. + """ + experiment = get_object_or_404(DifferentialExpressionExperiment, pk=pk) + + # Only the owner can delete the experiment + if experiment.user.id != request.user.id: + return Response( + {'ok': False, 'detail': 'You do not have permission to delete this experiment.'}, + status=403 + ) + + # Check if the experiment is currently running + running_states = [ + DifferentialExpressionExperimentState.IN_PROCESS, + DifferentialExpressionExperimentState.WAITING_FOR_QUEUE, + DifferentialExpressionExperimentState.STOPPING, + ] + + if experiment.state in running_states: + return Response( + { + 'ok': False, + 'detail': f'Cannot delete experiment while it is running. Current state: {experiment.get_state_display()}. Please stop the experiment first.' + }, + status=400 + ) + + # If the experiment has a task_id and is somehow still active, abort it + if experiment.task_id: + try: + async_res = AbortableAsyncResult(experiment.task_id) + if async_res.state in ['PENDING', 'STARTED', 'RETRY']: + async_res.abort() + except Exception as ex: + # If we can't abort, continue with deletion anyway since state shows it's not running + logging.exception(ex) + + # Delete the experiment (cascade will delete related objects) + experiment.delete() + + return Response({'ok': True}) + + +def get_samples_list( + id_source: int, + type_source: Optional[SourceType], + file_type: Optional[FileType], + user +) -> Tuple[Optional[List[str]], Optional[Dict]]: + """ + Gets a DataFrame from the file retrieve from DB or MongoDB with an id and SourceType. + @param id_source: ID of the UserFile/CGDSDataset to retrieve. + @param type_source: Source type to check if it's a UserFile or a CGDSDataset. + @param file_type: FileType (mRNA, miRNA, etc.) to get the corresponding CGDSDataset. + @param user: Current logged user to retrieve only his datasets. + @return: A DataFrame (if corresponds) and a Response dict (the dataset doesn't exist). + """ + list_of_samples = None + response = None + if type_source is None: + response = { + 'status': ResponseStatus( + ResponseCode.ERROR, + message=f'The source type {type_source} does not exist', + internal_code=CommonSamplesStatusErrorCode.SOURCE_TYPE_DOES_NOT_EXISTS + ), + } + elif type_source == SourceType.UPLOADED_DATASETS: + try: + user_file = get_an_user_file(user=user, user_file_pk=id_source) + if file_type == FileType.CLINICAL: + list_of_samples = user_file.get_first_column_of_all_rows() + else: + list_of_samples = user_file.get_column_names() + except UserFile.DoesNotExist: + response = { + 'status': ResponseStatus( + ResponseCode.ERROR, + message=f'The UserFile with id = {id_source} does not exist', + internal_code=CommonSamplesStatusErrorCode.DATASET_DOES_NOT_EXISTS + ), + } + elif type_source == SourceType.CGDS: + try: + # Gets the CGDS Study + cgds_study = CGDSStudy.objects.get(pk=id_source) + + # Gets the corresponding Study's Dataset + cgds_dataset = get_cgds_dataset(cgds_study, file_type) + + list_of_samples = cgds_dataset.get_column_names() + + except CGDSDataset.DoesNotExist: + response = { + 'status': ResponseStatus( + ResponseCode.ERROR, + message=f'The CGDS dataset with id = {id_source} does not exist', + internal_code=CommonSamplesStatusErrorCode.DATASET_DOES_NOT_EXISTS + ), + } + + return list_of_samples, response + + +@login_required +def download_differential_expression_results(request, pk: int): + """ + Downloads all the differential expression results for a specific experiment. + Returns a TSV file with all results (no pagination). + Supports optional filtering via query parameter: + - p_value: adjusted p-value threshold (returns genes with adj_p_val <= p_value) + """ + experiment = get_object_or_404(DifferentialExpressionExperiment, pk=pk) + + # Check permissions + user = request.user + if not (experiment.is_public or + experiment.user == user or + experiment.shared_institutions.filter(institutionadministration__user=user).exists() or + experiment.shared_users.filter(id=user.id).exists()): + return HttpResponse('Unauthorized', status=401) + + # Get filter parameter from query string + p_value = request.GET.get('p_value') + + # Apply filter if provided + if p_value is not None: + try: + p_value = float(p_value) + except ValueError: + return HttpResponse('Invalid p_value. Must be numeric.', status=400) + + # Validate p_value + if p_value < 0 or p_value > 1: + return HttpResponse('p_value must be between 0 and 1', status=400) + + # Filter results: adj_p_val <= p_value (significant genes) + results = experiment.results.filter(adj_p_val__lte=p_value).order_by('adj_p_val') + filename_suffix = f'_filtered_p{p_value}' + else: + # Get all results (no filtering) + results = experiment.results.all().order_by('adj_p_val') + filename_suffix = '_all' + + if not results.exists(): + return HttpResponse('No results found for this experiment', status=404) + + # Convert results to list of dictionaries + results_data = [] + for result in results: + results_data.append({ + 'gene': result.gene, + 'log_fc': result.log_fc, + 'ave_expr': result.ave_expr, + 't_statistic': result.t_statistic, + 'p_value': result.p_value, + 'adj_p_val': result.adj_p_val, + 'b_statistic': result.b_statistic + }) + + # Create DataFrame and convert to TSV + df = pd.DataFrame(results_data) + file_to_send = ContentFile(df.to_csv(sep='\t', decimal='.', index=False)) + + # Generate HTTP response for file download + response = HttpResponse(file_to_send, 'text/csv') + response['Content-Length'] = file_to_send.size + response['Content-Disposition'] = f'attachment; filename="{experiment.name}_results{filename_suffix}.tsv"' + + return response diff --git a/src/feature_selection/tasks.py b/src/feature_selection/tasks.py index f2e8edd8..9da116da 100644 --- a/src/feature_selection/tasks.py +++ b/src/feature_selection/tasks.py @@ -62,7 +62,7 @@ def eval_feature_selection_experiment(self, experiment_pk: int, fit_fun_enum: Fi start = time.time() molecules_temp_file_path, clinical_temp_file_path, running_in_spark = prepare_and_compute_fs_experiment( experiment, fit_fun_enum, fitness_function_parameters, algorithm_parameters, - cross_validation_parameters, self.is_aborted + cross_validation_parameters, is_aborted=self.is_aborted ) total_execution_time = time.time() - start logging.warning(f'FSExperiment {experiment.pk} total time -> {total_execution_time} seconds') diff --git a/src/frontend/static/frontend/package-lock.json b/src/frontend/static/frontend/package-lock.json index 9e29f6d6..b6e3a75c 100644 --- a/src/frontend/static/frontend/package-lock.json +++ b/src/frontend/static/frontend/package-lock.json @@ -23,11 +23,13 @@ "ky": "^1.7.5", "lodash": "^4.17.21", "neo-react-semantic-ui-range": "^0.3.6", + "plotly.js": "^3.3.0", "react": "^18.3.1", "react-apexcharts": "^1.4.1", "react-avatar": "^5.0.3", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", + "react-plotly.js": "^2.6.0", "react-virtualized": "^9.22.6", "recharts": "^2.15.1", "semantic-ui-react": "^2.1.5", @@ -126,6 +128,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2122,6 +2125,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -2176,6 +2180,24 @@ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" }, + "node_modules/@choojs/findup": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", + "integrity": "sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==", + "license": "MIT", + "dependencies": { + "commander": "^2.15.1" + }, + "bin": { + "findup": "bin/findup.js" + } + }, + "node_modules/@choojs/findup/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/@cnakazawa/watch": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz", @@ -3676,6 +3698,122 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "dev": true }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/geojson-rewind/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC" + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/@module-federation/error-codes": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.11.2.tgz", @@ -3816,6 +3954,92 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@plotly/d3": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.2.tgz", + "integrity": "sha512-wvsNmh1GYjyJfyEBPKJLTMzgf2c2bEbSIL50lmqVUi+o1NHaLPi1Lb4v7VxXXJn043BhNyrxUrWI85Q+zmjOVA==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey/-/d3-sankey-0.7.2.tgz", + "integrity": "sha512-2jdVos1N3mMp3QW0k2q1ph7Gd6j5PY1YihBrwpkFnKqO+cqtZq3AdEYUeSGXMeLsBDQYiqTVcihYfk8vr5tqhw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1", + "d3-collection": "1", + "d3-shape": "^1.2.0" + } + }, + "node_modules/@plotly/d3-sankey-circular": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey-circular/-/d3-sankey-circular-0.33.1.tgz", + "integrity": "sha512-FgBV1HEvCr3DV7RHhDsPXyryknucxtfnLwPtCKKxdolKyTFYoLX/ibEfX39iFYIL7DYbVeRtP43dbFcrHNE+KQ==", + "license": "MIT", + "dependencies": { + "d3-array": "^1.2.1", + "d3-collection": "^1.0.4", + "d3-shape": "^1.2.0", + "elementary-circuits-directed-graph": "^1.0.4" + } + }, + "node_modules/@plotly/mapbox-gl": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@plotly/mapbox-gl/-/mapbox-gl-1.13.4.tgz", + "integrity": "sha512-sR3/Pe5LqT/fhYgp4rT4aSFf1rTsxMbGiH6Hojc7PH36ny5Bn17iVFUjpzycafETURuFbLZUfjODO8LvSI+5zQ==", + "license": "SEE LICENSE IN LICENSE.txt", + "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/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/@plotly/point-cluster": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@plotly/point-cluster/-/point-cluster-3.1.9.tgz", + "integrity": "sha512-MwaI6g9scKf68Orpr1pHZ597pYx9uP8UEFXLPbsCmuw3a84obwz6pnMXGc90VhgDNeNiLEdlmuK7CPo+5PIxXw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "binary-search-bounds": "^2.0.4", + "clamp": "^1.0.1", + "defined": "^1.0.0", + "dtype": "^2.0.0", + "flatten-vertex-data": "^1.0.2", + "is-obj": "^1.0.1", + "math-log2": "^1.0.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/@plotly/regl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@plotly/regl/-/regl-2.1.2.tgz", + "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==", + "license": "MIT" + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.3.tgz", @@ -3880,6 +4104,7 @@ "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -4198,6 +4423,7 @@ "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.3.2.tgz", "integrity": "sha512-QbEn1SkNW3b89KTlSkp6OHdvw3DhpL6tSdDhsOlldw3LoRBy4fx80Z9W9lmg+g+8DjTAs1Z1ysElEFtAN69AZg==", "dev": true, + "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.11.2", "@rspack/binding": "1.3.2", @@ -4248,6 +4474,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4878,31 +5105,107 @@ "url": "https://github.com/sponsors/gregberge" } }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "dev": true, - "optional": true, - "peer": true, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@turf/area": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.1.tgz", + "integrity": "sha512-9nSiwt4zB5QDMcSoTxF28WpK1f741MNKcpUJDiHVRX08CZ4qfGWGV9ZIPQ8TVEn5RE4LyYkFuQ47Z9pdEUZE9Q==", + "license": "MIT", "dependencies": { - "tslib": "^2.8.0" + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/@swc/helpers/node_modules/tslib": { + "node_modules/@turf/area/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "optional": true, - "peer": true + "license": "0BSD" }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "engines": { - "node": ">= 6" + "node_modules/@turf/bbox": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.1.tgz", + "integrity": "sha512-/IyMKoS7P9B0ch5PIlQ6gMfoE8gRr48+cSbzlyexvEjuDuaAV1VURjH1jAthS0ipFG8RrFxFJKnp7TLL1Skong==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/centroid": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.3.1.tgz", + "integrity": "sha512-hRnsDdVBH4pX9mAjYympb2q5W8TCMUMNEjcRrAF7HTCyjIuRmjJf8vUtlzf7TTn9RXbsvPc1vtm3kLw20Jm8DQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/centroid/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/helpers": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.1.tgz", + "integrity": "sha512-zkL34JVhi5XhsuMEO0MUTIIFEJ8yiW1InMu4hu/oRqamlY4mMoZql0viEmH6Dafh/p+zOl8OYvMJ3Vm3rFshgg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@turf/meta": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.1.tgz", + "integrity": "sha512-NWsfOE5RVtWpLQNkfOF/RrYvLRPwwruxhZUV0UFIzHqfiRJ50aO9Y6uLY4bwCUe2TumLJQSR4yaoA72Rmr2mnQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, "node_modules/@tybys/wasm-util": { @@ -5256,7 +5559,6 @@ "version": "3.7.7", "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -5308,6 +5610,15 @@ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -5386,10 +5697,28 @@ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==" }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, "node_modules/@types/markdown-it": { "version": "12.2.3", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "peer": true, "dependencies": { "@types/linkify-it": "*", "@types/mdurl": "*" @@ -5440,6 +5769,12 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, "node_modules/@types/prettier": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", @@ -5471,6 +5806,7 @@ "version": "18.3.20", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5562,6 +5898,15 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/tapable": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz", @@ -5593,6 +5938,7 @@ "version": "4.41.32", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz", "integrity": "sha512-cb+0ioil/7oz5//7tZUSwbrSAN/NWHrQylz5cW8G0dWTcF/g+/dSdMlKVZspBYuMAN1+WnwHrkxiRrLcwd0Heg==", + "peer": true, "dependencies": { "@types/node": "*", "@types/tapable": "^1", @@ -5701,6 +6047,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.28.0.tgz", "integrity": "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/types": "8.28.0", @@ -6411,7 +6758,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -6421,14 +6767,12 @@ "node_modules/@webassemblyjs/helper-numbers/node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "peer": true + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==" }, "node_modules/@webassemblyjs/helper-numbers/node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "peer": true + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==" }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.9.0", @@ -6561,6 +6905,12 @@ "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==" }, + "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==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -6577,6 +6927,7 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6656,6 +7007,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6815,6 +7167,7 @@ "version": "3.52.0", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.52.0.tgz", "integrity": "sha512-7dg0ADKs8AA89iYMZMe2sFDG0XK5PfqllKV9N+i3hKHm3vEtdhwz8AlXGm+/b0nJ6jKiaXsqci5LfVxNhtB+dA==", + "peer": true, "dependencies": { "@yr/monotone-cubic-spline": "^1.0.3", "svg.draggable.js": "^2.2.2", @@ -6881,6 +7234,12 @@ "node": ">=0.10.0" } }, + "node_modules/array-bounds": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-bounds/-/array-bounds-1.0.1.tgz", + "integrity": "sha512-8wdW3ZGk6UjMPJx/glyEt0sLzzwAE1bhToPsO1W2pbpR2gULyxe3BjSiuJFheP50T/GgODVPz2fuMUmIywt8cQ==", + "license": "MIT" + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -6896,6 +7255,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/array-flatten": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", @@ -6920,6 +7288,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-normalize": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array-normalize/-/array-normalize-1.1.4.tgz", + "integrity": "sha512-fCp0wKFLjvSPmCn4F5Tiw4M3lpMZoHlCjfcs7nNzuj3vqQQ1/a8cgB9DXcpDSn18c+coLnaW7rqfcYCvKbyJXg==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.0" + } + }, + "node_modules/array-range": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-range/-/array-range-1.0.1.tgz", + "integrity": "sha512-shdaI1zT3CVNL2hnx9c0JMc0ZogGaxDs5e85akgHWKYa0yVbIyp06Ind3dVkTj/uuFrzaHBOyqFzo+VV6aXgtA==", + "license": "MIT" + }, + "node_modules/array-rearrange": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array-rearrange/-/array-rearrange-2.2.2.tgz", + "integrity": "sha512-UfobP5N12Qm4Qu4fwLDIi2v6+wZsSf6snYSxAMeKhrh37YGnNWZPRmVEKc/2wfms53TLQnzfpG8wCx2Y/6NG1w==", + "license": "MIT" + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -7272,6 +7661,7 @@ "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", "deprecated": "babel-eslint is now @babel/eslint-parser. This package will no longer receive updates.", + "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "@babel/parser": "^7.7.0", @@ -7698,6 +8088,15 @@ "node": ">=0.10.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -7753,6 +8152,12 @@ "node": ">=8" } }, + "node_modules/binary-search-bounds": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", + "license": "MIT" + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -7762,6 +8167,28 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bit-twiddle": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", + "integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==", + "license": "MIT" + }, + "node_modules/bitmap-sdf": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", + "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -8038,6 +8465,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -8336,6 +8764,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-fit": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/canvas-fit/-/canvas-fit-1.5.0.tgz", + "integrity": "sha512-onIcjRpz69/Hx5bB5HGbYKUF2uC6QT6Gp+pfpGm3A7mPfcluSLV5v4Zu+oflDUwLdUw0rLIBhUbi0v8hM4FJQQ==", + "license": "MIT", + "dependencies": { + "element-size": "^1.1.1" + } + }, "node_modules/capture-exit": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz", @@ -8459,6 +8896,12 @@ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz", "integrity": "sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==" }, + "node_modules/clamp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", + "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==", + "license": "MIT" + }, "node_modules/class-utils": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", @@ -8582,6 +9025,24 @@ "color-string": "^1.6.0" } }, + "node_modules/color-alpha": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/color-alpha/-/color-alpha-1.0.4.tgz", + "integrity": "sha512-lr8/t5NPozTSqli+duAN+x+no/2WaKTeWvxhHGN+aXT6AJ8vPlzLa7UriyjWak0pSC2jHol9JgjBYnnHsGha9A==", + "license": "MIT", + "dependencies": { + "color-parse": "^1.3.8" + } + }, + "node_modules/color-alpha/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -8590,11 +9051,75 @@ "color-name": "1.1.3" } }, - "node_modules/color-name": { + "node_modules/color-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/color-id/-/color-id-1.1.0.tgz", + "integrity": "sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1" + } + }, + "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, + "node_modules/color-normalize": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/color-normalize/-/color-normalize-1.5.0.tgz", + "integrity": "sha512-rUT/HDXMr6RFffrR53oX3HGWkDOP9goSAQGBkUaAYKjOE2JxozccdGyufageWDlInRAjm/jYPrf/Y38oa+7obw==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1", + "color-rgba": "^2.1.1", + "dtype": "^2.0.0" + } + }, + "node_modules/color-normalize/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-normalize/node_modules/color-rgba": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.4.0.tgz", + "integrity": "sha512-Nti4qbzr/z2LbUWySr7H9dk3Rl7gZt7ihHAxlgT4Ho90EXWkjtkL1avTleu9yeGuqrt/chxTB6GKK8nZZ6V0+Q==", + "license": "MIT", + "dependencies": { + "color-parse": "^1.4.2", + "color-space": "^2.0.0" + } + }, + "node_modules/color-parse": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-2.0.0.tgz", + "integrity": "sha512-g2Z+QnWsdHLppAbrpcFWo629kLOnOPtpxYV69GCqm92gqSgyXbzlfyN3MXs0412fPBkFmiuS+rXposgBgBa6Kg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-rgba": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-3.0.0.tgz", + "integrity": "sha512-PPwZYkEY3M2THEHHV6Y95sGUie77S7X8v+h1r6LSAPF3/LL2xJ8duUXSrkic31Nzc4odPwHgUbiX/XuTYzQHQg==", + "license": "MIT", + "dependencies": { + "color-parse": "^2.0.0", + "color-space": "^2.0.0" + } + }, + "node_modules/color-space": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/color-space/-/color-space-2.3.2.tgz", + "integrity": "sha512-BcKnbOEsOarCwyoLstcoEztwT0IJxqqQkNwDuA3a65sICvvHL2yoeV13psoDFh5IuiOMnIOKdQDwB4Mk3BypiA==", + "license": "Unlicense" + }, "node_modules/color-string": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", @@ -8901,6 +9426,12 @@ "node": ">=10" } }, + "node_modules/country-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/country-regex/-/country-regex-1.1.0.tgz", + "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==", + "license": "MIT" + }, "node_modules/create-ecdh": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", @@ -9047,6 +9578,53 @@ "node": ">4" } }, + "node_modules/css-font": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-font/-/css-font-1.2.0.tgz", + "integrity": "sha512-V4U4Wps4dPDACJ4WpgofJ2RT5Yqwe1lEH6wlOOaIxMi0gTjdIijsc5FmxQlZ7ZZyKQkkutqqvULOp07l9c7ssA==", + "license": "MIT", + "dependencies": { + "css-font-size-keywords": "^1.0.0", + "css-font-stretch-keywords": "^1.0.1", + "css-font-style-keywords": "^1.0.1", + "css-font-weight-keywords": "^1.0.0", + "css-global-keywords": "^1.0.1", + "css-system-font-keywords": "^1.0.0", + "pick-by-alias": "^1.2.0", + "string-split-by": "^1.0.0", + "unquote": "^1.1.0" + } + }, + "node_modules/css-font-size-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz", + "integrity": "sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==", + "license": "MIT" + }, + "node_modules/css-font-stretch-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz", + "integrity": "sha512-KmugPO2BNqoyp9zmBIUGwt58UQSfyk1X5DbOlkb2pckDXFSAfjsD5wenb88fNrD6fvS+vu90a/tsPpb9vb0SLg==", + "license": "MIT" + }, + "node_modules/css-font-style-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz", + "integrity": "sha512-0Fn0aTpcDktnR1RzaBYorIxQily85M2KXRpzmxQPgh8pxUN9Fcn00I8u9I3grNr1QXVgCl9T5Imx0ZwKU973Vg==", + "license": "MIT" + }, + "node_modules/css-font-weight-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz", + "integrity": "sha512-5So8/NH+oDD+EzsnF4iaG4ZFHQ3vaViePkL1ZbZ5iC/KrsCY+WHq/lvOgrtmuOQ9pBBZ1ADGpaf+A4lj1Z9eYA==", + "license": "MIT" + }, + "node_modules/css-global-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-global-keywords/-/css-global-keywords-1.0.1.tgz", + "integrity": "sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==", + "license": "MIT" + }, "node_modules/css-has-pseudo": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-0.10.0.tgz", @@ -9158,6 +9736,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -9273,6 +9852,12 @@ "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==" }, + "node_modules/css-system-font-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", + "integrity": "sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==", + "license": "MIT" + }, "node_modules/css-tree": { "version": "1.0.0-alpha.37", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", @@ -9312,6 +9897,12 @@ "node": ">=0.10.0" } }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, "node_modules/cssdb": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-4.4.0.tgz", @@ -9587,6 +10178,12 @@ "node": ">=12" } }, + "node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, "node_modules/d3-axis": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", @@ -9621,6 +10218,12 @@ "node": ">=12" } }, + "node_modules/d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", + "license": "BSD-3-Clause" + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -9774,6 +10377,40 @@ "node": ">=12" } }, + "node_modules/d3-geo-projection": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-2.9.0.tgz", + "integrity": "sha512-ZULvK/zBn87of5rWAfFMc9mJOipeSo57O+BBitsKIXmU4rTVAnX1kSsJkE0R+TxY8pGNoM1nbyRRE7GYHhdOEQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "2", + "d3-array": "1", + "d3-geo": "^1.12.0", + "resolve": "^1.1.10" + }, + "bin": { + "geo2svg": "bin/geo2svg", + "geograticule": "bin/geograticule", + "geoproject": "bin/geoproject", + "geoquantize": "bin/geoquantize", + "geostitch": "bin/geostitch" + } + }, + "node_modules/d3-geo-projection/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/d3-geo-projection/node_modules/d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1" + } + }, "node_modules/d3-geo/node_modules/d3-array": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.3.tgz", @@ -9850,6 +10487,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "peer": true, "engines": { "node": ">=12" } @@ -10343,6 +10981,15 @@ "node": ">=0.10.0" } }, + "node_modules/defined": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/del": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", @@ -10425,6 +11072,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-kerning": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", + "integrity": "sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw==", + "license": "MIT" + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -10676,6 +11329,25 @@ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" }, + "node_modules/draw-svg-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/draw-svg-path/-/draw-svg-path-1.0.0.tgz", + "integrity": "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "~0.1.1", + "normalize-svg-path": "~0.1.0" + } + }, + "node_modules/dtype": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dtype/-/dtype-2.0.0.tgz", + "integrity": "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -10689,6 +11361,12 @@ "node": ">= 0.4" } }, + "node_modules/dup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dup/-/dup-1.0.0.tgz", + "integrity": "sha512-Bz5jxMMC0wgp23Zm15ip1x8IhYRqJvF3nFC0UInJUDkN1z4uNPk9jTnfCUJXbOGiQ1JbXLQsiV41Fb+HXcj5BA==", + "license": "MIT" + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -10705,6 +11383,12 @@ "stream-shift": "^1.0.0" } }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -10725,6 +11409,21 @@ "integrity": "sha512-pfEx5CBFAocOKNrc+i5fSvhDaI1Vr9R9aT5uX1IzM3hhdL6k649wfuUcdUd9EZnmbE1xdfA51CwqQ61CO3Xl3g==", "license": "ISC" }, + "node_modules/element-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/element-size/-/element-size-1.1.1.tgz", + "integrity": "sha512-eaN+GMOq/Q+BIWy0ybsgpcYImjGIdNLyjLFJU4XsLHXYQao5jCNb36GyN6C2qwmDDYSfIBmKpPpr4VnBdLCsPQ==", + "license": "MIT" + }, + "node_modules/elementary-circuits-directed-graph": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/elementary-circuits-directed-graph/-/elementary-circuits-directed-graph-1.3.1.tgz", + "integrity": "sha512-ZEiB5qkn2adYmpXGnJKkxT8uJHlW/mxmBpmeqawEHzPxh9HkLD4/1mFYX5l0On+f6rcPIt8/EWlRU2Vo3fX6dQ==", + "license": "MIT", + "dependencies": { + "strongly-connected-components": "^1.0.1" + } + }, "node_modules/elliptic": { "version": "6.6.1", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", @@ -11067,6 +11766,18 @@ "ext": "^1.1.2" } }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "license": "ISC", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -11089,14 +11800,14 @@ } }, "node_modules/escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" + "esutils": "^2.0.2" }, "bin": { "escodegen": "bin/escodegen.js", @@ -11131,6 +11842,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -11321,6 +12033,7 @@ "version": "2.31.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -11354,6 +12067,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.9.3.tgz", "integrity": "sha512-NrPUarxpFzGpQVXdVWkGttDD8WIxBuM/dRNw5kKFxrlGdjAJ3l8ma0LK5hsK5Qp79GBGM+HY1zYVbHqateTklA==", "dev": true, + "peer": true, "dependencies": { "@types/doctrine": "^0.0.9", "@typescript-eslint/utils": "^8.28.0", @@ -11524,6 +12238,7 @@ "version": "6.10.2", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -11557,6 +12272,7 @@ "version": "7.37.4", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz", "integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==", + "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -12516,6 +13232,25 @@ "node": ">=0.10.0" } }, + "node_modules/falafel": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.5.tgz", + "integrity": "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ==", + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "isarray": "^2.0.1" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/falafel/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12544,6 +13279,15 @@ "node": ">=8.6.0" } }, + "node_modules/fast-isnumeric": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.4.tgz", + "integrity": "sha512-1mM8qOr2LYz8zGaUdmiqRDiuue00Dxjgcb1NQR7TnhLVh6sQyngP9xvLo7Sl7LZpP/sk5eb+bcyWXw530NTBZw==", + "license": "MIT", + "dependencies": { + "is-string-blank": "^1.0.1" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -12741,6 +13485,15 @@ "integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==", "deprecated": "flatten is deprecated in favor of utility frameworks such as lodash." }, + "node_modules/flatten-vertex-data": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flatten-vertex-data/-/flatten-vertex-data-1.0.2.tgz", + "integrity": "sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw==", + "license": "MIT", + "dependencies": { + "dtype": "^2.0.0" + } + }, "node_modules/flush-write-stream": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", @@ -12777,6 +13530,24 @@ "jquery": "^3.4.0" } }, + "node_modules/font-atlas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/font-atlas/-/font-atlas-2.1.0.tgz", + "integrity": "sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg==", + "license": "MIT", + "dependencies": { + "css-font": "^1.0.0" + } + }, + "node_modules/font-measure": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/font-measure/-/font-measure-1.2.2.tgz", + "integrity": "sha512-mRLEpdrWzKe9hbfaF3Qpr06TAjquuBVP5cHy4b3hyeNdjc9i0PO6HniGsX5vjL5OWv7+Bd++NiooNpT/s8BvIA==", + "license": "MIT", + "dependencies": { + "css-font": "^1.2.0" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -13099,6 +13870,12 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -13107,6 +13884,12 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-canvas-context": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-canvas-context/-/get-canvas-context-1.0.2.tgz", + "integrity": "sha512-LnpfLf/TNzr9zVOGiIY6aKCz8EKuXmlYNV7CM2pUjBa/B+c2I15tS7KLySep75+FuerJdmArvJLcsAXWEy2H0A==", + "license": "MIT" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -13205,6 +13988,58 @@ "node": ">=0.10.0" } }, + "node_modules/gl-mat4": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gl-mat4/-/gl-mat4-1.2.0.tgz", + "integrity": "sha512-sT5C0pwB1/e9G9AvAoLsoaJtbMGjfd/jfxo8jMCKqYYEnjZuFvqV5rehqar0538EmssjdDeiEWnKyBSTw7quoA==", + "license": "Zlib" + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/gl-text": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gl-text/-/gl-text-1.4.0.tgz", + "integrity": "sha512-o47+XBqLCj1efmuNyCHt7/UEJmB9l66ql7pnobD6p+sgmBUdzfMZXIF0zD2+KRfpd99DJN+QXdvTFAGCKCVSmQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.2", + "color-normalize": "^1.5.0", + "css-font": "^1.2.0", + "detect-kerning": "^2.1.2", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "font-atlas": "^2.1.0", + "font-measure": "^1.2.2", + "gl-util": "^3.1.2", + "is-plain-obj": "^1.1.0", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "parse-unit": "^1.0.1", + "pick-by-alias": "^1.2.0", + "regl": "^2.0.0", + "to-px": "^1.0.1", + "typedarray-pool": "^1.1.0" + } + }, + "node_modules/gl-util": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/gl-util/-/gl-util-3.1.3.tgz", + "integrity": "sha512-dvRTggw5MSkJnCbh74jZzSoTOGnVYK+Bt+Ckqm39CVcl6+zSsxqWk4lr5NKhkqXHL6qvZAU9h17ZF8mIskY9mA==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1", + "is-firefox": "^1.0.3", + "is-plain-obj": "^1.1.0", + "number-is-integer": "^1.0.1", + "object-assign": "^4.1.0", + "pick-by-alias": "^1.2.0", + "weak-map": "^1.0.5" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -13238,8 +14073,7 @@ "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "peer": true + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" }, "node_modules/global-modules": { "version": "2.0.0", @@ -13311,6 +14145,207 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glsl-inject-defines": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/glsl-inject-defines/-/glsl-inject-defines-1.0.3.tgz", + "integrity": "sha512-W49jIhuDtF6w+7wCMcClk27a2hq8znvHtlGnrYkSWEr8tHe9eA2dcnohlcAmxLYBSpSSdzOkRdyPTrx9fw49+A==", + "license": "MIT", + "dependencies": { + "glsl-token-inject-block": "^1.0.0", + "glsl-token-string": "^1.0.1", + "glsl-tokenizer": "^2.0.2" + } + }, + "node_modules/glsl-resolve": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/glsl-resolve/-/glsl-resolve-0.0.1.tgz", + "integrity": "sha512-xxFNsfnhZTK9NBhzJjSBGX6IOqYpvBHxxmo+4vapiljyGNCY0Bekzn0firQkQrazK59c1hYxMDxYS8MDlhw4gA==", + "license": "MIT", + "dependencies": { + "resolve": "^0.6.1", + "xtend": "^2.1.2" + } + }, + "node_modules/glsl-resolve/node_modules/resolve": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz", + "integrity": "sha512-UHBY3viPlJKf85YijDUcikKX6tmF4SokIDp518ZDVT92JNDcG5uKIthaT/owt3Sar0lwtOafsQuwrg22/v2Dwg==", + "license": "MIT" + }, + "node_modules/glsl-resolve/node_modules/xtend": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", + "integrity": "sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/glsl-token-assignments": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-assignments/-/glsl-token-assignments-2.0.2.tgz", + "integrity": "sha512-OwXrxixCyHzzA0U2g4btSNAyB2Dx8XrztY5aVUCjRSh4/D0WoJn8Qdps7Xub3sz6zE73W3szLrmWtQ7QMpeHEQ==", + "license": "MIT" + }, + "node_modules/glsl-token-defines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-defines/-/glsl-token-defines-1.0.0.tgz", + "integrity": "sha512-Vb5QMVeLjmOwvvOJuPNg3vnRlffscq2/qvIuTpMzuO/7s5kT+63iL6Dfo2FYLWbzuiycWpbC0/KV0biqFwHxaQ==", + "license": "MIT", + "dependencies": { + "glsl-tokenizer": "^2.0.0" + } + }, + "node_modules/glsl-token-depth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-depth/-/glsl-token-depth-1.1.2.tgz", + "integrity": "sha512-eQnIBLc7vFf8axF9aoi/xW37LSWd2hCQr/3sZui8aBJnksq9C7zMeUYHVJWMhFzXrBU7fgIqni4EhXVW4/krpg==", + "license": "MIT" + }, + "node_modules/glsl-token-descope": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-descope/-/glsl-token-descope-1.0.2.tgz", + "integrity": "sha512-kS2PTWkvi/YOeicVjXGgX5j7+8N7e56srNDEHDTVZ1dcESmbmpmgrnpjPcjxJjMxh56mSXYoFdZqb90gXkGjQw==", + "license": "MIT", + "dependencies": { + "glsl-token-assignments": "^2.0.0", + "glsl-token-depth": "^1.1.0", + "glsl-token-properties": "^1.0.0", + "glsl-token-scope": "^1.1.0" + } + }, + "node_modules/glsl-token-inject-block": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/glsl-token-inject-block/-/glsl-token-inject-block-1.1.0.tgz", + "integrity": "sha512-q/m+ukdUBuHCOtLhSr0uFb/qYQr4/oKrPSdIK2C4TD+qLaJvqM9wfXIF/OOBjuSA3pUoYHurVRNao6LTVVUPWA==", + "license": "MIT" + }, + "node_modules/glsl-token-properties": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-properties/-/glsl-token-properties-1.0.1.tgz", + "integrity": "sha512-dSeW1cOIzbuUoYH0y+nxzwK9S9O3wsjttkq5ij9ZGw0OS41BirKJzzH48VLm8qLg+au6b0sINxGC0IrGwtQUcA==", + "license": "MIT" + }, + "node_modules/glsl-token-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-scope/-/glsl-token-scope-1.1.2.tgz", + "integrity": "sha512-YKyOMk1B/tz9BwYUdfDoHvMIYTGtVv2vbDSLh94PT4+f87z21FVdou1KNKgF+nECBTo0fJ20dpm0B1vZB1Q03A==", + "license": "MIT" + }, + "node_modules/glsl-token-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-string/-/glsl-token-string-1.0.1.tgz", + "integrity": "sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==", + "license": "MIT" + }, + "node_modules/glsl-token-whitespace-trim": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-whitespace-trim/-/glsl-token-whitespace-trim-1.0.0.tgz", + "integrity": "sha512-ZJtsPut/aDaUdLUNtmBYhaCmhIjpKNg7IgZSfX5wFReMc2vnj8zok+gB/3Quqs0TsBSX/fGnqUUYZDqyuc2xLQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz", + "integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==", + "license": "MIT", + "dependencies": { + "through2": "^0.6.3" + } + }, + "node_modules/glsl-tokenizer/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/glsl-tokenizer/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", + "license": "MIT", + "dependencies": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "node_modules/glslify": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glslify/-/glslify-7.1.1.tgz", + "integrity": "sha512-bud98CJ6kGZcP9Yxcsi7Iz647wuDz3oN+IZsjCRi5X1PI7t/xPKeL0mOwXJjo+CRZMqvq0CkSJiywCcY7kVYog==", + "license": "MIT", + "dependencies": { + "bl": "^2.2.1", + "concat-stream": "^1.5.2", + "duplexify": "^3.4.5", + "falafel": "^2.1.0", + "from2": "^2.3.0", + "glsl-resolve": "0.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glslify-bundle": "^5.0.0", + "glslify-deps": "^1.2.5", + "minimist": "^1.2.5", + "resolve": "^1.1.5", + "stack-trace": "0.0.9", + "static-eval": "^2.0.5", + "through2": "^2.0.1", + "xtend": "^4.0.0" + }, + "bin": { + "glslify": "bin.js" + } + }, + "node_modules/glslify-bundle": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glslify-bundle/-/glslify-bundle-5.1.1.tgz", + "integrity": "sha512-plaAOQPv62M1r3OsWf2UbjN0hUYAB7Aph5bfH58VxJZJhloRNbxOL9tl/7H71K7OLJoSJ2ZqWOKk3ttQ6wy24A==", + "license": "MIT", + "dependencies": { + "glsl-inject-defines": "^1.0.1", + "glsl-token-defines": "^1.0.0", + "glsl-token-depth": "^1.1.1", + "glsl-token-descope": "^1.0.2", + "glsl-token-scope": "^1.1.1", + "glsl-token-string": "^1.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glsl-tokenizer": "^2.0.2", + "murmurhash-js": "^1.0.0", + "shallow-copy": "0.0.1" + } + }, + "node_modules/glslify-deps": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/glslify-deps/-/glslify-deps-1.3.2.tgz", + "integrity": "sha512-7S7IkHWygJRjcawveXQjRXLO2FTjijPDYC7QfZyAQanY+yGLCFHYnPtsGT9bdyHiwPTw/5a1m1M9hamT2aBpag==", + "license": "ISC", + "dependencies": { + "@choojs/findup": "^0.2.0", + "events": "^3.2.0", + "glsl-resolve": "0.0.1", + "glsl-tokenizer": "^2.0.0", + "graceful-fs": "^4.1.2", + "inherits": "^2.0.1", + "map-limit": "0.0.1", + "resolve": "^1.0.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -13333,6 +14368,12 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, "node_modules/growly": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", @@ -13396,6 +14437,24 @@ "node": ">=4" } }, + "node_modules/has-hover": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-hover/-/has-hover-1.0.1.tgz", + "integrity": "sha512-0G6w7LnlcpyDzpeGUTuT0CEw05+QlMuGVk1IHNAlHrGJITGodjZu3x8BNDUMfKJSZXNB2ZAclqc1bvrd+uUpfg==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/has-passive-events": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-passive-events/-/has-passive-events-1.0.0.tgz", + "integrity": "sha512-2vSj6IeIsgvsRMyeQ0JaCX5Q3lX4zMn5HpoVc7MEhQ6pv8Iq9rsXjsp+E5ZwaT7T0xhMT0KmU8gtt1EFVdbJiw==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -14396,6 +15455,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-browser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", + "integrity": "sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ==", + "license": "MIT" + }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -14582,7 +15647,28 @@ "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-firefox": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-firefox/-/is-firefox-1.0.3.tgz", + "integrity": "sha512-6Q9ITjvWIm0Xdqv+5U12wgOKEM2KoBw4Y926m0OFkvlCxnbG94HKAsVz8w3fWcfAS5YA2fJORXX1dLrkprCCxA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, "node_modules/is-fullwidth-code-point": { @@ -14629,6 +15715,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-iexplorer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-iexplorer/-/is-iexplorer-1.0.0.tgz", + "integrity": "sha512-YeLzceuwg3K6O0MLM3UyUUjKAlyULetwryFp1mHy1I5PfArK0AEqlfa+MR4gkJjcbuJXoDJCvXbyqZVf5CR2Sg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -14673,6 +15768,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-mobile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-4.0.0.tgz", + "integrity": "sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==", + "license": "MIT" + }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", @@ -14877,6 +15978,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-string-blank": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-string-blank/-/is-string-blank-1.0.1.tgz", + "integrity": "sha512-9H+ZBCVs3L9OYqv8nuUAzpcT9OTgMD1yAWrG7ihlnibdkbtB850heAmYWxHuXc4CHy4lKeK69tN+ny1K7gBIrw==", + "license": "MIT" + }, + "node_modules/is-svg-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-svg-path/-/is-svg-path-1.0.2.tgz", + "integrity": "sha512-Lj4vePmqpPR1ZnRctHv8ltSh1OrSxHkhUkd7wi+VQdcdP15/KvQFyk7LhNuM7ZW0EVbJz8kZLVmL9quLrfq4Kg==", + "license": "MIT" + }, "node_modules/is-symbol": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", @@ -15129,6 +16242,7 @@ "version": "26.6.0", "resolved": "https://registry.npmjs.org/jest/-/jest-26.6.0.tgz", "integrity": "sha512-jxTmrvuecVISvKFFhOkjsWRZV7sFqdSUAd1ajOKY+/QE/aLBVstsJ/dX8GczLzwiT6ZEwwmZqtCUHLHHQVzcfA==", + "peer": true, "dependencies": { "@jest/core": "^26.6.0", "import-local": "^3.0.2", @@ -17402,16 +18516,6 @@ "node": ">=8" } }, - "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/jquery": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", @@ -17589,6 +18693,12 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -17625,6 +18735,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyboard-key": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keyboard-key/-/keyboard-key-1.1.0.tgz", @@ -17748,18 +18864,6 @@ "node": ">=6" } }, - "node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -17973,6 +19077,24 @@ "node": ">=0.10.0" } }, + "node_modules/map-limit": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", + "integrity": "sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==", + "license": "MIT", + "dependencies": { + "once": "~1.3.0" + } + }, + "node_modules/map-limit/node_modules/once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/map-obj": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", @@ -17995,10 +19117,184 @@ "node": ">=0.10.0" } }, + "node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "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/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", + "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.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@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.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/maplibre-gl/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/maplibre-gl/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/maplibre-gl/node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/maplibre-gl/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, "node_modules/markdown-it": { "version": "12.3.2", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "~2.1.0", @@ -18038,6 +19334,15 @@ "node": ">= 0.4" } }, + "node_modules/math-log2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz", + "integrity": "sha512-9W0yGtkaMAkf74XGYVy4Dqw3YUMnTNB2eeiw9aQbUl4A3KmuCEHTt2DgAB07ENzOYAjsYSAYufkAq0Zd+jU7zA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -18181,7 +19486,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/memory-fs": { "version": "0.4.1", @@ -18360,9 +19666,13 @@ } }, "node_modules/minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/minimist-options": { "version": "4.1.0", @@ -18487,6 +19797,38 @@ "node": ">=10" } }, + "node_modules/mouse-change": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/mouse-change/-/mouse-change-1.4.0.tgz", + "integrity": "sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==", + "license": "MIT", + "dependencies": { + "mouse-event": "^1.0.0" + } + }, + "node_modules/mouse-event": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/mouse-event/-/mouse-event-1.0.5.tgz", + "integrity": "sha512-ItUxtL2IkeSKSp9cyaX2JLUuKk2uMoxBg4bbOWVd29+CskYJR9BGsUqtXenNzKbnDshvupjUewDIYVrOB6NmGw==", + "license": "MIT" + }, + "node_modules/mouse-event-offset": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mouse-event-offset/-/mouse-event-offset-3.0.2.tgz", + "integrity": "sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==", + "license": "MIT" + }, + "node_modules/mouse-wheel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mouse-wheel/-/mouse-wheel-1.2.0.tgz", + "integrity": "sha512-+OfYBiUOCTWcTECES49neZwL5AoGkXE+lFjIvzwNCnYRlso+EnfvovcBxGoyQ0yQt806eSPjS675K0EwWknXmw==", + "license": "MIT", + "dependencies": { + "right-now": "^1.0.0", + "signum": "^1.0.0", + "to-px": "^1.0.1" + } + }, "node_modules/move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -18553,6 +19895,12 @@ "resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz", "integrity": "sha512-cnAsSVxIDsYt0v7HmC0hWZFwwXSh+E6PgCrREDuN/EsjgLwA5XRmlMHhSiDPrt6HxY1gTivEa/Zh7GtODoLevQ==" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nan": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.16.0.tgz", @@ -18598,6 +19946,12 @@ "node": ">=0.10.0" } }, + "node_modules/native-promise-only": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", + "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", + "license": "MIT" + }, "node_modules/native-url": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/native-url/-/native-url-0.2.6.tgz", @@ -18611,6 +19965,32 @@ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, + "node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -18737,6 +20117,7 @@ "version": "4.33.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.33.0.tgz", "integrity": "sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg==", + "peer": true, "dependencies": { "@typescript-eslint/experimental-utils": "4.33.0", "@typescript-eslint/scope-manager": "4.33.0", @@ -18808,6 +20189,7 @@ "version": "4.33.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.33.0.tgz", "integrity": "sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA==", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "4.33.0", "@typescript-eslint/types": "4.33.0", @@ -19202,6 +20584,7 @@ "version": "7.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", + "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -19290,6 +20673,7 @@ "version": "5.10.0", "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-5.10.0.tgz", "integrity": "sha512-vcz32f+7TP+kvTUyMXZmCnNujBQZDNmcqPImw8b9PZ+16w1Qdm6ryRuYZYVaG9xRqqmAPr2Cs9FAX5gN+x/bjw==", + "peer": true, "dependencies": { "lodash": "^4.17.15", "string-natural-compare": "^3.0.1" @@ -19305,6 +20689,7 @@ "version": "24.7.0", "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.7.0.tgz", "integrity": "sha512-wUxdF2bAZiYSKBclsUMrYHH6WxiBreNjyDxbRv345TIvPeoCEgPNEn3Sa+ZrSqsf1Dl9SqqSREXMHExlMMu1DA==", + "peer": true, "dependencies": { "@typescript-eslint/experimental-utils": "^4.0.1" }, @@ -19325,6 +20710,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "peer": true, "engines": { "node": ">=10" }, @@ -19336,6 +20722,7 @@ "version": "3.10.2", "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-3.10.2.tgz", "integrity": "sha512-WAmOCt7EbF1XM8XfbCKAEzAPnShkNSwcIsAD2jHdsMUT9mZJPjLCG7pMzbcC8kK366NOuGip8HKLDC+Xk4yIdA==", + "peer": true, "dependencies": { "@typescript-eslint/experimental-utils": "^3.10.1" }, @@ -19835,6 +21222,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -20128,20 +21516,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/neo-react-semantic-ui-range/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/neo-react-semantic-ui-range/node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -20154,6 +21528,7 @@ "version": "4.44.2", "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.44.2.tgz", "integrity": "sha512-6KJVGlCxYdISyurpQ0IPTklv+DULv05rs2hseIXer6D7KrUicRDLFb4IUM1S6LUAKypPM/nSiVSuv8jHu1m3/Q==", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.9.0", "@webassemblyjs/helper-module-context": "1.9.0", @@ -20791,6 +22166,12 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-svg-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-0.1.0.tgz", + "integrity": "sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==", + "license": "MIT" + }, "node_modules/normalize-url": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-1.9.1.tgz", @@ -20840,6 +22221,18 @@ "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", "integrity": "sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg==" }, + "node_modules/number-is-integer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-integer/-/number-is-integer-1.0.1.tgz", + "integrity": "sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==", + "license": "MIT", + "dependencies": { + "is-finite": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nwsapi": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.4.tgz", @@ -21130,22 +22523,6 @@ "node": ">=4" } }, - "node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", @@ -21287,6 +22664,12 @@ "node": ">=6" } }, + "node_modules/parenthesis": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", + "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", + "license": "MIT" + }, "node_modules/parse-asn1": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.7.tgz", @@ -21367,6 +22750,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-rect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parse-rect/-/parse-rect-1.2.0.tgz", + "integrity": "sha512-4QZ6KYbnE6RTwg9E0HpLchUM9EZt6DnDxajFZZDSV4p/12ZJEvPO702DZpGvRYEPo00yKDys7jASi+/w7aO8LA==", + "license": "MIT", + "dependencies": { + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, + "node_modules/parse-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-unit/-/parse-unit-1.0.1.tgz", + "integrity": "sha512-hrqldJHokR3Qj88EIlV/kAyAi/G5R2+R56TBANxNMy0uPlYcttx0jnMW6Yx5KsKPSbC3KddM/7qQm3+0wEXKxg==", + "license": "MIT" + }, "node_modules/parse5": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", @@ -21459,6 +22863,19 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/pbkdf2": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", @@ -21488,6 +22905,12 @@ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, + "node_modules/pick-by-alias": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pick-by-alias/-/pick-by-alias-1.2.0.tgz", + "integrity": "sha512-ESj2+eBxhGrcA1azgHs7lARG5+5iLakc/6nlfbpjcLl00HuuUOIuORhYXN4D1HfvMSKuVtFQjAlnwi1JHEeDIw==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", @@ -21673,6 +23096,107 @@ "node": ">=4" } }, + "node_modules/plotly.js": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-3.3.0.tgz", + "integrity": "sha512-3PT9dW7IbIfN7JWGr4YxxFQnbN5MRaB36qIKF/eF0iC9l0/MuGSlMlgRgI7Uu8vYuGxX6AjLwsBBRYTPG7NFSA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@plotly/d3": "3.8.2", + "@plotly/d3-sankey": "0.7.2", + "@plotly/d3-sankey-circular": "0.33.1", + "@plotly/mapbox-gl": "1.13.4", + "@plotly/regl": "^2.1.2", + "@turf/area": "^7.1.0", + "@turf/bbox": "^7.1.0", + "@turf/centroid": "^7.1.0", + "base64-arraybuffer": "^1.0.2", + "canvas-fit": "^1.5.0", + "color-alpha": "1.0.4", + "color-normalize": "1.5.0", + "color-parse": "2.0.0", + "color-rgba": "3.0.0", + "country-regex": "^1.1.0", + "d3-force": "^1.2.1", + "d3-format": "^1.4.5", + "d3-geo": "^1.12.1", + "d3-geo-projection": "^2.9.0", + "d3-hierarchy": "^1.1.9", + "d3-interpolate": "^3.0.1", + "d3-time": "^1.1.0", + "d3-time-format": "^2.2.3", + "fast-isnumeric": "^1.1.4", + "gl-mat4": "^1.2.0", + "gl-text": "^1.4.0", + "has-hover": "^1.0.1", + "has-passive-events": "^1.0.0", + "is-mobile": "^4.0.0", + "maplibre-gl": "^4.7.1", + "mouse-change": "^1.4.0", + "mouse-event-offset": "^3.0.2", + "mouse-wheel": "^1.2.0", + "native-promise-only": "^0.8.1", + "parse-svg-path": "^0.1.2", + "point-in-polygon": "^1.1.0", + "polybooljs": "^1.2.2", + "probe-image-size": "^7.2.3", + "regl-error2d": "^2.0.12", + "regl-line2d": "^3.1.3", + "regl-scatter2d": "^3.3.1", + "regl-splom": "^1.0.14", + "strongly-connected-components": "^1.0.1", + "superscript-text": "^1.0.0", + "svg-path-sdf": "^1.1.3", + "tinycolor2": "^1.4.2", + "to-px": "1.0.1", + "topojson-client": "^3.1.0", + "webgl-context": "^2.2.0", + "world-calendars": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/plotly.js/node_modules/d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", + "license": "BSD-3-Clause" + }, + "node_modules/plotly.js/node_modules/d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "node_modules/plotly.js/node_modules/d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1" + } + }, + "node_modules/plotly.js/node_modules/d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", + "license": "BSD-3-Clause" + }, + "node_modules/plotly.js/node_modules/d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", + "license": "BSD-3-Clause" + }, "node_modules/pnp-webpack-plugin": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz", @@ -21684,6 +23208,18 @@ "node": ">=6" } }, + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, + "node_modules/polybooljs": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/polybooljs/-/polybooljs-1.2.2.tgz", + "integrity": "sha512-ziHW/02J0XuNuUtmidBc6GXE8YohYydp3DWPWXYsd7O721TjcmN+k6ezjdwkDqep+gnWnFY+yqZHvzElra2oCg==", + "license": "MIT" + }, "node_modules/portfinder": { "version": "1.0.32", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz", @@ -22938,13 +24474,11 @@ "node": ">=0.10.0" } }, - "node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "engines": { - "node": ">= 0.8.0" - } + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" }, "node_modules/prepend-http": { "version": "1.0.4", @@ -23023,6 +24557,17 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/probe-image-size": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", + "integrity": "sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==", + "license": "MIT", + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -23073,12 +24618,19 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, + "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==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -23247,6 +24799,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -23306,6 +24864,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -23600,6 +25159,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -23636,6 +25196,19 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "node_modules/react-plotly.js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-plotly.js/-/react-plotly.js-2.6.0.tgz", + "integrity": "sha512-g93xcyhAVCSt9kV1svqG1clAEdL6k3U+jjuSzfTV7owaSU9Go6Ph8bl25J+jKfKvIGAEYpe4qj++WHJuc9IaeA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "plotly.js": ">1.34.0", + "react": ">0.13.0" + } + }, "node_modules/react-popper": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", @@ -23654,6 +25227,7 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz", "integrity": "sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -24056,6 +25630,104 @@ "node": ">=6" } }, + "node_modules/regl": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz", + "integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==", + "license": "MIT" + }, + "node_modules/regl-error2d": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/regl-error2d/-/regl-error2d-2.0.12.tgz", + "integrity": "sha512-r7BUprZoPO9AbyqM5qlJesrSRkl+hZnVKWKsVp7YhOl/3RIpi4UDGASGJY0puQ96u5fBYw/OlqV24IGcgJ0McA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "color-normalize": "^1.5.0", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-line2d": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/regl-line2d/-/regl-line2d-3.1.3.tgz", + "integrity": "sha512-fkgzW+tTn4QUQLpFKsUIE0sgWdCmXAM3ctXcCgoGBZTSX5FE2A0M7aynz7nrZT5baaftLrk9te54B+MEq4QcSA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-find-index": "^1.0.2", + "array-normalize": "^1.1.4", + "color-normalize": "^1.5.0", + "earcut": "^2.1.5", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0" + } + }, + "node_modules/regl-scatter2d": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/regl-scatter2d/-/regl-scatter2d-3.3.1.tgz", + "integrity": "sha512-seOmMIVwaCwemSYz/y4WE0dbSO9svNFSqtTh5RE57I7PjGo3tcUYKtH0MTSoshcAsreoqN8HoCtnn8wfHXXfKQ==", + "license": "MIT", + "dependencies": { + "@plotly/point-cluster": "^3.1.9", + "array-range": "^1.0.1", + "array-rearrange": "^2.2.2", + "clamp": "^1.0.1", + "color-id": "^1.1.0", + "color-normalize": "^1.5.0", + "color-rgba": "^2.1.1", + "flatten-vertex-data": "^1.0.2", + "glslify": "^7.0.0", + "is-iexplorer": "^1.0.0", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-scatter2d/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/regl-scatter2d/node_modules/color-rgba": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.4.0.tgz", + "integrity": "sha512-Nti4qbzr/z2LbUWySr7H9dk3Rl7gZt7ihHAxlgT4Ho90EXWkjtkL1avTleu9yeGuqrt/chxTB6GKK8nZZ6V0+Q==", + "license": "MIT", + "dependencies": { + "color-parse": "^1.4.2", + "color-space": "^2.0.0" + } + }, + "node_modules/regl-splom": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/regl-splom/-/regl-splom-1.0.14.tgz", + "integrity": "sha512-OiLqjmPRYbd7kDlHC6/zDf6L8lxgDC65BhC8JirhP4ykrK4x22ZyS+BnY8EUinXKDeMgmpRwCvUmk7BK4Nweuw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-range": "^1.0.1", + "color-alpha": "^1.0.4", + "flatten-vertex-data": "^1.0.2", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "raf": "^3.4.1", + "regl-scatter2d": "^3.2.3" + } + }, "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", @@ -24198,6 +25870,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", @@ -24354,6 +26035,12 @@ "resolved": "https://registry.npmjs.org/rgba-regex/-/rgba-regex-1.0.0.tgz", "integrity": "sha512-zgn5OjNQXLUTdq8m17KdaicF6w89TZs8ZU8y0AYENIU6wG8GG6LLm0yLSiPY8DmaYmHdgRW8rnApjoT0fQRfMg==" }, + "node_modules/right-now": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", + "integrity": "sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==", + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -24386,6 +26073,7 @@ "version": "1.32.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.32.1.tgz", "integrity": "sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A==", + "peer": true, "dependencies": { "@types/estree": "*", "@types/node": "*", @@ -25183,6 +26871,12 @@ "sha.js": "bin.js" } }, + "node_modules/shallow-copy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", + "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==", + "license": "MIT" + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", @@ -25291,6 +26985,12 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/signum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/signum/-/signum-1.0.0.tgz", + "integrity": "sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==", + "license": "MIT" + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -25531,6 +27231,7 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "peer": true, "dependencies": { "debug": "^3.2.7", "eventsource": "^2.0.2", @@ -25741,6 +27442,14 @@ "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", "dev": true }, + "node_modules/stack-trace": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz", + "integrity": "sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ==", + "engines": { + "node": "*" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -25765,6 +27474,15 @@ "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" }, + "node_modules/static-eval": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", + "license": "MIT", + "dependencies": { + "escodegen": "^2.1.0" + } + }, "node_modules/static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", @@ -25826,6 +27544,30 @@ "xtend": "^4.0.0" } }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "license": "MIT", + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/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/stream-parser/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/stream-shift": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", @@ -25864,6 +27606,15 @@ "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==" }, + "node_modules/string-split-by": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", + "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", + "license": "MIT", + "dependencies": { + "parenthesis": "^3.1.5" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -26063,6 +27814,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strongly-connected-components": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strongly-connected-components/-/strongly-connected-components-1.0.1.tgz", + "integrity": "sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==", + "license": "MIT" + }, "node_modules/style-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", @@ -26105,6 +27862,27 @@ "node": ">=8" } }, + "node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "dependencies": { + "kdbush": "^3.0.0" + } + }, + "node_modules/supercluster/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC" + }, + "node_modules/superscript-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/superscript-text/-/superscript-text-1.0.0.tgz", + "integrity": "sha512-gwu8l5MtRZ6koO0icVTlmN5pm7Dhh1+Xpe9O4x6ObMAsW+3jPbW14d1DsBq1F4wiI+WOFjXF35pslgec/G8yCQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -26158,11 +27936,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, "node_modules/svg-parser": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" }, + "node_modules/svg-path-bounds": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/svg-path-bounds/-/svg-path-bounds-1.0.2.tgz", + "integrity": "sha512-H4/uAgLWrppIC0kHsb2/dWUYSmb4GE5UqH06uqWBcg6LBjX2fu0A8+JrO2/FJPZiSsNOKZAhyFFgsLTdYUvSqQ==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "^0.1.1", + "is-svg-path": "^1.0.1", + "normalize-svg-path": "^1.0.0", + "parse-svg-path": "^0.1.2" + } + }, + "node_modules/svg-path-bounds/node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, + "node_modules/svg-path-sdf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/svg-path-sdf/-/svg-path-sdf-1.1.3.tgz", + "integrity": "sha512-vJJjVq/R5lSr2KLfVXVAStktfcfa1pNFjFOgyJnzZFXlO/fDZ5DmM8FpnSKKzLPfEYTVeXuVBTHF296TpxuJVg==", + "license": "MIT", + "dependencies": { + "bitmap-sdf": "^1.0.0", + "draw-svg-path": "^1.0.0", + "is-svg-path": "^1.0.1", + "parse-svg-path": "^0.1.2", + "svg-path-bounds": "^1.0.1" + } + }, "node_modules/svg.draggable.js": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz", @@ -26503,7 +28321,6 @@ "version": "5.3.14", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -26537,7 +28354,6 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -26565,7 +28381,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -26576,14 +28391,12 @@ "node_modules/terser-webpack-plugin/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "peer": true + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/terser-webpack-plugin/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "peer": true, "engines": { "node": ">=8" } @@ -26592,7 +28405,6 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -26605,14 +28417,12 @@ "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "peer": true + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/terser-webpack-plugin/node_modules/schema-utils": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -26631,7 +28441,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -26640,7 +28449,6 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -26655,7 +28463,6 @@ "version": "5.39.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -26740,6 +28547,12 @@ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", @@ -26775,6 +28588,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -26791,6 +28605,12 @@ "node": "^18.0.0 || >=20.0.0" } }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -26801,6 +28621,12 @@ "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==" }, + "node_modules/to-float32": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-float32/-/to-float32-1.1.0.tgz", + "integrity": "sha512-keDnAusn/vc+R3iEiSDw8TOF7gPiTLdK1ArvWtYbJQiVfmRg6i/CAvbKq3uIS0vWroAC7ZecN3DjQKw3aSklUg==", + "license": "MIT" + }, "node_modules/to-object-path": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", @@ -26823,6 +28649,15 @@ "node": ">=0.10.0" } }, + "node_modules/to-px": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-px/-/to-px-1.0.1.tgz", + "integrity": "sha512-2y3LjBeIZYL19e5gczp14/uRWFDtDUErJPVN3VU9a7SJO+RjGRtYR47aMN2bZgGlxvW4ZcEz2ddUPVHXcMfuXw==", + "license": "MIT", + "dependencies": { + "parse-unit": "^1.0.1" + } + }, "node_modules/to-regex": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", @@ -26856,6 +28691,26 @@ "node": ">=0.6" } }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -27168,17 +29023,6 @@ "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" }, - "node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -27187,19 +29031,6 @@ "node": ">=4" } }, - "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -27287,6 +29118,16 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, + "node_modules/typedarray-pool": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", + "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.0", + "dup": "^1.0.0" + } + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -27304,6 +29145,7 @@ "version": "5.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -27586,6 +29428,12 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, + "node_modules/update-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-diff/-/update-diff-1.1.0.tgz", + "integrity": "sha512-rCiBPiHxZwT4+sBhEbChzpO5hYHjm91kScWgdHf4Qeafs6Ba7MBl+d9GlGv72bcTZQO0sLmtQS1pHSWoCLtN/A==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -27891,6 +29739,17 @@ "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==" }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -28198,6 +30057,21 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/weak-map": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", + "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==", + "license": "Apache-2.0" + }, + "node_modules/webgl-context": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", + "integrity": "sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q==", + "license": "MIT", + "dependencies": { + "get-canvas-context": "^1.0.1" + } + }, "node_modules/webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -28404,6 +30278,7 @@ "version": "3.11.1", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-3.11.1.tgz", "integrity": "sha512-u4R3mRzZkbxQVa+MBWi2uVpB5W59H3ekZAJsQlKUTdl7Elcah2EhygTPLmeFXybQkf9i2+L0kn7ik9SnXa6ihQ==", + "peer": true, "dependencies": { "ansi-html": "0.0.7", "bonjour": "^3.5.0", @@ -29098,7 +30973,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -29107,26 +30981,22 @@ "node_modules/webpack/node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "peer": true + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==" }, "node_modules/webpack/node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "peer": true + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==" }, "node_modules/webpack/node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "peer": true + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==" }, "node_modules/webpack/node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -29138,7 +31008,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -29147,7 +31016,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -29155,14 +31023,12 @@ "node_modules/webpack/node_modules/@webassemblyjs/utf8": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "peer": true + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==" }, "node_modules/webpack/node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -29178,7 +31044,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -29191,7 +31056,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -29203,7 +31067,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -29217,7 +31080,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -29227,7 +31089,6 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -29257,7 +31118,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -29269,7 +31129,6 @@ "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -29282,14 +31141,12 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/webpack/node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "peer": true, "engines": { "node": ">=6.11.5" } @@ -29299,7 +31156,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -29318,7 +31174,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "peer": true, "engines": { "node": ">=6" } @@ -29327,7 +31182,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -29340,7 +31194,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -29719,6 +31572,15 @@ "microevent.ts": "~0.1.1" } }, + "node_modules/world-calendars": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.4.tgz", + "integrity": "sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.0" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -29962,6 +31824,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/frontend/static/frontend/package.json b/src/frontend/static/frontend/package.json index b3c8a873..fd73478c 100644 --- a/src/frontend/static/frontend/package.json +++ b/src/frontend/static/frontend/package.json @@ -22,11 +22,6 @@ "@babel/preset-env": "^7.27.2", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", - "babel-loader": "^10.0.0", - "babel-plugin-react-compiler": "^19.1.0-rc.2", - "eslint-plugin-react-compiler": "^19.1.0-rc.2", - "react-compiler-runtime": "^19.1.0-rc.2", - "@eslint/compat": "^1.2.8", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.27.0", @@ -37,13 +32,17 @@ "@types/react-dom": "^18.3.5", "@types/react-virtualized": "^9.22.2", "@types/validator": "^13.12.3", + "babel-loader": "^10.0.0", + "babel-plugin-react-compiler": "^19.1.0-rc.2", "css-loader": "^7.1.2", "eslint": "^9.27.0", "eslint-plugin-jsdoc": "^50.6.9", "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-compiler": "^19.1.0-rc.2", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.0.0", "neostandard": "^0.12.1", + "react-compiler-runtime": "^19.1.0-rc.2", "style-loader": "^4.0.0", "ts-checker-rspack-plugin": "^1.1.1", "ts-loader": "^9.5.2", @@ -67,11 +66,13 @@ "ky": "^1.7.5", "lodash": "^4.17.21", "neo-react-semantic-ui-range": "^0.3.6", + "plotly.js": "^3.3.0", "react": "^18.3.1", "react-apexcharts": "^1.4.1", "react-avatar": "^5.0.3", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", + "react-plotly.js": "^2.6.0", "react-virtualized": "^9.22.6", "recharts": "^2.15.1", "semantic-ui-react": "^2.1.5", diff --git a/src/frontend/static/frontend/rspack.common.js b/src/frontend/static/frontend/rspack.common.js index df78ea52..e7f41f91 100644 --- a/src/frontend/static/frontend/rspack.common.js +++ b/src/frontend/static/frontend/rspack.common.js @@ -14,6 +14,7 @@ const PATHS = { export const common = { entry: { + differentialExpression: `${PATHS.src}/differential-expression.tsx`, base: `${PATHS.src}/base.tsx`, gem: `${PATHS.src}/gem.tsx`, main: `${PATHS.src}/index.tsx`, diff --git a/src/frontend/static/frontend/src/components/MainNavbar.tsx b/src/frontend/static/frontend/src/components/MainNavbar.tsx index a40b1e8f..508735f1 100644 --- a/src/frontend/static/frontend/src/components/MainNavbar.tsx +++ b/src/frontend/static/frontend/src/components/MainNavbar.tsx @@ -17,6 +17,7 @@ declare const urlInstitutions: string declare const urlCGDSPanel: string declare const urlAboutUs: string declare const urlOpenSource: string +declare const urlDifferentialExpression: string /** Component's Props */ interface LogInLogOutPanelProps { @@ -149,7 +150,7 @@ const LogInLogOutPanel = (props: LogInLogOutPanelProps) => { ) } -type ActiveItemOptions = 'home' | 'pipeline' | 'files' | 'cgds' | 'survival' | 'institutions' | 'about-us' | 'biomarkers' | 'open-source' +type ActiveItemOptions = 'home' | 'pipeline' | 'files' | 'cgds' | 'survival' | 'institutions' | 'about-us' | 'biomarkers' | 'open-source' | 'differential-expression' interface MainNavbarProps { activeItem?: ActiveItemOptions, @@ -230,6 +231,14 @@ const MainNavbar = (props: MainNavbarProps) => { as='a' href={urlBiomarkers} active={props.activeItem === 'biomarkers'} /> + + {/* Differential Expression panel */} + diff --git a/src/frontend/static/frontend/src/components/common/EditIcon.tsx b/src/frontend/static/frontend/src/components/common/EditIcon.tsx new file mode 100644 index 00000000..e4449128 --- /dev/null +++ b/src/frontend/static/frontend/src/components/common/EditIcon.tsx @@ -0,0 +1,29 @@ +import React, { useContext } from 'react' +import { Icon } from 'semantic-ui-react' +import { CurrentUserContext } from '../Base' +import { Nullable } from '../../utils/interfaces' + +interface Props { + editExperiment: () => void, + ownerId: Nullable, + disabled: boolean +} + +export const EditIcon = (props: Props) => { + const currentUser = useContext(CurrentUserContext) + + if (props.ownerId !== currentUser?.id) { + return <> + } + + return ( + + ) +} diff --git a/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionForm.tsx b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionForm.tsx new file mode 100644 index 00000000..3f7a83ef --- /dev/null +++ b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionForm.tsx @@ -0,0 +1,805 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Button, Form, Header, Icon, Label, PopupContentProps, Segment, SemanticShorthandItem } from 'semantic-ui-react' +import { SourceForm } from '../pipeline/SourceForm' +import { SingleRangeSlider } from 'neo-react-semantic-ui-range' +import { CustomAlertTypes, FileType, KySearchParams, Nullable, Source, SourceType } from '../../utils/interfaces' +import { cleanRef, getDefaultSource, getDjangoHeader, getFilenameFromSource, getFileSizeInMB, getInputFileCSVColumns } from '../../utils/util_functions' +import { DjangoCGDSStudy, DjangoNumberSamplesInCommonMrnaClinicalResult, DjangoNumberSamplesInCommonOneFrontResult, DjangoResponseCode, DjangoUserFile } from '../../utils/django_interfaces' +import { InfoPopup } from '../pipeline/experiment-result/gene-gem-details/InfoPopup' +import ky from 'ky' +import { MAX_FILE_SIZE_IN_MB_WARN } from '../../utils/constants' +import { intersection, isEqual } from 'lodash' +import { DifferentialExpressionInputClinicalAttribute } from './DifferentialExpressionInputClinicalAttribute' +import { DifferentialExpressionAnalysis } from './types' + +// Define the possible field names for number of samples +type NumberOfSamplesFields = 'numberOfSamplesMRNA' | 'numberOfSamplesClinical' + +declare const urlDifferentialExpressionSubmit: string +declare const urlGetCommonSamplesDiferentialExperiment: string +declare const urlGetCommonSamplesOneFrontDiferentialExperiment: string +declare const urlGetClinicalAttributes: string +declare const urlCGDSDatasetClinicalAttributes: string +declare const urlUpdateExperiment: string + +/** Available types of Sources for a DifferentialExpressionForm. */ +type SourceStateDifferentialExpression = 'clinicalSource' | 'mRNASource' + +/** Structure for the Feature Selection panel. */ +interface IDifferentialExpressionFormData { + /** mRNA source. */ + mRNASource: Source, + /** clinical source. */ + clinicalSource: Source, + /** Differential Expression name. */ + differentialExpressionDescription: string, + /** Differential Expression description. */ + differentialExpressionName: string, + /** Umbral de Percentil para el filtrado de genes de baja expresión. */ + thresholdPercentile: number, + /** Umbral de varianza para el filtrado de genes. */ + thresholdStd: number + /** top */ + top: number, + + numberOfSamplesMRNA: number, + numberOfSamplesClinical: number, + numberOfSamplesInCommon: number, + gettingCommonSamples: boolean, + clinicalAttribute: string, +} +interface IDifferentialExpressionForm extends IDifferentialExpressionFormData { + isEditing: boolean, + isLoading: boolean, + optionsClinicalAttributes: string[], +} +const cleanForm: IDifferentialExpressionForm = { + mRNASource: getDefaultSource(), + clinicalSource: getDefaultSource(), + isEditing: false, + differentialExpressionDescription: '', + differentialExpressionName: '', + clinicalAttribute: '', + thresholdPercentile: 0.15, + thresholdStd: 0.0001, + top: 100, + isLoading: false, + numberOfSamplesMRNA: 0, + numberOfSamplesClinical: 0, + numberOfSamplesInCommon: 0, + gettingCommonSamples: false, + optionsClinicalAttributes: [], +} +interface DifferentialExpressionFormProps { + updateAlert: (type: CustomAlertTypes, msg: string) => void, + experimentToEdit: Nullable + handleCleanExperimentToEdit: () => void; +} + +export const DifferentialExpressionForm = (props: DifferentialExpressionFormProps) => { + const [form, setForm] = useState(cleanForm) + const abortController = useRef(new AbortController()) + + /** + * Change the source state to submit a pipeline + * @param sourceType New selected Source + * @param sourceStateName Source's name in state object to update + */ + const handleChangeSourceType = (sourceType: SourceType, sourceStateName: SourceStateDifferentialExpression) => { + // Selects source to update + const source = form[sourceStateName] + // Change source type + source.type = sourceType + + // Resets all source values + source.selectedExistingFile = null + source.CGDSStudy = null + cleanRef(source.newUploadedFileRef) + // After update state + setForm(prevState => ({ + ...prevState, + [sourceStateName]: source, + })) + + updateSourceFilenamesAndCommonSamples() + } + + /** + * Selects a User's file as a source + * @param selectedFile Selected file as Source + * @param sourceStateName Source's name in state object to update + */ + const selectUploadedFile = (selectedFile: DjangoUserFile, sourceStateName: SourceStateDifferentialExpression) => { + // Selects source to update + const source = form[sourceStateName] + + source.type = SourceType.UPLOADED_DATASETS + source.selectedExistingFile = selectedFile + setForm(prevState => ({ + ...prevState, + [sourceStateName]: source, + })) + updateSourceFilenamesAndCommonSamples() + } + + /** + * Selects a CGDS Study as a source + * @param selectedStudy Selected Study as Source + * @param sourceStateName Source's name in state object to update + */ + const selectStudy = (selectedStudy: DjangoCGDSStudy, sourceStateName: SourceStateDifferentialExpression) => { + // Selects source to update + const source = form[sourceStateName] + + source.type = SourceType.CGDS + source.CGDSStudy = selectedStudy + setForm(prevState => ({ + ...prevState, + [sourceStateName]: source, + })) + updateSourceFilenamesAndCommonSamples() + } + + /** + * function to handle the update de select clinical attribute. + */ + const updateSelectClinicalAttribute = () => { + if (form.clinicalSource.newUploadedFileRef.current && form.clinicalSource.selectedExistingFile === null) { + getInputFileCSVColumns(form.clinicalSource.newUploadedFileRef.current.files[0]).then((clinicalHeadersColumnsNames) => { + setForm(prevState => ({ ...prevState, optionsClinicalAttributes: clinicalHeadersColumnsNames })) + }) + } else if (form.clinicalSource.selectedExistingFile?.id || form.clinicalSource.CGDSStudy?.id) { + const idToSearch = form.clinicalSource.selectedExistingFile?.id ?? form.clinicalSource.CGDSStudy?.id + const myHeaders = getDjangoHeader() + const url = form.clinicalSource.selectedExistingFile?.id ? urlGetClinicalAttributes + `${idToSearch}/` : urlCGDSDatasetClinicalAttributes + `${idToSearch}/` + ky.get(url, { timeout: 60000, headers: myHeaders, signal: abortController.current.signal }).then((response) => { + response.json().then((clinicalAttributes: string[]) => { + setForm(prevState => ({ ...prevState, optionsClinicalAttributes: clinicalAttributes })) + }).catch((err) => { + console.error('Error parsing JSON ->', err) + }) + }).catch((err) => { + if (!abortController.current.signal.aborted) { + console.error('Error getting clinical attributes', err) + } + }) + } + } + + /** + * Handles file input changes to set data to show in form + * IMPORTANT: this is necessary because the file inputs are uncontrolled components + * and doesn't trigger an update of the state fields + */ + const updateSourceFilenames = () => { + const updateClinicalAtt = getFilenameFromSource(form.clinicalSource) !== form.clinicalSource.filename + + setForm(prevState => ({ + ...prevState, + clinicalSource: { + ...prevState.clinicalSource, + filename: getFilenameFromSource(prevState.clinicalSource) + }, + mRNASource: { + ...prevState.mRNASource, + filename: getFilenameFromSource(prevState.mRNASource) + }, + + })) + + if (updateClinicalAtt) { + updateSelectClinicalAttribute() + } + } + + /** + * Callback when a new file is selected in the uncontrolled component + * (input type=file) + */ + const updateSourceFilenamesAndCommonSamples = () => { + updateSourceFilenames() + checkCommonSamples() + } + + /** + * Callback when a new file is selected in the uncontrolled component + * (input type=file) + */ + const selectNewFile = () => { + updateSourceFilenamesAndCommonSamples() + } + + /** + * change name or description of manual form + * @param value new value for input form + * @param name type of input to change + */ + const handleChangeForm = (value: string | number, name: keyof IDifferentialExpressionFormData) => { + setForm(prevState => ({ + ...prevState, + [name]: value, + })) + } + + /** + * Function to convert a number to scientific notation + * @param value Number to convert + * @param sigfigs Significant figures to use in the conversion + * @returns String in scientific notation + */ + function toScientific (value: string, sigfigs?: number): string { + const num = Number(String(value).trim().replace(',', '.')) + + if (!Number.isFinite(num)) { + throw new Error('Número inválido') + } + + // toExponential(n) usa n dígitos después del punto ⇒ sigfigs - 1 + return sigfigs && sigfigs > 0 + ? num.toExponential(sigfigs - 1) + : num.toExponential() + } + + /** + * Handles the submit when is editting an experiment + * It sends only the fields that can be edited: name and description + */ + const handleEditSubmit = () => { + setForm(prevState => ({ + ...prevState, + isLoading: true + })) + const myHeaders = getDjangoHeader() + + const body = { + name: form.differentialExpressionName, + description: form.differentialExpressionDescription + } + + ky.patch(urlUpdateExperiment + `/${props.experimentToEdit?.id}/`, { headers: myHeaders, json: body }).then((response) => { + response.json().then(() => { + props.updateAlert(CustomAlertTypes.SUCCESS, 'Differential Expression experiment updated successfully!') + setForm(cleanForm) + }).catch((err) => { + console.error('Error parsing JSON ->', err) + }) + }).catch((err) => { + console.error('Error to update experiment ->', err) + props.updateAlert(CustomAlertTypes.ERROR, 'Error to update Differential Expression experiment!') + }).finally(() => { + setForm(prevState => ({ + ...prevState, + isLoading: false + })) + }) + } + + const handleSubmit = () => { + setForm(prevState => ({ + ...prevState, + isLoading: true + })) + const myHeaders = getDjangoHeader() + + const body = { + name: form.differentialExpressionName, + description: form.differentialExpressionDescription, + clinicalType: form.clinicalSource.type, + mRNAType: form.mRNASource.type, + clinicalAttribute: form.clinicalAttribute, + thresholdPercentile: form.thresholdPercentile, + threshold: form.thresholdStd, + top: form.top, + clinicalCGDSStudyPk: form.clinicalSource.CGDSStudy?.id, + mRNACGDSStudyPk: form.mRNASource.CGDSStudy?.id, + clinicalExistingFilePk: form.clinicalSource.selectedExistingFile?.id, + mRNAExistingFilePk: form.mRNASource.selectedExistingFile?.id + } + ky.post(urlDifferentialExpressionSubmit, { headers: myHeaders, json: body }).then((response) => { + response.json().then(() => { + props.updateAlert(CustomAlertTypes.SUCCESS, 'Differential Expression experiment created successfully!') + setForm(cleanForm) + }).catch((err) => { + console.error('Error parsing JSON ->', err) + }) + }).catch((err) => { + console.error('Error getting users ->', err) + props.updateAlert(CustomAlertTypes.ERROR, 'Error creating Differential Expression experiment!') + }).finally(() => { + setForm(prevState => ({ + ...prevState, + isLoading: false + })) + }) + } + + /** + * Gets the id of the source hosted in backend to send to the service + * of number of samples in common + * @param source Source to get its id + * @returns Id of the source or null if it's not been selected yet + */ + const getIdInBackend = (source: Source): Nullable => { + if (source.type === SourceType.UPLOADED_DATASETS && source.selectedExistingFile !== null) { + return source.selectedExistingFile.id ?? null + } + + if (source.type === SourceType.CGDS && source.CGDSStudy !== null) { + return source.CGDSStudy.id ?? null + } + + return null + } + + /** + * General method to avoid duplicated code when the reading + * of a loaded user's file fails + * @param event Event of error + */ + const errorReadingFileInInput = (event) => { + resetAllNumberOfSamples() + console.error('Error reading user\'s file', event.target.error.name) + } + + /** + * Gets the number of samples in common between both selected datasets + * one in frontend, other in backend + * @param mRNASourceIsInBackend Flag to know which dataset is in frontend and backend + */ + const checkCommonSamplesOneFrontOneBack = (mRNASourceIsInBackend: boolean) => { + let sourceInFront: Source + let sourceFrontNumberOfSampleName: NumberOfSamplesFields, sourceBackNumberOfSampleName: NumberOfSamplesFields + let otherSourceId: number, otherSourceType: Nullable + let otherSourceFileType: FileType + const mRNASource = form.mRNASource + const clinicalSource = form.clinicalSource + + if (mRNASourceIsInBackend) { + // If mRNA is in backend, GEM is in frontend + const idInBackend = getIdInBackend(mRNASource) + + if (idInBackend === null) { + return + } + + sourceInFront = clinicalSource + sourceFrontNumberOfSampleName = 'numberOfSamplesClinical' + sourceBackNumberOfSampleName = 'numberOfSamplesMRNA' + otherSourceId = idInBackend + otherSourceType = mRNASource.type + otherSourceFileType = FileType.CLINICAL + } else { + const idInBackend = getIdInBackend(clinicalSource) + + if (idInBackend === null) { + return + } + + sourceInFront = mRNASource + sourceFrontNumberOfSampleName = 'numberOfSamplesMRNA' + sourceBackNumberOfSampleName = 'numberOfSamplesClinical' + otherSourceId = idInBackend + otherSourceType = clinicalSource.type + otherSourceFileType = FileType.MRNA + } + + // We need both datasets! + const sourceCurrentRef = sourceInFront.newUploadedFileRef.current + + if (!sourceCurrentRef || sourceCurrentRef.files.length === 0) { + resetAllNumberOfSamples() + return + } + + const fileSizeInMB = getFileSizeInMB(sourceInFront.newUploadedFileRef.current.files[0].size) + + if (fileSizeInMB < MAX_FILE_SIZE_IN_MB_WARN) { + const file = sourceInFront.newUploadedFileRef.current.files[0] + getInputFileCSVColumns(file).then((headersColumnsNames) => { + // Sets the Request's Headers + const myHeaders = getDjangoHeader() + + // Sends an array of headers to compare in server + const jsonData = { + headersColumnsNames, + otherSourceId, + otherSourceType, + otherSourceFileType + } + setForm(prevState => ({ ...prevState, gettingCommonSamples: true })) + ky.post(urlGetCommonSamplesOneFrontDiferentialExperiment, { json: jsonData, headers: myHeaders }).then((response) => { + response.json().then((jsonResponse: DjangoNumberSamplesInCommonOneFrontResult) => { + if (jsonResponse.status.code === DjangoResponseCode.SUCCESS) { + // For front Source subtracts 1 to not have in count the first column of the file + setForm(prevState => ({ + ...prevState, + gettingCommonSamples: false, + [sourceFrontNumberOfSampleName]: Math.max(headersColumnsNames.length - 1, 0), + [sourceBackNumberOfSampleName]: jsonResponse.data.number_samples_backend, + numberOfSamplesInCommon: jsonResponse.data.number_samples_in_common + })) + } + }).catch((err) => { + console.error('Error parsing JSON ->', err) + }) + }).catch((err) => { + console.error('Error getting user experiments', err) + }) + }).catch(errorReadingFileInInput).finally(() => { + setForm(prevState => ({ + ...prevState, + gettingCommonSamples: false + })) + }) + } + } + + /** + * Gets the number of samples in common between both selected datasets + * hosted in backend + */ + const checkCommonSamplesInBackend = () => { + const mRNASourceId = getIdInBackend(form.mRNASource) + const clinicalSourceId = getIdInBackend(form.clinicalSource) + + if (mRNASourceId !== null && clinicalSourceId !== null) { + const searchParams = { + mRNASourceId, + mRNASourceType: form.mRNASource.type, + clinicalSourceId, + clinicalSourceType: form.clinicalSource.type, + } + setForm(prevState => ({ ...prevState, gettingCommonSamples: true })) + + ky.get(urlGetCommonSamplesDiferentialExperiment, { signal: abortController.current.signal, searchParams: searchParams as KySearchParams }).then((response) => { + setForm(prevState => ({ + ...prevState, + gettingCommonSamples: false + })) + response.json().then((jsonResponse: DjangoNumberSamplesInCommonMrnaClinicalResult) => { + if (jsonResponse.status.code === DjangoResponseCode.SUCCESS) { + setForm(prevState => ({ + ...prevState, + numberOfSamplesMRNA: jsonResponse.data.number_samples_mrna, + numberOfSamplesClinical: jsonResponse.data.number_samples_clinical, + numberOfSamplesInCommon: jsonResponse.data.number_samples_in_common + })) + } + }).catch((err) => { + console.error('Error parsing JSON ->', err) + }) + }).catch((err) => { + if (!abortController.current.signal.aborted) { + setForm(prevState => ({ + ...prevState, + gettingCommonSamples: false + })) + } + + console.error('Error getting user experiments', err) + }).finally(() => { + setForm(prevState => ({ + ...prevState, + gettingCommonSamples: false + })) + }) + } + } + + /** + * Check if a Source is hosted in the backend + * @param source Source to check + * @returns True if the Source is hosted in the backend. False otherwise + */ + const isDatasetInBackend = (source: Source): boolean => { + return source.type === SourceType.UPLOADED_DATASETS || source.type === SourceType.CGDS + } + + /** + * Resets all the number of samples + */ + const resetAllNumberOfSamples = () => { + setForm(prevState => ({ + ...prevState, + numberOfSamplesMRNA: 0, + numberOfSamplesClinical: 0, + numberOfSamplesInCommon: 0 + }) + ) + } + + /** + * Checks if there are common samples between two selected sources + * to show in the new experiment form + */ + const checkCommonSamples = () => { + // It needs both sources! + if (form.mRNASource.type === SourceType.NONE || form.clinicalSource.type === SourceType.NONE) { + resetAllNumberOfSamples() + return + } + + const mRNASourceIsInBackend = isDatasetInBackend(form.mRNASource) + const clinicalSourceIsInBackend = isDatasetInBackend(form.clinicalSource) + // If both datasets are hosted in backend, checks in server + + if (mRNASourceIsInBackend && clinicalSourceIsInBackend) { + checkCommonSamplesInBackend() + } else if (mRNASourceIsInBackend || clinicalSourceIsInBackend) { + checkCommonSamplesOneFrontOneBack(mRNASourceIsInBackend) + } else { + checkCommonSamplesInFrontend() + } + } + + /** + * Gets the number of samples in common between both selected datasets + * loaded in HTML file inputs + */ + const checkCommonSamplesInFrontend = () => { + const mRNASource = form.mRNASource + const clinicalSource = form.clinicalSource + + // We need both datasets! + if (mRNASource.newUploadedFileRef.current.files.length === 0 || + clinicalSource.newUploadedFileRef.current.files.length === 0) { + resetAllNumberOfSamples() + return + } + + // Reads first file + const mRNAFile = mRNASource.newUploadedFileRef.current.files[0] + getInputFileCSVColumns(mRNAFile).then((mRNAHeadersColumnsNames) => { + // Reads second file + const clinicalFile = clinicalSource.newUploadedFileRef.current.files[0] + getInputFileCSVColumns(clinicalFile).then((clinicalHeadersColumnsNames) => { + // Gets length of sources and their intersection + // For mRNA and GEM removes first element to not have in count the first column of the file (the index) + mRNAHeadersColumnsNames.shift() + clinicalHeadersColumnsNames.shift() + + setForm(prevState => ({ + ...prevState, + numberOfSamplesMRNA: mRNAHeadersColumnsNames.length, + numberOfSamplesClinial: clinicalHeadersColumnsNames.length, + numberOfSamplesInCommon: intersection( + mRNAHeadersColumnsNames, + clinicalHeadersColumnsNames + ).length + })) + }).catch(errorReadingFileInInput) + }).catch(errorReadingFileInInput) + } + + /** + * Checks if the form can be submitted + * @returns True if the submit button is disabled. + */ + const submitDisabled = (form.differentialExpressionName.trim() === '' || + form.clinicalAttribute.trim() === '' || + isEqual(form.mRNASource, () => getDefaultSource()) || + isEqual(form.clinicalSource, () => getDefaultSource()) + ) && !(form.isEditing && form.differentialExpressionName.trim().length !== 0 && form.differentialExpressionDescription.trim().length !== 0) + + /** + * Use effect to handle when is editting + */ + useEffect(() => { + if (props.experimentToEdit) { + setForm(prevState => ({ + ...prevState, + ...cleanForm, + isEditing: true, + differentialExpressionName: props.experimentToEdit?.name as string, + differentialExpressionDescription: props.experimentToEdit?.description as string + })) + } + }, [props.experimentToEdit]) + + return ( + +
+ + New Differential Expression +
+
+ handleChangeForm(e.target.value, 'differentialExpressionName')} + type='text' + placeholder='Name' + className='diff--side--bar--container--item--margin' + value={form.differentialExpressionName} + icon='asterisk' + /> + + handleChangeForm(e.value ? e.value.toString() : '', 'differentialExpressionDescription')} + placeholder='Description' + className='diff--side--bar--container--item--margin' + value={form.differentialExpressionDescription} + /> + {/* mRNA SourceForm */} + + { + handleChangeSourceType(selectedSourceType, 'mRNASource') + }} + selectNewFile={selectNewFile} + selectUploadedFile={(selectedFile) => { + selectUploadedFile(selectedFile, 'mRNASource') + }} + selectStudy={(selectedStudy) => { + selectStudy(selectedStudy, 'mRNASource') + }} + /> + + {/* Clinical SourceForm */} + + { + handleChangeSourceType(selectedSourceType, 'clinicalSource') + }} + selectNewFile={selectNewFile} + selectUploadedFile={(selectedFile) => { + selectUploadedFile(selectedFile, 'clinicalSource') + }} + selectStudy={(selectedStudy) => { + selectStudy(selectedStudy, 'clinicalSource') + }} + /> + + + + + + handleChangeForm(value, 'clinicalAttribute')} + isEditing={form.isEditing} + /> + + {/* Coefficient threshold slider */}{/* Minimum Standard Deviation for Genes */} + + + + form.isEditing && handleChangeForm(value, 'thresholdPercentile')} + className='diff--side--bar--slider' + color='blue' + disabled={form.isEditing} + /> + + + + + + + + form.isEditing && handleChangeForm(value, 'thresholdStd')} + className='diff--side--bar--slider' + color='blue' + disabled={form.isEditing} + /> + + + + + + + + form.isEditing && handleChangeForm(value, 'top')} + className='diff--side--bar--slider' + color='blue' + disabled={form.isEditing} + /> + + + + + + + + +
+ ) +} + +/** + * LabelWithInfoPopup's props + */ +interface LabelWithInfoPopupProps { + labelText: string, + popupContent: SemanticShorthandItem, + centered?: boolean +} + +/** + * Renders an label with an info popup. It's defined here for simplicity and reusability + * @param props Component's props + * @returns Component + */ +const LabelWithInfoPopup = (props: LabelWithInfoPopupProps) => ( + +) diff --git a/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionInputClinicalAttribute.tsx b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionInputClinicalAttribute.tsx new file mode 100644 index 00000000..b1ff4dc7 --- /dev/null +++ b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionInputClinicalAttribute.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { Form } from 'semantic-ui-react' +import { InputLabel } from '../common/InputLabel' + +interface DifferentialExpressionInputClinicalAttributeProps { + /* List of clinical attributes available for selection */ + optionsClinicalAttributes: string[], + /* Currently selected clinical attribute */ + clinicalAttribute: string, + /* Callback when clinical attribute selection changes */ + onChange: (value: string) => void, + /* Whether the input is in editing mode */ + isEditing: boolean, +} + +/** + * Renders a Select to select a clinical attribute. + * @param props Component props. + * @returns Component. + */ +export const DifferentialExpressionInputClinicalAttribute = (props: DifferentialExpressionInputClinicalAttributeProps) => { + return ( + <> + + + ({ key: attr, text: attr, value: attr }))} + loading={false} + className='margin-bottom-2' + search + selectOnBlur={false} + clearable + value={props.clinicalAttribute} + onChange={(_, { value }) => { props.onChange(value as string) }} + placeholder='Clinical attribute to group by' + disabled={props.isEditing} + /> + + ) +} diff --git a/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionModalResults.tsx b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionModalResults.tsx new file mode 100644 index 00000000..dbbebce8 --- /dev/null +++ b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionModalResults.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { DifferentialExpressionAnalysis } from './types' +import { Icon, Modal, Tab, TabPane } from 'semantic-ui-react' +import { DifferentialExpressionModalResultsTableView } from './DifferentialExpressionModalResultsTableView' +import { DifferentialExpressionModalResultsVolcanoPlot } from './DifferentialExpressionModalResultsVolcanoPlot' + +interface DifferentialExpressionModalResultsProps { + /* Whether the results modal is open */ + isOpen: boolean + /* Differential expression analysis results to display */ + differentialExpressionAnalysis: DifferentialExpressionAnalysis | null + /* Callback to close the modal */ + closeModal: () => void +} + +/* Differential expression results modal component */ +export const DifferentialExpressionModalResults = (props: DifferentialExpressionModalResultsProps) => { + /* Tab panes for results modal */ + const panes = [ + { + menuItem: 'Table', + render: () => ( + + + + ) + }, + { + menuItem: 'Volcano Plot', + render: () => ( + + + + ) + }, + ] + + return ( + } + closeOnEscape={false} + closeOnDimmerClick={false} + closeOnDocumentClick={false} + className='space-modal large-modal' + onClose={props.closeModal} + > + <> + + Differential Expression Analysis Results - {props.differentialExpressionAnalysis?.name} + + + + + + + ) +} diff --git a/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionModalResultsTableView.tsx b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionModalResultsTableView.tsx new file mode 100644 index 00000000..884c3279 --- /dev/null +++ b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionModalResultsTableView.tsx @@ -0,0 +1,87 @@ +import React, { useMemo } from 'react' +import { Table, Icon, TableCell, Form, Button } from 'semantic-ui-react' +import { PaginatedTable } from '../common/PaginatedTable' +import { DiffExpExperimentDetail } from './types' +import { TableCellWithTitle } from '../common/TableCellWithTitle' +declare const urlDifferentialExpressionExperimentResults: string +declare const urlDownloadDifferentialExpressionResults: string + +interface DifferentialExpressionModalResultsProps { + differentialExpressionAnalysisId?: number; +} + +export const DifferentialExpressionModalResultsTableView = (props: DifferentialExpressionModalResultsProps) => { + const downloadUrl = useMemo(() => { + const searchParams = new URLSearchParams() + + return `${urlDownloadDifferentialExpressionResults}/${props.differentialExpressionAnalysisId}?${searchParams.toString()}` + }, [props.differentialExpressionAnalysisId]) + + const onDownload = () => { + window.open(downloadUrl, '_blank', 'noopener,noreferrer') + } + + return ( + <> + + headerTitle='Experiment results' + headers={[ + { name: 'Gene', serverCodeToSort: 'gene' }, + { name: 'adj_p_val', serverCodeToSort: 'adj_p_val' }, + { name: 'ave_expr', serverCodeToSort: 'ave_expr' }, + { name: 'b_statistic', serverCodeToSort: 'b_statistic' }, + { name: 'log_fc', serverCodeToSort: 'log_fc' }, + { name: 'is_significant', serverCodeToSort: 'is_significant' }, + { name: 'p_value', serverCodeToSort: 'p_value' }, + { name: 't_statistic', serverCodeToSort: 't_statistic' }, + ]} + defaultSortProp={{ sortField: 'created_at', sortOrderAscendant: false }} + customFilters={undefined} + showSearchInput + customElements={[ + + + + ]} + searchLabel='Gene' + searchPlaceholder='Search by Gene' + urlToRetrieveData={urlDifferentialExpressionExperimentResults + props.differentialExpressionAnalysisId + '/'} + updateWSKey='update_differential_expression_experiments' + mapFunction={(differentialExpressionAnalysis: DiffExpExperimentDetail) => ( + + + + {differentialExpressionAnalysis.ave_expr.toFixed(4)} + + {differentialExpressionAnalysis.b_statistic.toFixed(4)} + + {differentialExpressionAnalysis.log_fc.toFixed(4)} + + {differentialExpressionAnalysis.is_significant + ? ( + + ) + : ( + + )} + + {differentialExpressionAnalysis.p_value} + + {differentialExpressionAnalysis.t_statistic.toFixed(4)} + + + )} + /> + + ) +} diff --git a/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionModalResultsVolcanoPlot.tsx b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionModalResultsVolcanoPlot.tsx new file mode 100644 index 00000000..656c2112 --- /dev/null +++ b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionModalResultsVolcanoPlot.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from 'react' +import { VolcanoPlot } from './VolcanoPlot' +import ky from 'ky' +import { VolcanoPoint } from './types' +import { Form } from 'semantic-ui-react' + +declare const urlDifferentialExpressionVolcanoData: string + +/** DifferentialExpressionModalResultsVolcanoPlot props. */ +interface DifferentialExpressionModalResultsVolcanoPlotProps { + differentialExpressionAnalysisId?: number; +} + +/** + * Renders a Volcano plot. + * @param props Component props. + * @returns Component. + */ +export const DifferentialExpressionModalResultsVolcanoPlot = (props: DifferentialExpressionModalResultsVolcanoPlotProps) => { + const [volcanoPoints, setVolcanoPoints] = useState([]) + const [fcThreshold, setFcThreshold] = useState(1) + const [pThreshold, setPThreshold] = useState(0.05) + const [showThresholds, setShowThresholds] = useState(true) + + /* Fetch volcano plot data if analysis ID is provided */ + useEffect(() => { + const id = props.differentialExpressionAnalysisId + + if (!id) { + return + } + + const controller = new AbortController() + + const url = + `${urlDifferentialExpressionVolcanoData}/` + + `${props.differentialExpressionAnalysisId}/` + + ky.get(url, { retry: 5, signal: controller.signal }).then((response) => { + response.json().then((volcanoPointsResponse: VolcanoPoint[]) => { + setVolcanoPoints(volcanoPointsResponse) + }).catch((err) => { + console.error('Error parsing JSON ->', err) + }) + }).catch((err) => { + console.error('Error getting volcanoPoints ->', err) + }) + + return () => { + controller.abort() + } + }, [ + props.differentialExpressionAnalysisId + ]) + + return ( +
+

Volcano Plot

+ + {/* Selects */} +
+ + + setFcThreshold(Number(data.value))} + /> + + + setPThreshold(Number(data.value))} + /> + + setShowThresholds(Boolean(data.checked))} + /> + +
+ + {/* Plot */} + {volcanoPoints.length > 0 && ( + + )} +
+ ) +} diff --git a/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionPanel.tsx b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionPanel.tsx new file mode 100644 index 00000000..b0d5025e --- /dev/null +++ b/src/frontend/static/frontend/src/components/differential-expression/DifferentialExpressionPanel.tsx @@ -0,0 +1,386 @@ +import React, { useState } from 'react' +import { Base } from '../Base' +import { DifferentialExpressionForm } from './DifferentialExpressionForm' +import { Confirm, DropdownItemProps, Grid, Icon, Table, TableCell } from 'semantic-ui-react' +import { PaginatedTable, PaginationCustomFilter } from '../common/PaginatedTable' +import { TableCellWithTitle } from '../common/TableCellWithTitle' +import { Alert } from '../common/Alert' +import { ConfirmModal, CustomAlert, CustomAlertTypes, GenesColors, Nullable } from '../../utils/interfaces' +import { DifferentialExpressionAnalysis, DifferentialExpressionAnalysisExperimentState } from './types' +import { formatDateLocale, getDefaultAlertProps, getDefaultConfirmModal, getDjangoHeader, getExperimentStateObjDiffExperiment } from '../../utils/util_functions' +import { SourcePopup } from '../pipeline/all-experiments-view/SourcePopup' +import { PopupIcons } from '../common/PopupIcons' +import { DeleteButton } from '../common/DeleteButton' +import { SwitchPublicButton } from '../common/SwitchPublicButton' +import { StopExperimentButton } from '../pipeline/all-experiments-view/StopExperimentButton' +import ky from 'ky' +import { EditIcon } from '../common/EditIcon' +import { DifferentialExpressionModalResults } from './DifferentialExpressionModalResults' + +declare const urlDifferentialExpressionList:string +declare const urlDifferentialExpressionStop:string +declare const urlDeleteExperiment: string + +interface DiferentialExpressionPanelState { + alert: CustomAlert + modal: ConfirmModal + stoppingExperiment: boolean + experimentToEdit: Nullable + modalResult: { + differentialExpressionAnalysis: DifferentialExpressionAnalysis | null + isOpen: boolean + } +} + +/** + * Differential Expression Panel component + * @returns JSX.Element + */ +export const DiferentialExpressionPanel = () => { + const [state, setState] = useState({ + alert: getDefaultAlertProps(), + modal: getDefaultConfirmModal(), + stoppingExperiment: false, + experimentToEdit: null, + modalResult: { + differentialExpressionAnalysis: null, + isOpen: false + } + }) + + const handleEdit = (differentialExpressionAnalysis: DifferentialExpressionAnalysis) => { + setState(prevState => ({ + ...prevState, + experimentToEdit: differentialExpressionAnalysis + })) + } + + /** + * Reset the confirm modal, to be used again + */ + const handleCloseAlert = () => { + setState(prevState => ({ ...prevState, alert: { ...prevState.alert, isOpen: false } })) + } + + /** + * Updates the alert state to show a new alert + * @param type Type of alert + * @param msg Message to show in the alert + */ + const updateAlert = (type: CustomAlertTypes, msg: string) => { + setState(prevState => ({ + ...prevState, + alert: { + ...prevState.alert, + isOpen: true, + type, + message: msg, + } + + })) + } + + /** + * Generates default table's Filters. + * @returns Default object for table's Filters + */ + const getDefaultFilters = (): PaginationCustomFilter[] => { + const methodsOptions: DropdownItemProps[] = [{ id: 'LIMMA', name: 'Lima' }, { id: 'DESEQ', name: 'Deseq2' }].map((tag) => { + const id = tag.id + return { key: id, value: id, text: tag.name } + }) + + methodsOptions.unshift({ key: 'no_method', text: 'No method' }) + + return [ + { label: 'Method', keyForServer: 'tool', defaultValue: '', placeholder: 'Select an existing method', options: methodsOptions, width: 3 } + ] + } + + const openInferenceResult = (differentialExpressionAnalysis: DifferentialExpressionAnalysis) => { + setState(prevState => ({ + ...prevState, + modalResult: { differentialExpressionAnalysis, isOpen: true } + })) + } + + /** + * Stop experiment + * @param experimentId experiment to stop + */ + const confirmExperimentStop = (experimentId: number) => { + const myHeaders = getDjangoHeader() + + ky.get(urlDifferentialExpressionStop, { + headers: myHeaders, + searchParams: { experimentId } + }).then((response) => { + // If OK closes the modal + if (response.ok) { + updateAlert(CustomAlertTypes.SUCCESS, 'Differential Expression experiment stopped successfully!') + } else { + updateAlert(CustomAlertTypes.ERROR, 'Error stopping Differential Expression experiment!') + } + }).catch((err) => { + updateAlert(CustomAlertTypes.ERROR, 'Error stopping Differential Expression experiment!') + console.error('Error stopping FSExperiment ->', err) + }).finally(() => { + setState(prevState => ({ ...prevState, stoppingExperiment: false })) + }) + } + + /** + * Delete experiment + * @param differentialExpressionAnalysis experiment to delete + */ + const confirmExperimentDeletion = (differentialExpressionAnalysis: DifferentialExpressionAnalysis) => { + const myHeaders = getDjangoHeader() + + ky.delete(urlDeleteExperiment + `/${differentialExpressionAnalysis.id}/`, { + headers: myHeaders, + }).then((response) => { + if (response.ok) { + updateAlert(CustomAlertTypes.SUCCESS, 'Differential Expression experiment deleted successfully!') + } else { + updateAlert(CustomAlertTypes.ERROR, 'Error deleting Differential Expression experiment!') + } + }).catch((err) => { + updateAlert(CustomAlertTypes.ERROR, 'Error deleting Differential Expression experiment!') + console.error('Error deleting FSExperiment ->', err) + }) + } + + /** + * Reset the confirm modal, to be used again + */ + const handleCancelConfirmModalState = () => { + setState(prevState => ({ + ...prevState, + modal: getDefaultConfirmModal() + })) + } + + /** + * Changes confirm modal state + * @param setOption New state of option + * @param headerText Optional text of header in confirm modal, by default will be empty + * @param contentText optional text of content in confirm modal, by default will be empty + * @param onConfirm Modal onConfirm callback + */ + const handleChangeConfirmModalState = (setOption: boolean, headerText: string, contentText: string, onConfirm: () => void) => { + setState(prevState => ({ + ...prevState, + modal: { + ...prevState.modal, + confirmModal: setOption, + headerText, + contentText, + onConfirm + } + })) + } + + const handleCleanExperimentToEdit = () => { + setState(prevState => ({ + ...prevState, + experimentToEdit: null + })) + } + + return ( + + + + + + + + headerTitle='Differential Expressions Analyses' + headers={[ + { name: 'Name', serverCodeToSort: 'name', width: 2 }, + { name: 'Description', serverCodeToSort: 'description', width: 3 }, + { name: 'Method', serverCodeToSort: 'tool' }, + { name: 'Date', serverCodeToSort: 'created_at' }, + { name: 'State', serverCodeToSort: 'state', width: 1, textAlign: 'center' }, + { name: 'Sources' }, + { name: 'Public', width: 1 }, + { name: 'Actions', width: 2 } + ]} + defaultSortProp={{ sortField: 'created_at', sortOrderAscendant: false }} + customFilters={getDefaultFilters()} + showSearchInput + customElements={[ + /* + + */ + ]} + searchLabel='Name/Description' + searchPlaceholder='Search by name/description' + urlToRetrieveData={urlDifferentialExpressionList} + updateWSKey='update_differential_expression_experiments' + mapFunction={(differentialExpressionAnalysis: DifferentialExpressionAnalysis) => { + const isInProcess = differentialExpressionAnalysis.state === DifferentialExpressionAnalysisExperimentState.IN_PROCESS || + differentialExpressionAnalysis.state === DifferentialExpressionAnalysisExperimentState.WAITING_FOR_QUEUE + const experimentState = getExperimentStateObjDiffExperiment(differentialExpressionAnalysis.state as any) + + return ( + + + + + + + + + + {/* Download mRNA */} + <> + + + + + + + + { + differentialExpressionAnalysis.is_public + ? ( + + ) + : ( + + ) + } + + + {/* See results button */} + { + differentialExpressionAnalysis.state === DifferentialExpressionAnalysisExperimentState.COMPLETED && ( + { openInferenceResult(differentialExpressionAnalysis) }} + className='clickable' + color='blue' + title='See results' + /> + ) + } + {/* Edit button */} + handleEdit(differentialExpressionAnalysis)} + ownerId={differentialExpressionAnalysis.user.id} + disabled={differentialExpressionAnalysis.state === DifferentialExpressionAnalysisExperimentState.COMPLETED} + /> + + + {/* Stop button */} + { + isInProcess && ( + handleChangeConfirmModalState(true, 'Stop Experiment', 'Are you sure to stop experiment?', () => confirmExperimentStop(differentialExpressionAnalysis.id))} + ownerId={differentialExpressionAnalysis.user.id as number} + /> + ) + } + + {/* Delete button */} + {(!isInProcess && !differentialExpressionAnalysis.is_public) && ( + confirmExperimentDeletion(differentialExpressionAnalysis)} + ownerId={differentialExpressionAnalysis.user.id} + /> + )} + + {/* Public switch */} + + + )} + /> + + + ) + }} + /> + + + + handleCancelConfirmModalState()} + onConfirm={() => { + state.modal.onConfirm() + setState(prevState => ({ + ...prevState, + modal: { + ...prevState.modal, + confirmModal: false + } + })) + }} + /> + setState(prevState => ({ + ...prevState, + modalResult: { differentialExpressionAnalysis: null, isOpen: false } + }))} + /> + + ) +} diff --git a/src/frontend/static/frontend/src/components/differential-expression/VolcanoPlot.tsx b/src/frontend/static/frontend/src/components/differential-expression/VolcanoPlot.tsx new file mode 100644 index 00000000..f1a666c9 --- /dev/null +++ b/src/frontend/static/frontend/src/components/differential-expression/VolcanoPlot.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import Plot from 'react-plotly.js' + +type VolcanoPoint = { + id: string | number + label?: string + log2FC: number // X axis + pValue: number // p-value (we plot -log10(p)) +} + +interface VolcanoPlotProps { + /* Data points for the volcano plot */ + data: VolcanoPoint[] + /* log2FC threshold for significance */ + fcThreshold?: number + /* p-value threshold for significance */ + pThreshold?: number + /* Whether to show threshold lines on the plot */ + showThresholds: boolean +} + +/* Volcano plot component */ +export const VolcanoPlot = ({ + data, + fcThreshold = 1, + pThreshold = 0.05, + showThresholds +}: VolcanoPlotProps) => { + const transformP = (p: number) => -Math.log10(p) + + const significant = data.filter( + (d) => Math.abs(d.log2FC) >= fcThreshold && d.pValue <= pThreshold + ) + + const nonsignificant = data.filter( + (d) => !(Math.abs(d.log2FC) >= fcThreshold && d.pValue <= pThreshold) + ) + const pLineY = transformP(pThreshold) + + const thresholdShapes = showThresholds + ? [ + // p-value threshold (horizontal) + { + type: 'line', + xref: 'paper', + x0: 0, + x1: 1, + yref: 'y', + y0: pLineY, + y1: pLineY, + line: { dash: 'dash', width: 1 }, + }, + // +log2FC threshold + { + type: 'line', + xref: 'x', + x0: fcThreshold, + x1: fcThreshold, + yref: 'paper', + y0: 0, + y1: 1, + line: { dash: 'dash', width: 1 }, + }, + // -log2FC threshold + { + type: 'line', + xref: 'x', + x0: -fcThreshold, + x1: -fcThreshold, + yref: 'paper', + y0: 0, + y1: 1, + line: { dash: 'dash', width: 1 }, + }, + ] + : [] + + return ( + d.log2FC), + y: significant.map((d) => transformP(d.pValue)), + text: significant.map((d) => d.label ?? d.id), + mode: 'markers', + type: 'scattergl', + name: 'Significant', + marker: { size: 6 }, + hovertemplate: + 'Significant
' + + 'log2FC: %{x:.2f}
' + + '-log10(p): %{y:.2f}
' + + '%{text}' + + '', + }, + { + x: nonsignificant.map((d) => d.log2FC), + y: nonsignificant.map((d) => transformP(d.pValue)), + text: nonsignificant.map((d) => d.label ?? d.id), + mode: 'markers', + type: 'scattergl', + name: 'Not significant', + marker: { size: 4, opacity: 0.6 }, + hovertemplate: + 'Not significant
' + + 'log2FC: %{x:.2f}
' + + '-log10(p): %{y:.2f}
' + + '%{text}' + + '', + }, + ]} + layout={{ + title: 'Volcano Plot', + hovermode: 'closest', + showlegend: true, + margin: { l: 90, r: 40, t: 50, b: 70 }, + + // Axis labels (normal, not floating) + xaxis: { + title: { text: 'log2(Fold Change)', standoff: 20 }, + zeroline: false, + }, + yaxis: { + title: { text: '-log10(p-value)', standoff: 10 }, + zeroline: false, + }, + shapes: thresholdShapes, + }} + config={{ + responsive: true, + displayModeBar: true, + }} + style={{ width: '100%', height: '500px' }} + /> + ) +} diff --git a/src/frontend/static/frontend/src/components/differential-expression/types.ts b/src/frontend/static/frontend/src/components/differential-expression/types.ts new file mode 100644 index 00000000..0b7921cd --- /dev/null +++ b/src/frontend/static/frontend/src/components/differential-expression/types.ts @@ -0,0 +1,86 @@ +import { DjangoExperimentSource, DjangoUser } from '../../utils/django_interfaces' + +/** + * Possible states for experiment evaluation + */ +export enum DifferentialExpressionAnalysisExperimentState { + /* Finished successfully */ + COMPLETED = 1, + /* Finished with error */ + FINISHED_WITH_ERROR = 2, + /* Currently running */ + IN_PROCESS = 3, + /* Waiting in queue */ + WAITING_FOR_QUEUE = 4, + /* No samples in common between clinical and mRNA data */ + NO_SAMPLES_IN_COMMON = 5, + /* Manually stopping */ + STOPPING = 6, + /* Automatically stopped */ + STOPPED = 7, + /** Reached attempts limit */ + REACHED_ATTEMPTS_LIMIT = 8, + /* No features found */ + NO_FEATURES_FOUND = 9, + /* Empty dataset */ + EMPTY_DATASET = 10, + /* Operation timed out */ + TIMEOUT_EXCEEDED = 11, +} + +export interface DifferentialExpressionAnalysis { + /* Unique identifier */ + id: number; + /* Name */ + name: string; + /* Description */ + description: string; + /* Analysis */ + tool: string; + /* Owner */ + user: DjangoUser; + /* Source of clinical data */ + clinical_source: DjangoExperimentSource; + /* Source of mRNA data */ + mrna_source: DjangoExperimentSource; + /* Current */ + state: DifferentialExpressionAnalysisExperimentState; + /* Human readable state */ + state_display: string; + /* Creation date */ + created_at: string; + /* Is public */ + is_public: boolean; +} + +export interface DiffExpExperimentDetail { + /* Unique identifier */ + id: number; + /* Gene name */ + gene: string; + /* Average expression */ + ave_expr: number; + /* P-value */ + p_value: number; + /* Adjusted p-value */ + adj_p_val: number; + /* Log2 fold change */ + log_fc: number; + /* T statistic */ + t_statistic: number; + /* B statistic */ + b_statistic: number; + /* Is significant */ + is_significant: boolean; +} + +export type VolcanoPoint = { + /* Unique identifier */ + id: string; + /* Optional label */ + label: string; + /* Log2 fold change */ + log2FC: number; + /* P-value */ + pValue: number; +} diff --git a/src/frontend/static/frontend/src/components/pipeline/all-experiments-view/AllExperimentsView.tsx b/src/frontend/static/frontend/src/components/pipeline/all-experiments-view/AllExperimentsView.tsx index affc4616..8939a95a 100644 --- a/src/frontend/static/frontend/src/components/pipeline/all-experiments-view/AllExperimentsView.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/all-experiments-view/AllExperimentsView.tsx @@ -13,7 +13,7 @@ import { DeleteButton } from '../../common/DeleteButton' import { SharedInstitutions, SharedInstitutionsProps } from './SharedInstitutions' import { SwitchPublicButton } from '../../common/SwitchPublicButton' import { SharedUsers, SharedUsersProps } from './SharedUsers' -import { EditExperimentIcon } from './EditExperimentIcon' +import { EditIcon } from '../../common/EditIcon' import { PopupIcons } from '../../common/PopupIcons' declare const urlUserExperiments: string @@ -316,10 +316,10 @@ export class AllExperimentsView extends React.Component {/* Edit button */} - this.props.editExperiment(experiment)} ownerId={experiment.user.id} + disabled={experiment.state !== ExperimentState.COMPLETED} /> void, - experiment: DjangoExperiment, - ownerId: number, -} - -export const EditExperimentIcon = (props: Props) => { - const currentUser = useContext(CurrentUserContext) - - if (props.ownerId !== currentUser?.id) { - return <> - } - - return ( - props.editExperiment(props.experiment)} - disabled={props.experiment.state !== ExperimentState.COMPLETED} - /> - ) -} diff --git a/src/frontend/static/frontend/src/components/pipeline/all-experiments-view/SourcePopup.tsx b/src/frontend/static/frontend/src/components/pipeline/all-experiments-view/SourcePopup.tsx index caeafd61..0f110c51 100644 --- a/src/frontend/static/frontend/src/components/pipeline/all-experiments-view/SourcePopup.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/all-experiments-view/SourcePopup.tsx @@ -30,6 +30,11 @@ export const SourcePopup = (props: SourcePopupProps) => { const isUserFile = props.source.user_file !== null const datasetObj = props.source.user_file ?? props.source.cgds_dataset + // If there's no valid source, don't render anything + if (!datasetObj) { + return null + } + // Gets file's type description in plural to show the number of rows const datasetRowDescriptionInPlural = getFileRowDescriptionInPlural(datasetObj.file_type) diff --git a/src/frontend/static/frontend/src/css/differential-expression.css b/src/frontend/static/frontend/src/css/differential-expression.css new file mode 100644 index 00000000..b8c6d635 --- /dev/null +++ b/src/frontend/static/frontend/src/css/differential-expression.css @@ -0,0 +1,29 @@ +.diff--side--bar--container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.diff--side--bar--container--item--margin { + margin: 0.5rem 0; +} +.diff--side--container--bar--slider{ + padding: 1rem 0; +} +.diff--side--bar--slider input{ + width: 100%; + margin-top: 10px; + margin-bottom: 10px; +} +.large-modal { + min-height: 76% !important; + width: 88% !important; + padding: 1rem !important; +} + +.space-modal:nth-child(1) { + display: flex !important; + flex-direction: column; + justify-content: space-between; +} \ No newline at end of file diff --git a/src/frontend/static/frontend/src/differential-expression.tsx b/src/frontend/static/frontend/src/differential-expression.tsx new file mode 100644 index 00000000..e7f2be87 --- /dev/null +++ b/src/frontend/static/frontend/src/differential-expression.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import './css/differential-expression.css' +import { DiferentialExpressionPanel } from './components/differential-expression/DifferentialExpressionPanel' + +const container = document.getElementById('differential-expression-app') +const root = createRoot(container!) +root.render() diff --git a/src/frontend/static/frontend/src/utils/django_interfaces.ts b/src/frontend/static/frontend/src/utils/django_interfaces.ts index 30ae349c..891a6d36 100644 --- a/src/frontend/static/frontend/src/utils/django_interfaces.ts +++ b/src/frontend/static/frontend/src/utils/django_interfaces.ts @@ -243,12 +243,14 @@ interface DjangoUserFile extends DjangoSimpleUserFile { /** * Django Model api_service.ExperimentSource */ +// Todo: agregar opcional update_differential_expression_experiments interface DjangoExperimentSource { id?: number, user_file: DjangoSimpleUserFile, cgds_dataset: SourceSimpleCGDSDataset, number_of_rows: number, number_of_samples: number + extra_cgds_dataset?: SourceSimpleCGDSDataset; } /** @@ -355,6 +357,16 @@ interface DjangoSamplesInCommonResultJSON { number_samples_gem: number } +/** + * JSON structure of the service that returns the number of samples in common between + * two datasets (UserFiles or CGDSDataset) + */ +interface DjangoSamplesInCommonResultMrnaClinicalJSON { + number_samples_in_common: number, + number_samples_mrna: number, + number_samples_clinical: number +} + /** * JSON structure of the service that returns the number of samples in common between * two datasets (UserFiles or CGDSDataset) @@ -555,6 +567,13 @@ interface DjangoCommonResponse { status: DjangoResponseStatus } +/** + * Django samples in common service Response + */ +interface DjangoNumberSamplesInCommonMrnaClinicalResult extends DjangoCommonResponse { + data: DjangoSamplesInCommonResultMrnaClinicalJSON +} + /** * Django samples in common service Response */ @@ -719,5 +738,7 @@ export { DjangoInstitutionUser, DjangoInstitutionUserLimited, InstitutionUser, - DjangoUserSimple + DjangoUserSimple, + DjangoSamplesInCommonResultMrnaClinicalJSON, + DjangoNumberSamplesInCommonMrnaClinicalResult } diff --git a/src/frontend/static/frontend/src/utils/util_functions.ts b/src/frontend/static/frontend/src/utils/util_functions.ts index f09a9247..b46d4f1a 100644 --- a/src/frontend/static/frontend/src/utils/util_functions.ts +++ b/src/frontend/static/frontend/src/utils/util_functions.ts @@ -1,10 +1,11 @@ import React from 'react' -import { FileType, StateIconInfo, GEMImageAndLabelInfo, CorrelationType, ExperimentResultTableControl, GeneralTableControl, Source, SourceType, HandleChangesCallback, Nullable, SortField, MirDIPScoreClass, ScoreClassData, BinData, ExperimentRequestPrefix } from './interfaces' +import { FileType, StateIconInfo, GEMImageAndLabelInfo, CorrelationType, ExperimentResultTableControl, GeneralTableControl, Source, SourceType, HandleChangesCallback, Nullable, SortField, MirDIPScoreClass, ScoreClassData, BinData, ExperimentRequestPrefix, ConfirmModal, CustomAlert, CustomAlertTypes } from './interfaces' import { DropdownItemProps, InputOnChangeData } from 'semantic-ui-react' import { TagType, DjangoTag, ExperimentState, ExperimentType, CorrelationMethod, PValuesAdjustmentMethod, DjangoMRNAxGEMResultRow } from './django_interfaces' import dayjs from 'dayjs' import countBy from 'lodash/countBy' import { MAX_FILE_SIZE_IN_MB_ERROR } from './constants' +import { DifferentialExpressionAnalysisExperimentState } from '../components/differential-expression/types' /** User locale extracted from the browser. */ const USER_LOCALE: readonly string[] | string = navigator.languages !== undefined && navigator.languages.length > 0 @@ -235,6 +236,100 @@ const getExperimentStateObj = (state: ExperimentState): StateIconInfo => { return stateIcon } +/** + * Gets info about the state of a specific Experiment to display in the card + * @param state Experiment state + * @returns The corresponding info of the current experiment's state of a differential expressione + */ +const getExperimentStateObjDiffExperiment = (state: DifferentialExpressionAnalysisExperimentState): StateIconInfo => { + let stateIconDiffExp: StateIconInfo | null = null + + switch (state) { + case DifferentialExpressionAnalysisExperimentState.COMPLETED: + stateIconDiffExp = { + iconName: 'check', + color: 'green', + loading: false, + title: 'The analysis is complete' + } + break + case DifferentialExpressionAnalysisExperimentState.FINISHED_WITH_ERROR: + stateIconDiffExp = { + iconName: 'times', + color: 'red', + loading: false, + title: 'The analysis has finished with errors. Try again' + } + break + case DifferentialExpressionAnalysisExperimentState.WAITING_FOR_QUEUE: + stateIconDiffExp = { + iconName: 'wait', + color: 'yellow', + loading: false, + title: 'The process of this analysis will start soon' + } + break + case DifferentialExpressionAnalysisExperimentState.NO_SAMPLES_IN_COMMON: + stateIconDiffExp = { + iconName: 'user outline', + color: 'red', + loading: false, + title: 'Datasets don\'t have samples in common' + } + break + case DifferentialExpressionAnalysisExperimentState.IN_PROCESS: + stateIconDiffExp = { + iconName: 'sync alternate', + color: 'yellow', + loading: true, + title: 'The analysis is being processed' + } + break + case DifferentialExpressionAnalysisExperimentState.STOPPING: + stateIconDiffExp = { + iconName: 'stop', + color: 'red', + loading: false, + title: 'The analysis was stopped' + } + break + case DifferentialExpressionAnalysisExperimentState.REACHED_ATTEMPTS_LIMIT: + stateIconDiffExp = { + iconName: 'undo', + color: 'red', + loading: false, + title: 'The analysis has failed several times. Try changing some parameters and try again' + } + break + case DifferentialExpressionAnalysisExperimentState.TIMEOUT_EXCEEDED: + stateIconDiffExp = { + iconName: 'wait', + color: 'red', + loading: false, + title: 'The analysis has reached the timeout limit. Try changing some parameters and try again' + } + break + case DifferentialExpressionAnalysisExperimentState.NO_FEATURES_FOUND: + stateIconDiffExp = { + iconName: 'user outline', + color: 'red', + loading: false, + title: 'Datasets don\'t have samples in common' + } + break + default: + stateIconDiffExp = { + iconName: 'times', + color: 'red', + loading: false, + title: 'The analysis has finished with errors. Try again' + } + break + } + + return stateIconDiffExp +} + /** * Gets info about an experiment's type to display in a Label * @param GEMType GEM FileType or Experiment to return its description @@ -769,6 +864,32 @@ const getFileTypeName = (type: FileType): string => { return fileType } +/** + * Generates a default confirm modal structure + * @returns Default confirmModal object + */ +const getDefaultConfirmModal = (): ConfirmModal => { + return { + confirmModal: false, + headerText: '', + contentText: '', + onConfirm: () => console.log('DefaultConfirmModalFunction, this should change during cycle of component') + } +} + +/** + * Generates a default alert structure + * @returns Default the default Alert + */ +const getDefaultAlertProps = (): CustomAlert => { + return { + message: '', // This have to change during cycle of component + isOpen: false, + type: CustomAlertTypes.SUCCESS, + duration: 500 + } +} + export { getDjangoHeader, alertGeneralError, @@ -805,5 +926,8 @@ export { getScoreClassData, generateBinData, getFileTypeName, - getInputFileCSVFirstColumnAllRows + getInputFileCSVFirstColumnAllRows, + getDefaultConfirmModal, + getDefaultAlertProps, + getExperimentStateObjDiffExperiment, } diff --git a/src/frontend/templates/frontend/base.html b/src/frontend/templates/frontend/base.html index 4d7a9fbc..6b4e0393 100644 --- a/src/frontend/templates/frontend/base.html +++ b/src/frontend/templates/frontend/base.html @@ -54,6 +54,7 @@ const urlSurvival = "{% url 'survival' %}" {# URL to the Survival Analysis Panel #} const urlBiomarkers = "{% url 'biomarkers' %}" {# URL to the Biomarkers Panel #} const urlAboutUs = "{% url 'about_us' %}" {# URL to About us page #} + const urlDifferentialExpression = "{% url 'differential_expression' %}" {# URL to About us page #} const urlOpenSource = "{% url 'open_source' %}" {# URL to About us page #} const urlClinicalSourceUserFileCRUD = "{% url 'clinical_source_user_file' %}" const urlSearchUser = "{% url 'search_user' %}" {# URL to search user view #} diff --git a/src/frontend/templates/frontend/differential-expression.html b/src/frontend/templates/frontend/differential-expression.html new file mode 100644 index 00000000..e27f367e --- /dev/null +++ b/src/frontend/templates/frontend/differential-expression.html @@ -0,0 +1,34 @@ +{% extends 'frontend/base.html' %} +{% load static %} +{% load render_bundle from webpack_loader %} + +{% block title %}| Differential Expression{% endblock %} +{% block meta_description %}Perform differential gene expression analysis to identify significantly expressed genes between different conditions. Configure parameters, visualize results, and export comprehensive reports for your research.{% endblock %} + +{% block css %} + {% render_bundle 'differentialExpression' 'css' %} +{% endblock %} + +{% block content %} +
+{% endblock %} + +{% block js %} + + {% render_bundle 'differentialExpression' 'js' %} +{% endblock %} \ No newline at end of file diff --git a/src/frontend/urls.py b/src/frontend/urls.py index dd05e672..eefe99a1 100644 --- a/src/frontend/urls.py +++ b/src/frontend/urls.py @@ -8,6 +8,8 @@ path('survival', views.survival_action, name='survival'), path('about-us', views.about_us_action, name='about_us'), path('open-source', views.open_source, name='open_source'), - path('site-policy', views.terms_and_privacy_policy_action, name='site_policy') + path('site-policy', views.terms_and_privacy_policy_action, name='site_policy'), + path('biomarker', views.biomarker, name='biomarker'), + path('differential-expression', views.differential_expression, name='differential_expression') ] diff --git a/src/frontend/views.py b/src/frontend/views.py index dac426ec..7243b6c3 100644 --- a/src/frontend/views.py +++ b/src/frontend/views.py @@ -42,6 +42,23 @@ def survival_action(request): """Survival Analysis view""" return render(request, "frontend/survival.html") +@login_required +def biomarker(request): + """Biomarker view""" + return render(request, "frontend/biomarker.html") + +@login_required +def differential_expression(request): + """Differential expression experiment view""" + return render( + request, + "frontend/differential-expression.html", + { + 'maximum_number_of_open_tabs': settings.MAX_NUMBER_OF_OPEN_TABS, + 'threshold_to_consider_ordinal': settings.THRESHOLD_ORDINAL + } + ) + def open_source(request): """Open source view""" diff --git a/src/multiomics_intermediate/urls.py b/src/multiomics_intermediate/urls.py index e5029a6b..436b0a95 100644 --- a/src/multiomics_intermediate/urls.py +++ b/src/multiomics_intermediate/urls.py @@ -32,6 +32,7 @@ path('biomarkers/', include('biomarkers.urls')), path('statistical-props/', include('statistical_properties.urls')), path('feature-selection/', include('feature_selection.urls')), + path('differential-expression/', include('differential_expression.urls')), path('inference/', include('inferences.urls')), path('molecules/', include('molecules_details.urls')), path('admin/', admin.site.urls), diff --git a/src/statistical_properties/migrations/0018_alter_sourcedatastatisticalproperties_gem_normality_and_more.py b/src/statistical_properties/migrations/0018_alter_sourcedatastatisticalproperties_gem_normality_and_more.py new file mode 100644 index 00000000..c4e3bf98 --- /dev/null +++ b/src/statistical_properties/migrations/0018_alter_sourcedatastatisticalproperties_gem_normality_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.19 on 2026-01-14 21:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("api_service", "0062_alter_experiment_clinical_source_and_more"), + ("statistical_properties", "0017_alter_statisticalvalidation_state"), + ] + + operations = [ + migrations.AlterField( + model_name="sourcedatastatisticalproperties", + name="gem_normality", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="source_data_statistical_properties_as_gem_normality", + to="statistical_properties.normalitytest", + ), + ), + migrations.AlterField( + model_name="sourcedatastatisticalproperties", + name="gene_normality", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="source_data_statistical_properties_as_gene_normality", + to="statistical_properties.normalitytest", + ), + ), + migrations.AlterField( + model_name="statisticalvalidationsourceresult", + name="source", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="statistical_validation_source_results", + to="api_service.experimentsource", + ), + ), + ] diff --git a/src/user_files/views.py b/src/user_files/views.py index e1549386..e165efa5 100644 --- a/src/user_files/views.py +++ b/src/user_files/views.py @@ -43,7 +43,7 @@ def on_completion(self, uploaded_file: UploadedFile, request): def get_response_data(self, chunked_upload: ChunkedUpload, request): """Final response, returns the created UserFile object""" if self.raised_exception is None: - return { 'ok': True } + return {'ok': True} error_msg = self.raised_exception.detail['file_obj']['status']['message'] return { @@ -77,8 +77,8 @@ def get_own_or_as_admin_user_files(user: AbstractBaseUser): @return: User's Files """ return UserFile.objects.filter(Q(user=user) | ( - Q(institutions__institutionadministration__user=user) - & Q(institutions__institutionadministration__is_institution_admin=True) + Q(institutions__institutionadministration__user=user) + & Q(institutions__institutionadministration__is_institution_admin=True) )).distinct() @@ -113,25 +113,31 @@ def get_user_files(user: AbstractBaseUser, public_only: bool, private_only: bool return user_files_objects.filter(filter_condition).select_related('tag').distinct() + class UserFileHeaders(APIView): permission_classes = [permissions.IsAuthenticated] """REST endpoint: list for UserFile header. """ @staticmethod - def get(request, pk: int) -> QuerySet: + def get(request, pk: int): """ - Returns the User's files headers from DB - @param user: User to retrieve his Datasets - @param pk: Id from file - @return: File's headers + Returns the User's files headers from DB. + @param user: User to retrieve his Datasets. + @param pk: ID from file. + @return: File's headers. """ user = request.user user_file = get_an_user_file(user=user, user_file_pk=pk) + """ + + """ list_of_header = user_file.get_column_names() return Response(list_of_header) + class UserFileList(generics.ListAPIView): """REST endpoint: list for UserFile model. """ + def get_queryset(self): # Returns own Datasets if explicitly requested... visibility = self.request.GET.get('visibility') @@ -180,30 +186,32 @@ def get(request: HttpRequest, pk: Optional[int] = None): return response + class ToggleFilePublicView(APIView): """ - API endpoint to toggle the 'is_public' field of an a user file. + API endpoint to toggle the 'is_public' field of a UseFile. Only the owner of the user file can perform this action. """ permission_classes = [permissions.IsAuthenticated] - def post(self, request): + @staticmethod + def post(request): """ Toggle the 'is_public' field of the userFiles. """ data = request.data - - userFile_id = data.get('userFileId') - userFile = get_object_or_404(UserFile, id=userFile_id) - if userFile.user.id != request.user.id: + + user_file_id = data.get('userFileId') + user_file: UserFile = get_object_or_404(UserFile, id=user_file_id) + if user_file.user.id != request.user.id: return Response( {"error": "You do not have permission to modify this user file."}, status=status.HTTP_403_FORBIDDEN ) - userFile.is_public = not userFile.is_public - userFile.save(update_fields=['is_public']) + user_file.is_public = not user_file.is_public + user_file.save(update_fields=['is_public']) return Response( - {"id": userFile.id, "is_public": userFile.is_public} - ) \ No newline at end of file + {"id": user_file.pk, "is_public": user_file.is_public} + )