`, where `general_stats_table` is the ID.
- Table cells should look something like ``, where the `mqc-generalstats-Assigned` bit is the unique ID.
:::note
diff --git a/docs/markdown/usage/downstream.md b/docs/markdown/usage/downstream.md
index c1926960a6..ddf090ec50 100644
--- a/docs/markdown/usage/downstream.md
+++ b/docs/markdown/usage/downstream.md
@@ -331,7 +331,6 @@ Note that the format is unstable as of 1.29 may change in 1.30, where it will be
The `multiqc.parquet` file contains several different types of rows that can be distinguished by the `type` column:
1. **`run_metadata`**: Contains metadata about the MultiQC run, including:
-
- `creation_date`: Timestamp when the report was generated
- `modules`: JSON-encoded list of modules included in the report
- `data_sources`: JSON-encoded information about the data source files
@@ -339,7 +338,6 @@ The `multiqc.parquet` file contains several different types of rows that can be
- `multiqc_version`: The version of MultiQC used
2. **`plot_input`**: Contains the serialized plot configuration and data:
-
- `anchor`: Unique identifier for the plot
- `plot_type`: Type of plot (e.g., "line", "bar", "heatmap", "violin", "scatter", "table")
- `plot_input_data`: JSON-encoded representation of the plot data and configuration
diff --git a/docs/markdown/usage/scripts.md b/docs/markdown/usage/scripts.md
index 1ba7d4907a..cc0320d0a2 100644
--- a/docs/markdown/usage/scripts.md
+++ b/docs/markdown/usage/scripts.md
@@ -377,7 +377,7 @@ Parameters:
- `plots_force_flat`: Use only flat plots (static images)
- `plots_force_interactive`: Use only interactive plots (in-browser Javascript)
- `strict`: Don't catch exceptions, run additional code checks to help development
-- `development`: Development mode. Do not compress and minimise JS, export uncompressed plot data
+- `development`: Development mode. Do not inline JS and CSS, export uncompressed plot data
- `make_pdf`: Create PDF report. Requires Pandoc to be installed
- `no_megaqc_upload`: Don't upload generated report to MegaQC, even if MegaQC options are found
- `quiet`: Only show log warnings
diff --git a/multiqc/base_module.py b/multiqc/base_module.py
index 7066ff95f7..653ada263a 100755
--- a/multiqc/base_module.py
+++ b/multiqc/base_module.py
@@ -6,6 +6,7 @@
import fnmatch
import io
import itertools
+import json
import logging
import mimetypes
import os
@@ -196,21 +197,17 @@ def _get_intro(self):
for doi in self.doi:
# Build the HTML link for the DOI
doi_links.append(
- f' {doi} '
+ f' {doi} '
)
- doi_html = 'DOI: {} '.format(
- "; ".join(doi_links)
- )
+ doi_html = 'DOI: {} '.format("; ".join(doi_links))
url_link = ""
if len(self.href) > 0:
url_links: List[str] = []
for url in self.href:
- url_links.append(f'{url.strip("/")} ')
- url_link = 'URL: {} '.format(
- "; ".join(url_links)
- )
+ url_links.append(f'{url.strip("/")} ')
+ url_link = "; ".join(url_links)
info_html = f"{self.info}{url_link}{doi_html}"
if not info_html.startswith("<"): # Assume markdown, convert to HTML
@@ -400,8 +397,26 @@ def add_section(
content: str = "",
autoformat: bool = True,
autoformat_type: str = "markdown",
+ statuses: Optional[Dict[Literal["pass", "warn", "fail"], List[str]]] = None,
):
- """Add a section to the module report output"""
+ """Add a section to the module report output
+
+ Args:
+ name: Title of the section. If not specified, the section will be untitled.
+ anchor: HTML anchor ID for the section. Auto-generated from `id` if not specified.
+ id: Section identifier for configuration. Auto-generated from `name` or module anchor if not specified.
+ description: Descriptive text shown at the top of the section, below the title.
+ comment: User-configurable comment text (can be set in MultiQC config).
+ helptext: Additional help text shown in a collapsible panel.
+ content_before_plot: HTML content to insert before any plot.
+ plot: A plot object or HTML string to display in the section.
+ content: HTML content to display in the section (shown after plot if both are provided).
+ autoformat: If True, format description/comment/helptext as markdown (default: True).
+ autoformat_type: Format type for autoformat, either "markdown" or "html" (default: "markdown").
+ statuses: Optional dict with keys "pass", "warn", "fail" containing lists of sample names.
+ When provided, displays a status progress bar showing sample pass/warn/fail counts.
+ Can be disabled globally or per-section via `section_status_checks` config.
+ """
if id is None and anchor is not None:
id = str(anchor)
@@ -468,6 +483,11 @@ def add_section(
comment = comment.strip()
helptext = helptext.strip()
+ # Generate status bar HTML if status data is provided
+ status_bar_html = ""
+ if statuses is not None and self._should_add_status_bar(str(id)):
+ status_bar_html = self._generate_status_bar_html(statuses, str(anchor))
+
section = Section(
name=name or "",
anchor=Anchor(anchor),
@@ -481,6 +501,7 @@ def add_section(
content_before_plot=content_before_plot,
content=content,
print_section=any([content_before_plot, plot, content]),
+ status_bar_html=status_bar_html,
)
if plot is not None:
@@ -494,6 +515,101 @@ def add_section(
# self.sections is passed into Jinja template:
self.sections.append(section)
+ def _should_add_status_bar(self, section_id: str) -> bool:
+ """
+ Check if status bar should be added based on config.section_status_checks.
+
+ Returns True if enabled (default), False if disabled.
+ """
+ # Check if there's a config for this module
+ module_config = config.section_status_checks.get(self.anchor)
+
+ if module_config is None:
+ # No config = enabled by default
+ return True
+
+ if isinstance(module_config, bool):
+ # Boolean config applies to all sections
+ return module_config
+
+ # Dict config - check for this specific section
+ return module_config.get(section_id, True) # Default True if section not specified
+
+ def _generate_status_bar_html(
+ self, status: Dict[Literal["pass", "warn", "fail"], List[str]], section_anchor: str
+ ) -> str:
+ """
+ Generate HTML for status bar with pass/warn/fail counts.
+
+ Args:
+ status: Dict with keys "pass", "warn", "fail" containing lists of sample names
+ section_anchor: The anchor ID for this section
+
+ Returns:
+ HTML string containing progress bar and embedded JSON data
+ """
+ # Count samples per status
+ pass_samples = status.get("pass", [])
+ warn_samples = status.get("warn", [])
+ fail_samples = status.get("fail", [])
+
+ total = len(pass_samples) + len(warn_samples) + len(fail_samples)
+ if total == 0:
+ return ""
+
+ # Calculate percentages
+ pass_pct = (len(pass_samples) / total) * 100
+ warn_pct = (len(warn_samples) / total) * 100
+ fail_pct = (len(fail_samples) / total) * 100
+
+ # Build sample status dict for JavaScript
+ sample_statuses = {}
+ for sample in pass_samples:
+ sample_statuses[sample] = "pass"
+ for sample in warn_samples:
+ sample_statuses[sample] = "warn"
+ for sample in fail_samples:
+ sample_statuses[sample] = "fail"
+
+ # Generate progress bar HTML
+ html = f'''
+
+
'''
+
+ if len(pass_samples) > 0:
+ html += f'''
+
'''
+
+ if len(warn_samples) > 0:
+ html += f'''
+
'''
+
+ if len(fail_samples) > 0:
+ html += f'''
+
'''
+
+ html += """
+
+
"""
+
+ # Add embedded JSON data for JavaScript
+ json_data = json.dumps([self.anchor.replace("-", "_"), section_anchor, sample_statuses])
+ html += f'\n '
+
+ return html
+
@staticmethod
def _clean_fastq_pair(r1: str, r2: str) -> Optional[str]:
"""
diff --git a/multiqc/config.py b/multiqc/config.py
index da988704f7..425de56fbd 100644
--- a/multiqc/config.py
+++ b/multiqc/config.py
@@ -69,11 +69,15 @@
show_analysis_paths: bool
show_analysis_time: bool
custom_logo: str
+custom_logo_dark: str
custom_logo_url: str
custom_logo_title: str
+custom_logo_width: int
custom_css_files: List[str]
simple_output: bool
template: str
+template_dark_mode: bool
+plot_font_family: Optional[str]
profile_runtime: bool
profile_memory: bool
pandoc_template: str
@@ -138,7 +142,6 @@
plots_force_interactive: bool
plots_flat_numseries: int
plots_defer_loading_numseries: int
-plot_theme: Optional[str]
num_datasets_plot_limit: int # DEPRECATED in favour of plots_number_of_series_to_defer_loading
lineplot_number_of_points_to_hide_markers: int
barplot_legend_on_bottom: bool
@@ -153,6 +156,7 @@
max_table_rows: int
max_configurable_table_columns: int
general_stats_columns: Dict[str, Dict]
+general_stats_helptext: str
table_columns_visible: Dict[str, Union[bool, Dict[str, bool]]]
table_columns_placement: Dict[str, Dict[str, float]]
table_columns_name: Dict[str, Union[str, Dict[str, str]]]
@@ -162,6 +166,7 @@
thousandsSep_format: str
remove_sections: List[str]
section_comments: Dict[str, str]
+section_status_checks: Dict[str, Union[bool, Dict[str, bool]]]
lint: bool # Deprecated since v1.17
strict: bool
development: bool
@@ -183,8 +188,8 @@
sample_names_replace_complete: bool
sample_names_rename: List[List[str]]
show_hide_buttons: List[str]
-show_hide_patterns: List[List[str]]
-show_hide_regex: List[bool]
+show_hide_patterns: List[Union[str, List[str]]]
+show_hide_regex: List[Union[str, bool]]
show_hide_mode: List[str]
highlight_patterns: List[str]
highlight_colors: List[str]
@@ -517,7 +522,7 @@ def _add_config(conf: Dict, conf_path=None):
log_filename_clean_extensions.append(v)
elif c == "extra_fn_clean_trim":
log_filename_clean_trimmings.append(v)
- elif c in ["custom_logo"] and v:
+ elif c in ["custom_logo", "custom_logo_dark"] and v:
# Resolve file paths - absolute or cwd, or relative to config file
fpath = v
if os.path.exists(v):
@@ -631,6 +636,20 @@ def load_show_hide(show_hide_file: Optional[Path] = None):
except AttributeError as e:
logger.error(f"Error loading show patterns file: {e}")
+ # Normalize show_hide_patterns to be List[List[str]]
+ # When loaded from YAML config, patterns may be strings instead of lists
+ for i in range(len(show_hide_patterns)):
+ pattern = show_hide_patterns[i]
+ if isinstance(pattern, str):
+ show_hide_patterns[i] = [pattern]
+
+ # Normalize show_hide_regex to be List[bool]
+ # When loaded from YAML config, regex flags may be missing or incorrect types
+ for i in range(len(show_hide_regex)):
+ regex_flag = show_hide_regex[i]
+ if not isinstance(regex_flag, bool):
+ show_hide_regex[i] = bool(regex_flag)
+
# Lists are not of the same length, pad or trim to the length of show_hide_patterns
for i in range(len(show_hide_buttons), len(show_hide_patterns)):
show_hide_buttons.append(
diff --git a/multiqc/config_defaults.yaml b/multiqc/config_defaults.yaml
index c8edbe5c7f..b28b2a089a 100644
--- a/multiqc/config_defaults.yaml
+++ b/multiqc/config_defaults.yaml
@@ -18,11 +18,15 @@ report_header_info: null
show_analysis_paths: True
show_analysis_time: True
custom_logo: null
+custom_logo_dark: null
custom_logo_url: null
custom_logo_title: null
+custom_logo_width: null
custom_css_files: []
simple_output: false
template: "default"
+template_dark_mode: true
+plot_font_family: null # Custom font family for plots (defaults to system font stack)
profile_runtime: false
profile_memory: false
pandoc_template: null
@@ -59,7 +63,7 @@ megaqc_url: null
megaqc_access_token: null
megaqc_timeout: 30
export_plots: false
-export_plots_timeout: 30
+export_plots_timeout: 60
make_report: true
make_pdf: false
@@ -91,7 +95,6 @@ plots_export_font_scale: 1.0 # set to 1.5 for bigger fonts
plots_force_interactive: false
plots_flat_numseries: 2000
plots_defer_loading_numseries: 100 # plot will require user to press button to render plot
-plot_theme: null # Plotly theme template - any registered Plotly theme name (e.g. "plotly", "plotly_white", "plotly_dark", "ggplot2", "seaborn", "simple_white", "none")
num_datasets_plot_limit: 100 # DEPRECATED in favour of plots_defer_loading_numseries
lineplot_number_of_points_to_hide_markers: 50 # sum of data points in all samples
barplot_legend_on_bottom: false # place legend at the bottom of the bar plot (not recommended)
@@ -106,6 +109,7 @@ collapse_tables: true
max_table_rows: 500
max_configurable_table_columns: 200
general_stats_columns: {}
+general_stats_helptext: null
table_columns_visible: {}
table_columns_placement: {}
table_columns_name: {}
@@ -146,6 +150,7 @@ decimalPoint_format: null
thousandsSep_format: null
remove_sections: []
section_comments: {}
+section_status_checks: {}
lint: False # Deprecated since v1.17
strict: False
development: False
@@ -367,6 +372,19 @@ fn_clean_exts:
- ".error.spl"
- ".error.grp"
- ".vgstats"
+ - "_mapq_table"
+ - "_strand_table"
+ - "_isize_table"
+ - "_dup_report"
+ - "_cv_table"
+ - "_covdist_all"
+ - "_covdist_q40"
+ - "_CpGRetention"
+ - "_CpHRetentionByReadPos"
+ - "_totalBaseConversionRate"
+ - "_totalReadConversionRate"
+ - ".sylphmpa"
+ - "_qual"
# Search patterns for grouping paired samples in general stats (e.g. fastq R1/R2)
# Affects all modules that support it (e.g. fastqc, cutadapt)
@@ -408,6 +426,7 @@ fn_clean_trim:
- ".phased"
- ".tar"
- "runs_"
+ - ".qc"
# Files to ignore when checking content with "content" and "content_re" search patterns.
fn_ignore_files:
@@ -605,6 +624,7 @@ module_order:
- xenome
- xengsort
- metaphlan
+ - sylphtax
- seqwho
- telseq
- ataqv
diff --git a/multiqc/core/ai.py b/multiqc/core/ai.py
index 428054355e..1b8cbc92e1 100644
--- a/multiqc/core/ai.py
+++ b/multiqc/core/ai.py
@@ -32,8 +32,11 @@
"o4-mini",
# Anthropic Claude 4 series (extended thinking models)
"claude-3-7-sonnet-latest",
+ "claude-sonnet-4-5",
"claude-sonnet-4-0",
+ "claude-haiku-4-5",
"claude-haiku-4-0",
+ "claude-opus-4-5",
"claude-opus-4-0",
}
@@ -109,7 +112,7 @@ def is_reasoning_model(model_name: str) -> bool:
"""
_EXAMPLE_DETAILED_SUMMARY = """\
-**Analysis**
+##### Analysis
- :sample[A1002]{.text-yellow} and :sample[A1003]{.text-yellow} groups (:span[11/13 samples]{.text-green}) show good quality metrics, with consistent GC content (38-39%), read lengths (125 bp), and acceptable levels of duplicates and valid pairs.
- :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} show severe quality issues:
@@ -126,7 +129,7 @@ def is_reasoning_model(model_name: str) -> bool:
- Overrepresented sequences analysis reveals adapter contamination in several samples, particularly in :sample[A1001.2003]{.text-yellow} (up to :span[35.82%]{.text-yellow} in Read 1).
- HiCUP analysis shows that most samples have acceptable levels of valid pairs, with :sample[A1003]{.text-green} group generally performing better than :sample[A1002]{.text-yellow} group.
-**Recommendations**
+##### Recommendations
- Remove :sample[A1001.2003]{.text-red} and :sample[A1200.2004]{.text-red} from further analysis due to severe quality issues.
- Investigate the cause of low valid pairs and passed Di-Tags in :sample[A1002-1007]{.text-yellow}. Consider removing it if the issue cannot be resolved.
@@ -179,19 +182,11 @@ def markdown_to_html(self, text: str) -> str:
# similarly, find and replace directives :sample[A1001.2003]{.text-red} -> \1",
+ r"\1 ",
html,
)
return html
- # def format_text(self) -> str:
- # """
- # Format to markdown to display in Seqera AI
- # """
- # summary = deanonymize_sample_names(self.summary)
- # detailed = deanonymize_sample_names(self.detailed_analysis) if self.detailed_analysis else None
- # return f"## Analysis\n{summary}" + (f"\n\n{detailed}" if detailed else "")
-
class InterpretationResponse(BaseModel):
interpretation: InterpretationOutput
@@ -450,7 +445,7 @@ class AnthropicClient(Client):
def __init__(self, api_key: str):
super().__init__(api_key)
self.model = (
- config.ai_model if config.ai_model and config.ai_model.startswith("claude") else "claude-sonnet-4-0"
+ config.ai_model if config.ai_model and config.ai_model.startswith("claude") else "claude-sonnet-4-5"
)
self.name = "anthropic"
self.title = "Anthropic"
@@ -534,7 +529,8 @@ def __init__(self, api_key: str):
creation_date = report.creation_date.strftime("%d %b %Y, %H:%M %Z")
self.chat_title = f"{(config.title + ': ' if config.title else '')}MultiQC report, created on {creation_date}"
self.tags = ["multiqc", f"multiqc_version:{config.version}"]
- self.model = config.ai_model or "claude-sonnet-4-0"
+ # Model is determined by Seqera endpoint, not specified in request
+ self.model = "seqera"
def max_tokens(self) -> int:
return 200000
@@ -1068,7 +1064,13 @@ def add_ai_summary_to_report():
return
# get_llm_client() will raise an exception if configuration is invalid when ai_summary=True
- client = get_llm_client()
+ try:
+ client = get_llm_client()
+ except RuntimeError as e:
+ logger.error(f"Failed to initialize AI client: {e}")
+ if config.strict:
+ raise
+ return
assert client is not None, "get_llm_client() should not return None when config.ai_summary is True"
report.ai_provider_id = client.name
diff --git a/multiqc/core/plot_data_store.py b/multiqc/core/plot_data_store.py
index 36e3fd3e10..e3c28ca8ae 100644
--- a/multiqc/core/plot_data_store.py
+++ b/multiqc/core/plot_data_store.py
@@ -24,38 +24,23 @@
_saved_anchors: Set[Anchor] = set()
# Keep track of metric column names
_metric_col_names: Set[ColumnKey] = set()
+# Buffer for batched parquet writes to avoid O(n²) read-concat-write behavior
+_pending_dataframes: List[pl.DataFrame] = []
+# Buffer for wide table dataframes (need special merging by sample)
+_pending_wide_tables: List[pl.DataFrame] = []
def wide_table_to_parquet(table_df: pl.DataFrame, metric_col_names: Set[ColumnKey]) -> None:
"""
- Merge wide-format table data with existing sample-based tables.
+ Buffer wide-format table data for later merging and writing.
- This function extracts table rows from the dataframe and merges them with
- the existing global cache of wide-format table data. This ensures all tables
- that have the same samples get combined into a single row per sample.
-
- The resulting table must have single row per sample.
+ This function buffers table data instead of immediately writing to avoid
+ O(n²) read-concat-write behavior. The actual merging by sample happens
+ in flush_to_parquet().
"""
- # Fix creation date
+ global _pending_wide_tables
table_df = fix_creation_date(table_df)
-
- existing_df = _read_or_create_df()
-
- # Get all rows that are table_row
- existing_table_rows = existing_df.filter(pl.col("type") == "table_row")
-
- # Merge existing and new tables, keeping one row per sample (defined by join_cols)
- if existing_table_rows.height > 0 and table_df.height > 0:
- new_df = existing_table_rows.join(table_df, on=["sample", "creation_date"], how="outer")
- all_cols = existing_table_rows.columns + [c for c in table_df.columns if c not in existing_table_rows.columns]
- new_df = new_df.select(all_cols)
- else:
- # If one of the dataframes is empty, just use diagonal concat
- new_df = pl.concat([existing_table_rows, table_df], how="diagonal")
-
- existing_other_rows_df = existing_df.filter(pl.col("type") != "table_row")
- new_df = pl.concat([existing_other_rows_df, new_df], how="diagonal")
- _write_parquet(new_df)
+ _pending_wide_tables.append(table_df)
def fix_creation_date(df: pl.DataFrame) -> pl.DataFrame:
@@ -68,14 +53,80 @@ def fix_creation_date(df: pl.DataFrame) -> pl.DataFrame:
def append_to_parquet(df: pl.DataFrame) -> None:
"""
- Save plot data to the parquet file.
+ Buffer plot data for later writing to the parquet file.
- This function adds/updates data for a specific plot in the file.
+ This function buffers data instead of immediately writing to avoid
+ O(n²) read-concat-write behavior when many plots are saved.
+ Call flush_to_parquet() to write all buffered data at once.
"""
+ global _pending_dataframes
df = fix_creation_date(df)
+ _pending_dataframes.append(df)
+
+
+def flush_to_parquet() -> None:
+ """
+ Write all buffered dataframes to the parquet file at once.
+
+ This should be called at the end of report generation to efficiently
+ write all accumulated plot data in a single operation.
+ """
+ global _pending_dataframes, _pending_wide_tables
+
+ if not _pending_dataframes and not _pending_wide_tables:
+ return
+
+ # Read existing data from file (if any)
existing_df = _read_or_create_df()
- df = pl.concat([existing_df, df], how="diagonal")
- _write_parquet(df)
+
+ # Start with existing non-table rows
+ existing_other_rows = existing_df.filter(pl.col("type") != "table_row") if not existing_df.is_empty() else None
+
+ # Process wide tables - merge all buffered wide tables by sample
+ merged_wide_tables: Optional[pl.DataFrame] = None
+ if _pending_wide_tables:
+ # Get existing table rows
+ existing_table_rows = existing_df.filter(pl.col("type") == "table_row") if not existing_df.is_empty() else None
+
+ # Start with existing table rows or first pending table
+ if existing_table_rows is not None and not existing_table_rows.is_empty():
+ merged_wide_tables = existing_table_rows
+ else:
+ merged_wide_tables = None
+
+ # Merge all pending wide tables
+ for table_df in _pending_wide_tables:
+ if merged_wide_tables is None:
+ merged_wide_tables = table_df
+ elif table_df.height > 0:
+ # Merge by joining on sample and creation_date
+ merged_wide_tables = merged_wide_tables.join(table_df, on=["sample", "creation_date"], how="outer")
+ # Ensure all columns are present
+ all_cols = merged_wide_tables.columns
+ for col in table_df.columns:
+ if col not in all_cols:
+ all_cols.append(col)
+ merged_wide_tables = merged_wide_tables.select([c for c in all_cols if c in merged_wide_tables.columns])
+
+ # Build list of dataframes to concatenate
+ all_dfs: List[pl.DataFrame] = []
+
+ if existing_other_rows is not None and not existing_other_rows.is_empty():
+ all_dfs.append(existing_other_rows)
+
+ all_dfs.extend(_pending_dataframes)
+
+ if merged_wide_tables is not None and not merged_wide_tables.is_empty():
+ all_dfs.append(merged_wide_tables)
+
+ # Write combined data
+ if all_dfs:
+ combined_df = pl.concat(all_dfs, how="diagonal")
+ _write_parquet(combined_df)
+
+ # Clear buffers
+ _pending_dataframes = []
+ _pending_wide_tables = []
def get_report_metadata(df: pl.DataFrame) -> Optional[Dict[str, Any]]:
@@ -201,6 +252,9 @@ def _clean_config_values(value: Any) -> Any:
append_to_parquet(metadata_df)
+ # Flush all buffered data to the parquet file
+ flush_to_parquet()
+
def _write_parquet(df: pl.DataFrame) -> None:
parquet_file = tmp_dir.parquet_file()
@@ -310,9 +364,11 @@ def reset():
"""
Reset the module state.
"""
- global _saved_anchors, _metric_col_names
+ global _saved_anchors, _metric_col_names, _pending_dataframes, _pending_wide_tables
_saved_anchors = set()
_metric_col_names = set()
+ _pending_dataframes = []
+ _pending_wide_tables = []
def parse_value(value: Any, value_type: str) -> Any:
diff --git a/multiqc/core/special_case_modules/custom_content.py b/multiqc/core/special_case_modules/custom_content.py
index ebe3fd2970..6f2fffa3bd 100644
--- a/multiqc/core/special_case_modules/custom_content.py
+++ b/multiqc/core/special_case_modules/custom_content.py
@@ -619,6 +619,7 @@ def add_cc_section(self, section_id: SectionId, section_anchor: Anchor, ccdict:
description=section_description,
plot=plot,
content=content or "",
+ helptext=ccdict.config.get("helptext", ""),
)
@@ -785,7 +786,7 @@ def _parse_txt(
row_str: List[str]
for line in non_header_lines:
if line.rstrip():
- row_str = line.rstrip("\n").split(sep)
+ row_str = [cell.strip() for cell in line.rstrip("\n").split(sep)]
matrix_str.append(row_str)
if ncols is None:
ncols = len(row_str)
diff --git a/multiqc/core/special_case_modules/load_multiqc_data.py b/multiqc/core/special_case_modules/load_multiqc_data.py
index 45f160c541..71618a26f9 100644
--- a/multiqc/core/special_case_modules/load_multiqc_data.py
+++ b/multiqc/core/special_case_modules/load_multiqc_data.py
@@ -136,6 +136,9 @@ def __init__(self):
# After loading all files, process and deduplicate software versions
self._process_collected_software_versions()
+ # After all files are loaded and merged, create plot objects once
+ self._create_plot_objects()
+
def load_parquet_file(self, path: Union[str, Path]):
"""
Load a multiqc.parquet file containing all report data.
@@ -233,14 +236,6 @@ def load_parquet_file(self, path: Union[str, Path]):
# Merge the existing module into the new one
mod.merge(existing_mod) # This only merges versions
- # Debug: Log sections before merging
- log.debug(f"Before merging - Existing module has {len(existing_mod.sections)} sections:")
- for s in existing_mod.sections:
- log.debug(f" - Existing: {s.name} (anchor: {s.anchor})")
- log.debug(f"Before merging - New module has {len(mod.sections)} sections:")
- for s in mod.sections:
- log.debug(f" - New: {s.name} (anchor: {s.anchor})")
-
# Merge sections based on anchor - keep all unique sections from both modules
existing_sections = {s.anchor: s for s in existing_mod.sections}
new_sections = {s.anchor: s for s in mod.sections}
@@ -248,12 +243,9 @@ def load_parquet_file(self, path: Union[str, Path]):
merged_sections = []
all_section_anchors = set(existing_sections.keys()) | set(new_sections.keys())
- log.debug(f"All section anchors to process: {sorted(all_section_anchors)}")
-
for section_anchor in all_section_anchors:
if section_anchor in existing_sections and section_anchor in new_sections:
# Both modules have this section - merge content if different
- log.debug(f"Merging content for section: {section_anchor}")
existing_section = existing_sections[section_anchor]
new_section = new_sections[section_anchor]
@@ -266,21 +258,13 @@ def load_parquet_file(self, path: Union[str, Path]):
)
if existing_has_data and not new_has_data:
- # Existing section has data, new section is empty - use existing as base
- log.debug(
- f"Using existing section as base (new section is empty): {section_anchor}"
- )
+ # Existing section has data, new section is empty - use existing
merged_sections.append(existing_section)
elif new_has_data and not existing_has_data:
- # New section has data, existing section is empty - use new as base
- log.debug(
- f"Using new section as base (existing section is empty): {section_anchor}"
- )
+ # New section has data, existing section is empty - use new
merged_sections.append(new_section)
elif existing_has_data and new_has_data:
# Both sections have data - perform proper merging
- log.debug(f"Both sections have data, merging content: {section_anchor}")
-
# Combine content if it's different
merged_content = new_section.content
if existing_section.content and existing_section.content != new_section.content:
@@ -307,30 +291,21 @@ def load_parquet_file(self, path: Union[str, Path]):
content_before_plot=new_section.content_before_plot,
content=merged_content,
print_section=new_section.print_section,
- plot_anchor=merged_plot_anchor, # Use merged plot_anchor
+ plot_anchor=merged_plot_anchor,
ai_summary=new_section.ai_summary,
)
merged_sections.append(merged_section)
else:
# Both sections are empty - use new section as default
- log.debug(f"Both sections are empty, using new section: {section_anchor}")
merged_sections.append(new_section)
elif section_anchor in existing_sections:
# Only in existing module
- log.debug(f"Preserving section from existing module: {section_anchor}")
merged_sections.append(existing_sections[section_anchor])
else:
# Only in new module
- log.debug(f"Adding section from new module: {section_anchor}")
merged_sections.append(new_sections[section_anchor])
mod.sections = merged_sections
-
- # Debug: Log sections after merging
- log.debug(f"After merging - Final module has {len(mod.sections)} sections:")
- for s in mod.sections:
- log.debug(f" - Final: {s.name} (anchor: {s.anchor})")
-
log.debug(f'Updating module "{existing_mod.name}" with data from parquet')
report.modules.remove(existing_mod)
else:
@@ -396,22 +371,13 @@ def load_parquet_file(self, path: Union[str, Path]):
merged_plot_input = existing_plot_input.__class__.merge(existing_plot_input, plot_input)
report.plot_input_data[anchor] = merged_plot_input
log.debug(f"Successfully merged plot input data for {anchor}")
-
- # Create the plot object from the merged data (this ensures proper color assignment)
- merged_plot: Union[Plot, str, None] = create_plot_from_input_data(merged_plot_input)
- if merged_plot is not None:
- report.plot_by_id[anchor] = merged_plot
- log.debug(f"Updated plot object for {anchor} with merged data")
+ # Note: Plot object creation is deferred until all files are loaded
else:
- # No existing data, just add the new data and create plot object
+ # No existing data, just store the input data
log.debug(f"No existing plot input data for {anchor}, adding new data")
report.plot_input_data[anchor] = plot_input
-
- # Create the plot object from the input data
- plot = create_plot_from_input_data(plot_input)
- if plot is not None:
- report.plot_by_id[anchor] = plot
+ # Note: Plot object creation is deferred until all files are loaded
except Exception as e:
log.error(f"Error loading plot input data {anchor}: {e}")
if config.strict:
@@ -447,3 +413,22 @@ def _process_collected_software_versions(self):
report.software_versions[software_name][software_name].extend(final_versions)
log.debug(f"Processed {len(final_versions)} versions for {software_name}: {', '.join(final_versions)}")
+
+ def _create_plot_objects(self):
+ """
+ Create plot objects from all loaded plot input data.
+
+ This is called once after all parquet files have been loaded and merged,
+ rather than creating plot objects after each file. This avoids expensive
+ repeated calls to save_to_parquet() during the merge process.
+ """
+ for anchor, plot_input in report.plot_input_data.items():
+ try:
+ plot = create_plot_from_input_data(plot_input)
+ if plot is not None:
+ report.plot_by_id[anchor] = plot
+ log.debug(f"Created plot object for {anchor}")
+ except Exception as e:
+ log.error(f"Error creating plot object for {anchor}: {e}")
+ if config.strict:
+ raise e
diff --git a/multiqc/core/special_case_modules/software_versions.py b/multiqc/core/special_case_modules/software_versions.py
index 82ac25d3e3..4093dcfd9e 100644
--- a/multiqc/core/special_case_modules/software_versions.py
+++ b/multiqc/core/special_case_modules/software_versions.py
@@ -8,6 +8,7 @@
from multiqc import report
from multiqc.base_module import BaseMultiqcModule
from multiqc.types import Anchor
+from multiqc.utils.material_icons import get_material_icon
# Initialise the logger
log = logging.getLogger(__name__)
@@ -52,10 +53,10 @@ def _make_versions_html(versions: Dict[str, Dict[str, List[str]]]) -> str:
html = [
dedent(
f"""\
-
- Copy table
+
+ {get_material_icon("mdi:content-copy", 16)} Copy table
-
+
{"".join(header_rows)}
diff --git a/multiqc/core/version_check.py b/multiqc/core/version_check.py
index 0b198de7ab..72aa8e1fca 100644
--- a/multiqc/core/version_check.py
+++ b/multiqc/core/version_check.py
@@ -14,6 +14,20 @@
logger = logging.getLogger(__name__)
+def _is_uv_installed() -> bool:
+ """Check if MultiQC was installed using uv by checking pyvenv.cfg for uv marker."""
+ pyvenv_cfg = os.path.join(sys.prefix, "pyvenv.cfg")
+ if os.path.exists(pyvenv_cfg):
+ try:
+ with open(pyvenv_cfg) as f:
+ for line in f:
+ if line.strip().startswith("uv ="):
+ return True
+ except OSError:
+ pass
+ return False
+
+
def check_version(interactive_function_name: Optional[str] = None):
# Check that we're running the latest version of MultiQC
if config.no_version_check is True:
@@ -28,6 +42,7 @@ def check_version(interactive_function_name: Optional[str] = None):
"is_docker": os.path.exists("/.dockerenv"),
"is_singularity": os.path.exists("/.singularity.d"),
"is_conda": os.path.exists(os.path.join(sys.prefix, "conda-meta")),
+ "is_uv": _is_uv_installed(),
"is_ci": strtobool(os.getenv("CI", False)),
"is_notebook": is_running_in_notebook(),
"interactive_function_name": interactive_function_name,
diff --git a/multiqc/core/write_results.py b/multiqc/core/write_results.py
index 9ab22239d3..6605966a06 100644
--- a/multiqc/core/write_results.py
+++ b/multiqc/core/write_results.py
@@ -27,6 +27,7 @@
from multiqc.types import Anchor
from multiqc.utils import util_functions
from multiqc.utils.util_functions import rmtree_with_retries
+from multiqc.utils.material_icons import get_material_icon
logger = logging.getLogger(__name__)
@@ -119,6 +120,12 @@ def write_results(return_html: bool = False) -> Optional[str]:
)
)
+ # Copy log to the multiqc_data dir. Keeping it in the tmp dir in case if it's an interactive session
+ # that goes beyond this write_results run.
+ # Do this before zipping the data directory, since zipping will remove the directory.
+ if log_and_rich.log_tmp_fn and paths.data_dir and paths.data_dir.exists():
+ shutil.copy2(log_and_rich.log_tmp_fn, str(paths.data_dir))
+
# Zip the data directory if requested
if config.zip_data_dir and paths.data_dir is not None:
shutil.make_archive(str(paths.data_dir), format="zip", root_dir=str(paths.data_dir))
@@ -130,11 +137,6 @@ def write_results(return_html: bool = False) -> Optional[str]:
if paths.report_path:
logger.debug(f"Report HTML written to {paths.report_path}")
- # Copy log to the multiqc_data dir. Keeping it in the tmp dir in case if it's an interactive session
- # that goes beyond this write_results run.
- if log_and_rich.log_tmp_fn and paths.data_dir:
- shutil.copy2(log_and_rich.log_tmp_fn, str(paths.data_dir))
-
# Return HTML content if requested
return html_content if return_html else None
@@ -563,6 +565,15 @@ def include_file(name, fdir=tmp_dir.get_tmp_dir(), b64=False):
try:
env = jinja2.Environment(loader=jinja2.FileSystemLoader(tmp_dir.get_tmp_dir()))
env.globals["include_file"] = include_file
+
+ # Add Material Design Icons function to all templates
+ env.globals["material_icon"] = get_material_icon
+
+ # Add template functions if available
+ if hasattr(template_mod, "template_functions"):
+ for func_name, func in template_mod.template_functions.items():
+ env.globals[func_name] = func
+
j_template = env.get_template(template_mod.base_fn, globals={"development": config.development})
except: # noqa: E722
raise IOError(f"Could not load {config.template} template file '{template_mod.base_fn}'")
@@ -576,6 +587,13 @@ def include_file(name, fdir=tmp_dir.get_tmp_dir(), b64=False):
# Use jinja2 to render the template and overwrite
report.analysis_files = [os.path.realpath(d) for d in report.analysis_files]
report.report_uuid = str(uuid.uuid4())
+
+ # Allow templates to override config settings
+ if hasattr(template_mod, "template_dark_mode"):
+ config.template_dark_mode = template_mod.template_dark_mode
+ if hasattr(template_mod, "plot_font_family"):
+ config.plot_font_family = template_mod.plot_font_family
+
report_output = j_template.render(report=report, config=config)
if to_stdout:
print(report_output, file=sys.stdout)
diff --git a/multiqc/interactive.py b/multiqc/interactive.py
index 70c7c4678b..08af9cc5d2 100644
--- a/multiqc/interactive.py
+++ b/multiqc/interactive.py
@@ -415,7 +415,7 @@ def write_report(
@param plots_force_flat: Use only flat plots (static images)
@param plots_force_interactive: Use only interactive plots (in-browser Javascript)
@param strict: Don't catch exceptions, run additional code checks to help development
- @param development: Development mode. Do not compress and minimise JS, export uncompressed plot data
+ @param development: Development mode. Do not inline JS and CSS, export uncompressed plot data
@param make_pdf: Create PDF report. Requires Pandoc to be installed
@param no_megaqc_upload: Don't upload generated report to MegaQC, even if MegaQC options are found
@param quiet: Only show log warnings
diff --git a/multiqc/modules/bamdst/bamdst.py b/multiqc/modules/bamdst/bamdst.py
index cbc73b58db..3adbca64a5 100755
--- a/multiqc/modules/bamdst/bamdst.py
+++ b/multiqc/modules/bamdst/bamdst.py
@@ -450,7 +450,6 @@ def _chrom_key(name):
"tt_suffix": "x",
"smooth_points": 500,
"logswitch": True,
- "hide_zero_cats": False,
"ymin": 0,
}
if data_labels:
@@ -470,7 +469,6 @@ def _chrom_key(name):
"tt_suffix": "%",
"smooth_points": 500,
"logswitch": True,
- "hide_zero_cats": False,
"ymax": 100,
"ymin": 0,
}
diff --git a/multiqc/modules/bases2fastq/bases2fastq.py b/multiqc/modules/bases2fastq/bases2fastq.py
index df4e29af77..3c8aae9d18 100644
--- a/multiqc/modules/bases2fastq/bases2fastq.py
+++ b/multiqc/modules/bases2fastq/bases2fastq.py
@@ -1,13 +1,15 @@
from collections import defaultdict
import copy
+from itertools import chain
import re
import json
import logging
import random
-from typing import Any, Dict, List
+from typing import Any, Callable, Dict, List, Optional, Tuple
import uuid
from pathlib import Path
+from multiqc import config
from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound
from multiqc.types import LoadedFileDict
from multiqc.utils import mqc_colour
@@ -33,10 +35,118 @@
log = logging.getLogger(__name__)
-MIN_POLONIES = 10000
+# Default minimum polony threshold - samples below this are skipped
+DEFAULT_MIN_POLONIES = 10000
+
+
+def _get_min_polonies() -> int:
+ """
+ Get the minimum polonies threshold from config or use default.
+
+ Can be configured in multiqc_config.yaml:
+ bases2fastq_config:
+ min_polonies: 5000
+ """
+ cfg = getattr(config, "bases2fastq_config", {})
+ if not isinstance(cfg, dict):
+ return DEFAULT_MIN_POLONIES
+
+ min_polonies = cfg.get("min_polonies", DEFAULT_MIN_POLONIES)
+ try:
+ min_polonies = int(min_polonies)
+ except (ValueError, TypeError):
+ log.warning(f"Invalid min_polonies value '{min_polonies}', using default {DEFAULT_MIN_POLONIES}")
+ min_polonies = DEFAULT_MIN_POLONIES
+
+ if min_polonies != DEFAULT_MIN_POLONIES:
+ log.debug(f"Using custom min_polonies threshold: {min_polonies}")
+
+ return min_polonies
class MultiqcModule(BaseMultiqcModule):
+ """
+ Bases2Fastq is Element Biosciences' secondary analysis software for demultiplexing
+ sequencing data from AVITI systems and converting base calls into FASTQ files.
+
+ Data Flow Overview
+ ------------------
+ The module handles three distinct data hierarchy levels:
+
+ 1. **Run Level**: Single sequencing run with all samples in one output
+ - Directory: `/`
+ - Files: `RunStats.json`, `RunManifest.json`
+ - Samples identified by: `{RunName}-{AnalysisID}__{SampleName}`
+
+ 2. **Project Level**: Demultiplexing by project, samples split into project subdirectories
+ - Directory: `/Samples//`
+ - Files: Project-specific `RunStats.json`
+ - Run-level `RunManifest.json` accessed via `../../RunManifest.json`
+ - Samples identified by: `{RunName}-{AnalysisID}__{SampleName}`
+
+ 3. **Combined Level**: Both run and project data present (merged view)
+
+ Parsing Flow
+ ------------
+ ```
+ __init__()
+ │
+ ├─> _init_data_structures() # Initialize empty dicts for all data levels
+ │
+ ├─> _parse_and_validate_data() # Main parsing entry point
+ │ │
+ │ ├─> _parse_run_project_data("bases2fastq/run") # Parse run-level RunStats.json
+ │ │ └─> Populates: run_level_data, run_level_samples, run_level_samples_to_project
+ │ │
+ │ ├─> _parse_run_project_data("bases2fastq/project") # Parse project-level RunStats.json
+ │ │ └─> Populates: project_level_data, project_level_samples, project_level_samples_to_project
+ │ │
+ │ └─> _determine_summary_path() # Returns: "run_level" | "project_level" | "combined_level"
+ │
+ ├─> _select_data_by_summary_path() # Route to appropriate data sources
+ │ │
+ │ ├─> _parse_run_manifest() or _parse_run_manifest_in_project()
+ │ │ └─> Returns: manifest_data (lane settings, adapter info)
+ │ │
+ │ ├─> _parse_index_assignment() or _parse_index_assignment_in_project()
+ │ │ └─> Returns: index_assignment_data (per-sample index stats)
+ │ │
+ │ └─> _parse_run_unassigned_sequences() (run_level only)
+ │ └─> Returns: unassigned_sequences (unknown barcodes)
+ │
+ ├─> _setup_colors() # Assign colors to runs/projects/samples
+ │
+ └─> _generate_plots() # Create all report sections and plots
+ ```
+
+ Data Structures
+ ---------------
+ - `run_level_data`: Dict[run_name, run_stats] - Run-level QC metrics
+ - `run_level_samples`: Dict[sample_id, sample_stats] - Sample metrics from run-level
+ - `project_level_data`: Dict[project_name, project_stats] - Project-level QC metrics
+ - `project_level_samples`: Dict[sample_id, sample_stats] - Sample metrics from project-level
+ - `*_samples_to_project`: Dict[sample_id, project_name] - Maps samples to their projects
+
+ Sample Naming Convention
+ ------------------------
+ Samples are uniquely identified as: `{RunName}-{AnalysisID[0:4]}__{SampleName}`
+ This ensures uniqueness across multiple runs while keeping names readable.
+
+ Files Parsed
+ ------------
+ - `RunStats.json`: Run/project QC metrics, sample statistics, lane data
+ - `RunManifest.json`: Sample sheet info, index sequences, adapter settings
+
+ Metrics Displayed
+ -----------------
+ - Polony counts and yields
+ - Base quality distributions (histogram and by-cycle)
+ - Index assignment statistics
+ - Per-sample sequence content and GC distribution
+ - Adapter content analysis
+ - Unassigned/unknown barcode sequences (run-level only)
+ """
+
def __init__(self):
super(MultiqcModule, self).__init__(
name="Bases2Fastq",
@@ -46,29 +156,144 @@ def __init__(self):
doi="10.1038/s41587-023-01750-7",
)
- # Initialize run, project and sample level structures
- self.run_level_data = {}
- self.run_level_samples = {}
- self.run_level_samples_to_project = {}
- self.project_level_data = {}
- self.project_level_samples = {}
- self.project_level_samples_to_project = {}
- num_run_level_samples = 0
- num_project_level_samples = 0
-
- # Initialize run and project groups
- self.group_dict = dict()
- self.group_lookup_dict = dict()
- self.project_lookup_dict = dict()
-
- self.b2f_sample_data = dict()
- self.b2f_run_data = dict()
- self.b2f_run_project_data = dict()
- self.b2f_run_project_sample_data = dict()
- self.missing_runs = set()
- self.sample_id_to_run = dict()
-
- # Define if call is project- or run-level
+ # Get configurable minimum polonies threshold
+ self.min_polonies = _get_min_polonies()
+
+ # Initialize data structures
+ self._init_data_structures()
+
+ # Parse and validate input data
+ summary_path = self._parse_and_validate_data()
+
+ # Select data based on summary path and parse additional sources
+ run_data, sample_data, samples_to_projects, manifest_data, index_assignment_data, unassigned_sequences = (
+ self._select_data_by_summary_path(summary_path)
+ )
+
+ # Set up color schemes for groups and samples
+ self._setup_colors(sample_data, samples_to_projects, summary_path)
+
+ # Generate all plots and sections
+ self._generate_plots(
+ summary_path,
+ run_data,
+ sample_data,
+ samples_to_projects,
+ manifest_data,
+ index_assignment_data,
+ unassigned_sequences,
+ )
+
+ # Write main data file at the very end after all sections are added
+ self.write_data_file(sample_data, "bases2fastq")
+
+ def _init_data_structures(self) -> None:
+ """
+ Initialize all data structures used by the module.
+
+ Data structures are organized by hierarchy level:
+ - Run level: Data from single-run Bases2Fastq output (no project splitting)
+ - Project level: Data from project-split Bases2Fastq output
+ - Combined: Merged data when both levels are present
+ """
+ # File cache to avoid reading the same JSON files multiple times
+ # Key: resolved file path, Value: parsed JSON data
+ self._file_cache: Dict[str, Any] = {}
+
+ # === Run-level data structures ===
+ # Populated from /RunStats.json
+ self.run_level_data: Dict[str, Any] = {} # run_name -> full run stats
+ self.run_level_samples: Dict[str, Any] = {} # sample_id -> sample stats
+ self.run_level_samples_to_project: Dict[str, str] = {} # sample_id -> project name
+
+ # === Project-level data structures ===
+ # Populated from /Samples//RunStats.json
+ self.project_level_data: Dict[str, Any] = {} # project_name -> project stats
+ self.project_level_samples: Dict[str, Any] = {} # sample_id -> sample stats
+ self.project_level_samples_to_project: Dict[str, str] = {} # sample_id -> project name
+
+ # === Grouping structures for color assignment ===
+ self.group_dict: Dict[str, Any] = {} # group_name -> list of members
+ self.group_lookup_dict: Dict[str, Any] = {} # item -> group it belongs to
+ self.project_lookup_dict: Dict[str, Any] = {} # sample -> project mapping
+
+ # === Legacy/auxiliary data structures ===
+ self.b2f_sample_data: Dict[str, Any] = {}
+ self.b2f_run_data: Dict[str, Any] = {}
+ self.b2f_run_project_data: Dict[str, Any] = {}
+ self.b2f_run_project_sample_data: Dict[str, Any] = {}
+ self.missing_runs: set = set() # Runs referenced but not found
+ self.sample_id_to_run: Dict[str, str] = {} # sample_id -> run_analysis_name
+
+ def _validate_path(self, file_path: Path, base_directory: Path) -> bool:
+ """
+ Validate that a file path doesn't escape outside the expected directory hierarchy.
+
+ Args:
+ file_path: Path to validate
+ base_directory: The base directory that the path should stay within
+
+ Returns:
+ True if path is valid, False if it escapes the base directory
+ """
+ try:
+ resolved_path = file_path.resolve()
+ resolved_base = base_directory.resolve()
+ # Check if the resolved path is within the base directory tree
+ resolved_path.relative_to(resolved_base)
+ return True
+ except ValueError:
+ # relative_to raises ValueError if path is not relative to base
+ log.warning(
+ f"Path {file_path} resolves outside expected directory {base_directory}. Skipping for security reasons."
+ )
+ return False
+
+ def _read_json_file(self, file_path: Path, base_directory: Optional[Path] = None) -> Optional[Dict[str, Any]]:
+ """
+ Read and parse a JSON file with caching.
+
+ Args:
+ file_path: Path to the JSON file
+ base_directory: Optional base directory to validate path against
+
+ Returns:
+ Parsed JSON data or None if reading failed
+ """
+ # Validate path doesn't escape expected directory if base is provided
+ if base_directory is not None and not self._validate_path(file_path, base_directory):
+ return None
+
+ cache_key = str(file_path.resolve())
+
+ if cache_key in self._file_cache:
+ return self._file_cache[cache_key]
+
+ if not file_path.exists():
+ log.error(
+ f"{file_path.name} does not exist at {file_path}.\n"
+ f"Please visit Elembio online documentation for more information - "
+ f"https://docs.elembio.io/docs/bases2fastq/introduction/"
+ )
+ return None
+
+ try:
+ with open(file_path) as _infile:
+ data = json.load(_infile)
+ self._file_cache[cache_key] = data
+ return data
+ except (json.JSONDecodeError, OSError) as e:
+ log.error(f"Error reading {file_path}: {e}")
+ return None
+
+ def _parse_and_validate_data(self) -> str:
+ """
+ Parse input data and validate that samples were found.
+
+ Returns:
+ summary_path: The determined summary path ('run_level', 'project_level', or 'combined_level')
+ """
+ # Check for available log files
run_level_log_files = len(list(self.find_log_files("bases2fastq/run")))
project_level_log_files = len(list(self.find_log_files("bases2fastq/project")))
@@ -77,7 +302,7 @@ def __init__(self):
log.error(error_msg)
raise ModuleNoSamplesFound(error_msg)
- # Parse data
+ # Parse data from available sources
if run_level_log_files > 0:
(self.run_level_data, self.run_level_samples, self.run_level_samples_to_project) = (
self._parse_run_project_data("bases2fastq/run")
@@ -87,11 +312,11 @@ def __init__(self):
self._parse_run_project_data("bases2fastq/project")
)
- # Get run- and project-level samples
+ # Count samples
num_run_level_samples = len(self.run_level_samples)
num_project_level_samples = len(self.project_level_samples)
- # Ensure run/sample data found
+ # Ensure at least some data was found
if all(
[
len(self.run_level_data) == 0,
@@ -104,16 +329,10 @@ def __init__(self):
log.error(error_msg)
raise ModuleNoSamplesFound(error_msg)
- # Choose path to take, if project use only project-level data, otherwise use run- and project-level
- summary_path = ""
- if len(self.run_level_data) > 0 and len(self.project_level_data) == 0:
- summary_path = "run_level"
- if len(self.run_level_data) == 0 and len(self.project_level_data) > 0:
- summary_path = "project_level"
- elif len(self.run_level_data) > 0 and len(self.project_level_data) > 0:
- summary_path = "combined_level"
+ # Determine summary path
+ summary_path = self._determine_summary_path()
- # Log runs, projects and samples found
+ # Log what was found
log.info(f"Found {len(self.run_level_data)} run(s) within the Bases2Fastq results.")
log.info(f"Found {len(self.project_level_data)} project(s) within the Bases2Fastq results.")
if summary_path == "run_level":
@@ -121,133 +340,226 @@ def __init__(self):
else:
log.info(f"Found {num_project_level_samples} sample(s) within the Bases2Fastq results.")
- # Superfluous function call to confirm that it is used in this module
+ # Required call to confirm module is used
self.add_software_version(None)
- # Warn user if run-level/project-level or sample-level metrics were not found
+ # Warn if no data found
if len(self.run_level_data) == 0 and len(self.project_level_data) == 0:
log.warning("No run/project stats found!")
if num_run_level_samples == 0 and num_project_level_samples == 0:
log.warning("No sample stats found!")
- # Define data to use
- run_data = {}
- sample_data = {}
- samples_to_projects = {}
- manifest_data = {}
- index_assigment_data = {}
- unassigned_sequences = {}
+ return summary_path
+
+ def _determine_summary_path(self) -> str:
+ """
+ Determine which summary path to use based on available data.
+
+ Returns:
+ 'run_level', 'project_level', or 'combined_level'
+ """
+ has_run_data = len(self.run_level_data) > 0
+ has_project_data = len(self.project_level_data) > 0
+
+ if has_run_data and not has_project_data:
+ return "run_level"
+ elif not has_run_data and has_project_data:
+ return "project_level"
+ elif has_run_data and has_project_data:
+ return "combined_level"
+ else:
+ error_msg = "No run- or project-level data was retained. No report will be generated."
+ log.error(error_msg)
+ raise ModuleNoSamplesFound(error_msg)
+
+ def _select_data_by_summary_path(
+ self, summary_path: str
+ ) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, str], Dict[str, Any], Dict[str, Any], Dict[str, Any]]:
+ """
+ Select the appropriate data sources based on the summary path.
+
+ Returns:
+ Tuple of (run_data, sample_data, samples_to_projects, manifest_data,
+ index_assignment_data, unassigned_sequences)
+ """
if summary_path == "run_level":
- run_data = self.run_level_data
- sample_data = self.run_level_samples
- samples_to_projects = self.run_level_samples_to_project
- manifest_data = self._parse_run_manifest("bases2fastq/manifest")
- index_assigment_data = self._parse_index_assignment("bases2fastq/manifest")
- unassigned_sequences = self._parse_run_unassigned_sequences("bases2fastq/run")
+ return (
+ self.run_level_data,
+ self.run_level_samples,
+ self.run_level_samples_to_project,
+ self._parse_run_manifest("bases2fastq/manifest"),
+ self._parse_index_assignment("bases2fastq/manifest"),
+ self._parse_run_unassigned_sequences("bases2fastq/run"),
+ )
elif summary_path == "project_level":
- run_data = self.project_level_data
- sample_data = self.project_level_samples
- samples_to_projects = self.project_level_samples_to_project
- manifest_data = self._parse_run_manifest_in_project("bases2fastq/project")
- index_assigment_data = self._parse_index_assignment_in_project("bases2fastq/project")
+ return (
+ self.project_level_data,
+ self.project_level_samples,
+ self.project_level_samples_to_project,
+ self._parse_run_manifest_in_project("bases2fastq/project"),
+ self._parse_index_assignment_in_project("bases2fastq/project"),
+ {}, # No unassigned sequences for project level
+ )
elif summary_path == "combined_level":
- run_data = self.run_level_data
- sample_data = self.project_level_samples
- samples_to_projects = self.project_level_samples_to_project
- manifest_data = self._parse_run_manifest("bases2fastq/manifest")
- index_assigment_data = self._parse_index_assignment("bases2fastq/manifest")
- unassigned_sequences = self._parse_run_unassigned_sequences("bases2fastq/run")
+ return (
+ self.run_level_data,
+ self.project_level_samples,
+ self.project_level_samples_to_project,
+ self._parse_run_manifest("bases2fastq/manifest"),
+ self._parse_index_assignment("bases2fastq/manifest"),
+ self._parse_run_unassigned_sequences("bases2fastq/run"),
+ )
else:
error_msg = "No run- or project-level data was retained. No report will be generated."
log.error(error_msg)
raise ModuleNoSamplesFound(error_msg)
+ def _setup_colors(
+ self, sample_data: Dict[str, Any], samples_to_projects: Dict[str, str], summary_path: str
+ ) -> None:
+ """Set up color schemes for groups and samples."""
# Create run and project groups
- run_groups = defaultdict(list)
- project_groups = defaultdict(list)
- in_project_sample_groups = defaultdict(list)
- ind_sample_groups = defaultdict(list)
- sample_to_run_group = {}
+ run_groups: Dict[str, List] = defaultdict(list)
+ project_groups: Dict[str, List] = defaultdict(list)
+ in_project_sample_groups: Dict[str, List] = defaultdict(list)
+ ind_sample_groups: Dict[str, List] = defaultdict(list)
+
for sample in sample_data.keys():
- (_run_name, _) = sample.split("__")
- run_groups[_run_name].append(sample)
- sample_to_run_group[sample] = _run_name
+ run_name, _ = sample.split("__")
+ run_groups[run_name].append(sample)
sample_project = samples_to_projects[sample]
project_groups[sample_project].append(sample)
ind_sample_groups[sample] = [sample]
if summary_path == "project_level":
in_project_sample_groups[sample].append(sample)
+
merged_groups = {**run_groups, **project_groups, **in_project_sample_groups, **ind_sample_groups}
- # Assign color for each group
+ # Build color palette
self.color_getter = mqc_colour.mqc_colour_scale()
- self.palette = sum(
- [
+ self.palette = list(
+ chain.from_iterable(
self.color_getter.get_colours(hue)
for hue in ["Set2", "Pastel1", "Accent", "Set1", "Set3", "Dark2", "Paired", "Pastel2"]
- ],
- [],
+ )
)
+
+ # Add extra colors if needed
if len(merged_groups) > len(self.palette):
extra_colors = [
- "#{:06x}".format(random.randrange(0, 0xFFFFFF)) for _ in range(len(self.palette), len(merged_groups))
+ f"#{random.randrange(0, 0xFFFFFF):06x}" for _ in range(len(self.palette), len(merged_groups))
]
self.palette = self.palette + extra_colors
- self.group_color = {g: c for g, c in zip(merged_groups.keys(), self.palette[: len(merged_groups)])}
- self.sample_color = dict()
- for s_name in samples_to_projects.keys():
- s_color = (
- self.group_color[s_name]
- if (summary_path == "project_level" or len(project_groups) == 1)
- else self.group_color[samples_to_projects[s_name]]
- )
- self.sample_color.update({s_name: s_color})
- self.run_color = copy.deepcopy(self.group_color) # Make sure that run colors and group colors match
+
+ # Assign colors to groups
+ self.group_color = {
+ group: color for group, color in zip(merged_groups.keys(), self.palette[: len(merged_groups)])
+ }
+
+ # Assign colors to samples
+ self.sample_color: Dict[str, str] = {}
+ for sample_name in samples_to_projects.keys():
+ if summary_path == "project_level" or len(project_groups) == 1:
+ sample_color = self.group_color[sample_name]
+ else:
+ sample_color = self.group_color[samples_to_projects[sample_name]]
+ self.sample_color[sample_name] = sample_color
+
+ # Copy group colors to run colors
+ self.run_color = copy.deepcopy(self.group_color)
self.palette = self.palette[len(merged_groups) :]
- # Plot metrics
+ def _generate_plots(
+ self,
+ summary_path: str,
+ run_data: Dict[str, Any],
+ sample_data: Dict[str, Any],
+ samples_to_projects: Dict[str, str],
+ manifest_data: Dict[str, Any],
+ index_assignment_data: Dict[str, Any],
+ unassigned_sequences: Dict[str, Any],
+ ) -> None:
+ """Generate all plots and add sections to the report."""
+ # QC metrics table
qc_metrics_function = (
tabulate_run_stats if summary_path in ["run_level", "combined_level"] else tabulate_project_stats
)
self.add_run_plots(data=run_data, plot_functions=[qc_metrics_function])
- self.add_run_plots(
- data=manifest_data,
- plot_functions=[
- tabulate_manifest_stats,
- ],
- )
+
+ # Manifest stats
+ self.add_run_plots(data=manifest_data, plot_functions=[tabulate_manifest_stats])
+
+ # Index assignment stats
+ self.add_run_plots(data=index_assignment_data, plot_functions=[tabulate_index_assignment_stats])
+
+ # Unassigned sequences (only for run_level and combined_level)
if summary_path in ["run_level", "combined_level"]:
- self.add_run_plots(
- data=index_assigment_data,
- plot_functions=[
- tabulate_index_assignment_stats,
- ],
- )
- self.add_run_plots(
- data=unassigned_sequences,
- plot_functions=[
- tabulate_unassigned_index_stats,
- ],
- )
- else:
- self.add_run_plots(
- data=index_assigment_data,
- plot_functions=[
- tabulate_index_assignment_stats,
- ],
- )
+ self.add_run_plots(data=unassigned_sequences, plot_functions=[tabulate_unassigned_index_stats])
+ # Run-level plots
self.add_run_plots(
data=run_data,
plot_functions=[plot_run_stats, plot_base_quality_hist, plot_base_quality_by_cycle],
)
- self.add_sample_plots(data=sample_data, group_lookup=samples_to_projects, project_lookup=samples_to_projects)
+ # Sample-level plots
+ self.add_sample_plots(
+ data=sample_data,
+ group_lookup=samples_to_projects,
+ project_lookup=samples_to_projects,
+ )
- def get_uuid(self):
+ def get_uuid(self) -> str:
return str(uuid.uuid4()).replace("-", "").lower()
+ def _extract_run_analysis_name(
+ self,
+ data: Dict[str, Any],
+ source_info: str = "RunStats.json",
+ ) -> Optional[str]:
+ """
+ Extract and validate run_analysis_name from data dict.
+
+ Args:
+ data: Dictionary containing RunName and AnalysisID keys
+ source_info: Description of the data source for error messages
+
+ Returns:
+ The run_analysis_name (RunName-AnalysisID[0:4]) or None if extraction failed
+ """
+ run_name = data.get("RunName")
+ analysis_id = data.get("AnalysisID")
+
+ if not run_name or not analysis_id:
+ log.error(
+ f"Error with {source_info}. Either RunName or AnalysisID is absent.\n"
+ f"RunName: {run_name}, AnalysisID: {analysis_id}\n"
+ f"Please visit Elembio online documentation for more information - "
+ f"https://docs.elembio.io/docs/bases2fastq/introduction/"
+ )
+ return None
+
+ return f"{run_name}-{analysis_id[0:4]}"
+
def _parse_run_project_data(self, data_source: str) -> List[Dict[str, Any]]:
+ """
+ Parse RunStats.json files to extract run/project and sample-level data.
+
+ This is the primary parsing method that populates the core data structures.
+ It handles both run-level and project-level RunStats.json files.
+
+ Args:
+ data_source: Search pattern key ("bases2fastq/run" or "bases2fastq/project")
+
+ Returns:
+ List containing:
+ - runs_global_data: Dict[run_name, run_stats] - Run/project level metrics
+ - runs_sample_data: Dict[sample_id, sample_stats] - Per-sample metrics
+ - sample_to_project: Dict[sample_id, project_name] - Sample-to-project mapping
+
+ Data Flow:
+ RunStats.json -> parse -> filter samples by min_polonies -> populate dicts
+ """
runs_global_data = {}
runs_sample_data = {}
sample_to_project = {}
@@ -262,18 +574,10 @@ def _parse_run_project_data(self, data_source: str) -> List[Dict[str, Any]]:
data_to_return["SampleStats"] = []
# get run + analysis
- run_name = data.get("RunName", None)
- analysis_id = data.get("AnalysisID", None)[0:4]
-
- if not run_name or not analysis_id:
- log.error(
- "Error with RunStats.json. Either RunName or AnalysisID is absent.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
+ run_name = data.get("RunName")
+ run_analysis_name = self._extract_run_analysis_name(data, source_info=f"RunStats.json ({f['fn']})")
+ if run_analysis_name is None:
continue
-
- run_analysis_name = "-".join([run_name, analysis_id])
run_analysis_name = self.clean_s_name(run_analysis_name, f)
# skip run if in user provider ignore list
@@ -295,10 +599,10 @@ def _parse_run_project_data(self, data_source: str) -> List[Dict[str, Any]]:
run_analysis_sample_name = "__".join([run_analysis_name, sample_name])
num_polonies = sample_data["NumPolonies"]
- if num_polonies < MIN_POLONIES:
+ if num_polonies < self.min_polonies:
log.warning(
- f"Skipping {run_analysis_sample_name} because it has"
- f" <{MIN_POLONIES} assigned reads [n={num_polonies}]."
+ f"Skipping {run_analysis_sample_name} because it has "
+ f"<{self.min_polonies} assigned reads [n={num_polonies}]."
)
continue
@@ -318,6 +622,20 @@ def _parse_run_project_data(self, data_source: str) -> List[Dict[str, Any]]:
return [runs_global_data, runs_sample_data, sample_to_project]
def _parse_run_manifest(self, data_source: str) -> Dict[str, Any]:
+ """
+ Parse RunManifest.json for run-level analysis to extract lane and adapter settings.
+
+ Data Flow:
+ RunManifest.json (via data_source pattern)
+ + RunStats.json (for run name) from same directory
+ -> Extract per-lane: index masks, adapter settings, trim lengths
+
+ Args:
+ data_source: Search pattern key for RunManifest.json files
+
+ Returns:
+ Dict[run_lane, settings] where run_lane = "{run_name} | L{lane_id}"
+ """
runs_manifest_data = {}
if data_source == "":
@@ -330,28 +648,13 @@ def _parse_run_manifest(self, data_source: str) -> Dict[str, Any]:
# Get RunName and RunID from RunStats.json
run_stats_path = Path(directory) / "RunStats.json"
- if not run_stats_path.exists():
- log.error(
- f"RunStats.json does not exist in the Bases2Fastq output directory {directory}.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
+ run_stats = self._read_json_file(run_stats_path)
+ if run_stats is None:
continue
- run_analysis_name = None
- with open(run_stats_path) as _infile:
- run_stats = json.load(_infile)
- run_name = run_stats.get("RunName", None)
- analysis_id = run_stats.get("AnalysisID", None)
- if run_name and analysis_id:
- run_analysis_name = "-".join([run_name, analysis_id[0:4]])
- else:
- log.error(
- "Error with RunStats.json. Either RunName or AnalysisID is absent.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
- continue
+ run_analysis_name = self._extract_run_analysis_name(run_stats, source_info=str(run_stats_path))
+ if run_analysis_name is None:
+ continue
run_manifest = json.loads(f["f"])
if "Settings" not in run_manifest:
@@ -393,6 +696,17 @@ def _parse_run_manifest(self, data_source: str) -> Dict[str, Any]:
return runs_manifest_data
def _parse_run_manifest_in_project(self, data_source: str) -> Dict[str, Any]:
+ """
+ Parse RunManifest.json for project-level analysis.
+
+ Similar to _parse_run_manifest but navigates up from project directories
+ to find the run-level RunManifest.json (via ../../RunManifest.json).
+
+ Data Flow:
+ Project RunStats.json (for run name)
+ + ../../RunManifest.json (run-level manifest)
+ -> Extract per-lane settings
+ """
project_manifest_data = {}
if data_source == "":
@@ -403,31 +717,14 @@ def _parse_run_manifest_in_project(self, data_source: str) -> Dict[str, Any]:
if not directory:
continue
- # Get RunName and RunID from RunParameters.json
- run_manifest = Path(directory) / "../../RunManifest.json"
- if not run_manifest.exists():
- log.error(
- f"RunManifest.json could not be found in {run_manifest}. Skipping index assignment.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
- continue
-
+ # Get RunManifest.json from run output root (two levels up from project directory)
+ base_directory = Path(directory).parent.parent
+ run_manifest = base_directory / "RunManifest.json"
project_stats = json.loads(f["f"])
- run_analysis_name = None
- run_name = project_stats.get("RunName", None)
- analysis_id = project_stats.get("AnalysisID", None)
-
- if run_name and analysis_id:
- run_analysis_name = "-".join([run_name, analysis_id[0:4]])
- else:
- log.error(
- "Error with project's RunStats.json. Either RunName or AnalysisID is absent.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
- log.debug(f"Error in RunStats.json: {f['fn']}")
- log.debug(f"Missing: RunName: {run_name} or AnalysisID: {analysis_id}")
+ run_analysis_name = self._extract_run_analysis_name(
+ project_stats, source_info=f"project RunStats.json ({f['fn']})"
+ )
+ if run_analysis_name is None:
continue
# skip run if in user provider ignore list
@@ -435,9 +732,9 @@ def _parse_run_manifest_in_project(self, data_source: str) -> Dict[str, Any]:
log.info(f"Skipping <{run_analysis_name}> because it is present in ignore list.")
continue
- run_manifest_data = None
- with open(run_manifest) as _infile:
- run_manifest_data = json.load(_infile)
+ run_manifest_data = self._read_json_file(run_manifest, base_directory=base_directory)
+ if run_manifest_data is None:
+ continue
if "Settings" not in run_manifest_data:
log.warning(f" section not found in {run_manifest}.\nSkipping RunManifest metrics.")
@@ -482,6 +779,16 @@ def _parse_run_manifest_in_project(self, data_source: str) -> Dict[str, Any]:
return project_manifest_data
def _parse_run_unassigned_sequences(self, data_source: str) -> Dict[str, Any]:
+ """
+ Parse unassigned/unknown barcode sequences from run-level data.
+
+ Only available for run-level analysis. Extracts sequences that could not
+ be assigned to any sample, useful for troubleshooting index issues.
+
+ Data Flow:
+ RunStats.json -> Lanes -> UnassignedSequences
+ -> Extract: sequence, count, percentage of total polonies
+ """
run_unassigned_sequences = {}
if data_source == "":
return run_unassigned_sequences
@@ -490,16 +797,9 @@ def _parse_run_unassigned_sequences(self, data_source: str) -> Dict[str, Any]:
data = json.loads(f["f"])
# Get RunName and AnalysisID
- run_name = data.get("RunName", None)
- analysis_id = data.get("AnalysisID", None)[0:4]
- if not run_name or not analysis_id:
- log.error(
- "Error with RunStats.json. Either RunName or AnalysisID is absent.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
+ run_analysis_name = self._extract_run_analysis_name(data, source_info=f"RunStats.json ({f['fn']})")
+ if run_analysis_name is None:
continue
- run_analysis_name = "-".join([run_name, analysis_id])
run_analysis_name = self.clean_s_name(run_analysis_name, f)
# skip run if in user provider ignore list
@@ -538,6 +838,17 @@ def _parse_run_unassigned_sequences(self, data_source: str) -> Dict[str, Any]:
return run_unassigned_sequences
def _parse_index_assignment(self, manifest_data_source: str) -> Dict[str, Any]:
+ """
+ Parse index assignment statistics for run-level analysis.
+
+ Combines data from RunStats.json (polony counts) and RunManifest.json
+ (index sequences) to show how well each sample's index performed.
+
+ Data Flow:
+ RunStats.json -> SampleStats -> per-sample polony counts
+ + RunManifest.json -> Samples -> index sequences (Index1, Index2)
+ -> Combined index assignment table
+ """
sample_to_index_assignment = {}
if manifest_data_source == "":
@@ -548,90 +859,71 @@ def _parse_index_assignment(self, manifest_data_source: str) -> Dict[str, Any]:
if not directory:
continue
- # Get RunName and RunID from RunParameters.json
+ # Get RunName and RunID from RunStats.json
run_stats_path = Path(directory) / "RunStats.json"
- if not run_stats_path.exists():
- log.error(
- f"RunStats.json does not exist in the Bases2Fastq output directory {directory}.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
+ run_stats = self._read_json_file(run_stats_path)
+ if run_stats is None:
continue
- run_analysis_name = None
total_polonies = 0
- with open(run_stats_path) as _infile:
- run_stats = json.load(_infile)
-
- # Get run name information
- run_name = run_stats.get("RunName", None)
- analysis_id = run_stats.get("AnalysisID", None)
- if run_name and analysis_id:
- run_analysis_name = "-".join([run_name, analysis_id[0:4]])
- else:
- log.error(
- "Error with RunStats.json. Either RunName or AnalysisID is absent.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
- log.debug(f"Error in RunStats.json: {run_stats_path}")
- log.debug(f"Missing: RunName: {run_name} or AnalysisID: {analysis_id}")
- continue
- # skip run if in user provider ignore list
- if self.is_ignore_sample(run_analysis_name):
- log.info(f"Skipping <{run_analysis_name}> because it is present in ignore list.")
- continue
+ # Get run name information
+ run_analysis_name = self._extract_run_analysis_name(run_stats, source_info=str(run_stats_path))
+ if run_analysis_name is None:
+ continue
- # Ensure sample stats are present
- if "SampleStats" not in run_stats:
- log.error(
- "Error, missing SampleStats in RunStats.json. Skipping index assignment metrics.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
- log.debug(f"Missing SampleStats in RunStats.json. Available keys: {list(run_stats.keys())}.")
- continue
+ # skip run if in user provider ignore list
+ if self.is_ignore_sample(run_analysis_name):
+ log.info(f"Skipping <{run_analysis_name}> because it is present in ignore list.")
+ continue
- # Extract per sample polony counts and overall total counts
- total_polonies = run_stats.get("NumPoloniesBeforeTrimming", 0)
- for sample_data in run_stats["SampleStats"]:
- sample_name = sample_data.get("SampleName")
- sample_id = None
- if run_analysis_name and sample_name:
- sample_id = "__".join([run_analysis_name, sample_name])
+ # Ensure sample stats are present
+ if "SampleStats" not in run_stats:
+ log.error(
+ f"Error, missing SampleStats in RunStats.json. Skipping index assignment metrics.\n"
+ f"Available keys: {list(run_stats.keys())}\n"
+ f"Please visit Elembio online documentation for more information - "
+ f"https://docs.elembio.io/docs/bases2fastq/introduction/"
+ )
+ continue
+
+ # Extract per sample polony counts and overall total counts
+ total_polonies = run_stats.get("NumPoloniesBeforeTrimming", 0)
+ for sample_data in run_stats["SampleStats"]:
+ sample_name = sample_data.get("SampleName")
+ sample_id = None
+ if run_analysis_name and sample_name:
+ sample_id = "__".join([run_analysis_name, sample_name])
+
+ if "Occurrences" not in sample_data:
+ log.error(f"Missing data needed to extract index assignment for sample {sample_id}. Skipping.")
+ continue
- if "Occurrences" not in sample_data:
+ for occurrence in sample_data["Occurrences"]:
+ sample_expected_seq = occurrence.get("ExpectedSequence")
+ sample_counts = occurrence.get("NumPoloniesBeforeTrimming")
+ if any([element is None for element in [sample_expected_seq, sample_counts, sample_id]]):
log.error(f"Missing data needed to extract index assignment for sample {sample_id}. Skipping.")
continue
+ if run_analysis_name not in sample_to_index_assignment:
+ sample_to_index_assignment[run_analysis_name] = {}
+ if sample_expected_seq not in sample_to_index_assignment[run_analysis_name]:
+ sample_to_index_assignment[run_analysis_name][sample_expected_seq] = {
+ "SampleID": sample_id,
+ "SamplePolonyCounts": 0,
+ "PercentOfPolonies": float("nan"),
+ "Index1": "",
+ "Index2": "",
+ }
+ sample_to_index_assignment[run_analysis_name][sample_expected_seq]["SamplePolonyCounts"] += (
+ sample_counts
+ )
- for occurrence in sample_data["Occurrences"]:
- sample_expected_seq = occurrence.get("ExpectedSequence")
- sample_counts = occurrence.get("NumPoloniesBeforeTrimming")
- if any([element is None for element in [sample_expected_seq, sample_counts, sample_id]]):
- log.error(
- f"Missing data needed to extract index assignment for sample {sample_id}. Skipping."
- )
- continue
- if run_analysis_name not in sample_to_index_assignment:
- sample_to_index_assignment[run_analysis_name] = {}
- if sample_expected_seq not in sample_to_index_assignment[run_analysis_name]:
- sample_to_index_assignment[run_analysis_name][sample_expected_seq] = {
- "SampleID": sample_id,
- "SamplePolonyCounts": 0,
- "PercentOfPolonies": float("nan"),
- "Index1": "",
- "Index2": "",
- }
- sample_to_index_assignment[run_analysis_name][sample_expected_seq]["SamplePolonyCounts"] += (
- sample_counts
- )
-
- for sample_data in sample_to_index_assignment[run_analysis_name].values():
- if total_polonies > 0:
- sample_data["PercentOfPolonies"] = round(
- sample_data["SamplePolonyCounts"] / total_polonies * 100, 2
- )
+ for sample_data in sample_to_index_assignment[run_analysis_name].values():
+ if total_polonies > 0:
+ sample_data["PercentOfPolonies"] = round(
+ sample_data["SamplePolonyCounts"] / total_polonies * 100, 2
+ )
run_manifest = json.loads(f["f"])
if "Samples" not in run_manifest:
@@ -668,6 +960,17 @@ def _parse_index_assignment(self, manifest_data_source: str) -> Dict[str, Any]:
return sample_to_index_assignment
def _parse_index_assignment_in_project(self, data_source: str) -> Dict[str, Any]:
+ """
+ Parse index assignment statistics for project-level analysis.
+
+ Similar to _parse_index_assignment but works with project-split output,
+ navigating up to find the run-level RunManifest.json.
+
+ Data Flow:
+ Project RunStats.json -> SampleStats -> polony counts
+ + ../../RunManifest.json -> Samples -> index sequences
+ -> Combined index assignment table
+ """
sample_to_index_assignment = {}
if data_source == "":
@@ -678,32 +981,17 @@ def _parse_index_assignment_in_project(self, data_source: str) -> Dict[str, Any]
if not directory:
continue
- # Get RunName and RunID from RunParameters.json
- run_manifest = Path(directory) / "../../RunManifest.json"
- if not run_manifest.exists():
- log.error(
- f"RunManifest.json could not be found in {run_manifest}. Skipping index assignment.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
- continue
+ # Get RunManifest.json from run output root (two levels up from project directory)
+ base_directory = Path(directory).parent.parent
+ run_manifest = base_directory / "RunManifest.json"
project_stats = json.loads(f["f"])
- run_analysis_name = None
- run_name = project_stats.get("RunName", None)
- analysis_id = project_stats.get("AnalysisID", None)
project = self.clean_s_name(project_stats.get("Project", "DefaultProject"), f)
- if run_name and analysis_id:
- run_analysis_name = "-".join([run_name, analysis_id[0:4]])
- else:
- log.error(
- "Error with project's RunStats.json. Either RunName or AnalysisID is absent.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
- )
- log.debug(f"Error in RunStats.json: {f['fn']}")
- log.debug(f"Missing: RunName: {run_name} or AnalysisID: {analysis_id}")
+ run_analysis_name = self._extract_run_analysis_name(
+ project_stats, source_info=f"project RunStats.json ({f['fn']})"
+ )
+ if run_analysis_name is None:
continue
# skip run if in user provider ignore list
@@ -714,11 +1002,11 @@ def _parse_index_assignment_in_project(self, data_source: str) -> Dict[str, Any]
# Ensure sample stats are present
if "SampleStats" not in project_stats:
log.error(
- "Error, missing SampleStats in RunStats.json. Skipping index assignment metrics.\n"
- "Please visit Elembio online documentation for more information - "
- "https://docs.elembio.io/docs/bases2fastq/introduction/"
+ f"Error, missing SampleStats in RunStats.json. Skipping index assignment metrics.\n"
+ f"Available keys: {list(project_stats.keys())}\n"
+ f"Please visit Elembio online documentation for more information - "
+ f"https://docs.elembio.io/docs/bases2fastq/introduction/"
)
- log.debug(f"Missing SampleStats in RunStats.json. Available keys: {list(project_stats.keys())}.")
continue
# Extract per sample polony counts and overall total counts
@@ -761,13 +1049,13 @@ def _parse_index_assignment_in_project(self, data_source: str) -> Dict[str, Any]
sample_data["SamplePolonyCounts"] / total_polonies * 100, 2
)
- run_manifest_data = None
- with open(run_manifest) as _infile:
- run_manifest_data = json.load(_infile)
+ run_manifest_data = self._read_json_file(run_manifest, base_directory=base_directory)
+ if run_manifest_data is None:
+ continue
if "Samples" not in run_manifest_data:
log.warning(
- f" section not found in {directory}/RunManifest.json.\n"
+ f" section not found in {run_manifest}.\n"
f"Skipping RunManifest sample index assignment metrics."
)
elif len(sample_to_index_assignment) == 0:
@@ -797,14 +1085,16 @@ def _parse_index_assignment_in_project(self, data_source: str) -> Dict[str, Any]
return sample_to_index_assignment
- def add_run_plots(self, data, plot_functions):
+ def add_run_plots(self, data: Dict[str, Any], plot_functions: List[Callable]) -> None:
for func in plot_functions:
plot_html, plot_name, anchor, description, helptext, plot_data = func(data, self.run_color)
self.add_section(name=plot_name, plot=plot_html, anchor=anchor, description=description, helptext=helptext)
self.write_data_file(plot_data, f"base2fastq:{plot_name}")
- def add_sample_plots(self, data, group_lookup, project_lookup):
- plot_functions = [
+ def add_sample_plots(
+ self, data: Dict[str, Any], group_lookup: Dict[str, str], project_lookup: Dict[str, str]
+ ) -> None:
+ plot_functions: List[Callable] = [
tabulate_sample_stats,
sequence_content_plot,
plot_per_cycle_N_content,
diff --git a/multiqc/modules/bbmap/bbmap.py b/multiqc/modules/bbmap/bbmap.py
index 1c908870a3..98c3d17b5a 100644
--- a/multiqc/modules/bbmap/bbmap.py
+++ b/multiqc/modules/bbmap/bbmap.py
@@ -29,8 +29,8 @@ class MultiqcModule(BaseMultiqcModule):
- Print binned coverage per location (one line per X bases).
- `scafstats` _(not yet implemented)_
- Statistics on how many reads mapped to which scaffold.
- - `refstats`
- - Statistics on how many reads mapped to which reference file; only for BBSplit.
+ - `bbsplit`
+ - Statistics on how many reads mapped to which reference genome.
- `bhist`
- Base composition histogram by position.
- `qhist`
@@ -139,6 +139,109 @@ def __init__(self):
}
self.general_stats_addcols(data, headers)
+ # BBSplit metrics in General Stats
+ if "bbsplit" in self.mod_data and len(self.mod_data["bbsplit"]) > 0:
+ bbsplit_data = {}
+ all_ref_names = set()
+ # BBSplit stats file column indices
+ BBSPLIT_COLS = [
+ "pct_unambiguous",
+ "unambiguous_mb",
+ "pct_ambiguous",
+ "ambiguous_mb",
+ "unambiguous_reads",
+ "ambiguous_reads",
+ "assigned_reads",
+ "assigned_bases",
+ ]
+
+ # First pass: collect all reference names and validate data
+ for s_name in self.mod_data["bbsplit"]:
+ sample_data = self.mod_data["bbsplit"][s_name]["data"]
+ for ref_name, values in sample_data.items():
+ # Validate array length before accessing indices
+ if len(values) < len(BBSPLIT_COLS):
+ log.warning(
+ f"BBSplit data for sample '{s_name}', reference '{ref_name}' has {len(values)} columns, "
+ f"expected {len(BBSPLIT_COLS)}. Skipping this entry."
+ )
+ continue
+ all_ref_names.add(ref_name)
+ if s_name not in bbsplit_data:
+ bbsplit_data[s_name] = {}
+ bbsplit_data[s_name][f"{ref_name}_assigned"] = values[BBSPLIT_COLS.index("assigned_reads")]
+ bbsplit_data[s_name][f"{ref_name}_unambig_pct"] = values[BBSPLIT_COLS.index("pct_unambiguous")]
+ bbsplit_data[s_name][f"{ref_name}_ambig_pct"] = values[BBSPLIT_COLS.index("pct_ambiguous")]
+ bbsplit_data[s_name][f"{ref_name}_unambig"] = values[BBSPLIT_COLS.index("unambiguous_reads")]
+ bbsplit_data[s_name][f"{ref_name}_ambig"] = values[BBSPLIT_COLS.index("ambiguous_reads")]
+
+ # Create headers for general stats (only assigned counts)
+ general_stats_headers = {}
+ for ref_name in sorted(all_ref_names):
+ general_stats_headers[f"{ref_name}_assigned"] = {
+ "title": f"{ref_name} Assigned",
+ "description": f"Total number of reads assigned to {ref_name}",
+ "scale": "Purples",
+ "shared_key": "read_count",
+ }
+ self.general_stats_addcols(bbsplit_data, general_stats_headers, namespace="bbsplit")
+
+ # Create detailed table with all BBSplit columns
+ bbsplit_table_headers = {}
+ for ref_name in sorted(all_ref_names):
+ bbsplit_table_headers[f"{ref_name}_assigned"] = {
+ "title": f"{ref_name} Assigned",
+ "description": f"Total number of reads assigned to {ref_name}",
+ "scale": "Purples",
+ "shared_key": "read_count",
+ "hidden": True,
+ }
+ bbsplit_table_headers[f"{ref_name}_unambig_pct"] = {
+ "title": f"{ref_name} % Unambig",
+ "description": f"Percentage of reads unambiguously aligned to {ref_name}",
+ "suffix": "%",
+ "scale": "Greens",
+ "min": 0,
+ "max": 100,
+ }
+ bbsplit_table_headers[f"{ref_name}_ambig_pct"] = {
+ "title": f"{ref_name} % Ambig",
+ "description": f"Percentage of reads ambiguously aligned to {ref_name}",
+ "suffix": "%",
+ "scale": "Oranges",
+ "min": 0,
+ "max": 100,
+ }
+ bbsplit_table_headers[f"{ref_name}_unambig"] = {
+ "title": f"{ref_name} Unambig",
+ "description": f"Number of reads unambiguously aligned to {ref_name}",
+ "scale": "Blues",
+ "shared_key": "read_count",
+ "hidden": True,
+ }
+ bbsplit_table_headers[f"{ref_name}_ambig"] = {
+ "title": f"{ref_name} Ambig",
+ "description": f"Number of reads ambiguously aligned to {ref_name}",
+ "scale": "Reds",
+ "shared_key": "read_count",
+ "hidden": True,
+ }
+
+ self.add_section(
+ name="BBSplit Statistics",
+ anchor="bbmap-bbsplit-stats",
+ description="Statistics on how many reads mapped to which reference genome.",
+ plot=table.plot(
+ bbsplit_data,
+ bbsplit_table_headers,
+ {
+ "id": "bbsplit_stats_table",
+ "namespace": "BBTools",
+ "title": "BBTools: BBSplit Statistics",
+ },
+ ),
+ )
+
def parse_logs(self, file_type, root, s_name, fn, f, **kw):
if self.is_ignore_sample(s_name):
return False
diff --git a/multiqc/modules/bbmap/bbmap_filetypes.py b/multiqc/modules/bbmap/bbmap_filetypes.py
index 20151d149e..cfcca1b00d 100644
--- a/multiqc/modules/bbmap/bbmap_filetypes.py
+++ b/multiqc/modules/bbmap/bbmap_filetypes.py
@@ -4,6 +4,7 @@
from .plot_aqhist import plot_aqhist
from .plot_basic_hist import plot_basic_hist
+from .plot_bbsplit import plot_bbsplit
from .plot_bhist import plot_bhist
from .plot_bqhist import plot_bqhist
from .plot_covhist import plot_covhist
@@ -37,6 +38,7 @@
"rpkm",
"statsfile_machine",
"statsfile",
+ "bbsplit",
]
file_types: Dict = {
"stats": {
@@ -420,6 +422,24 @@
"plot_func": plot_basic_hist,
"not_implemented": "",
},
+ "bbsplit": {
+ "title": "BBSplit alignment statistics",
+ "descr": "Statistics on how many reads mapped to which reference genome.",
+ "help_text": "Shows the percentage and count of reads aligned to each reference genome.",
+ "cols": {
+ "name": str,
+ "%unambiguousReads": float,
+ "unambiguousMB": float,
+ "%ambiguousReads": float,
+ "ambiguousMB": float,
+ "unambiguousReads": int,
+ "ambiguousReads": int,
+ "assignedReads": int,
+ "assignedBases": int,
+ },
+ "plot_params": {},
+ "plot_func": plot_bbsplit,
+ },
}
statsfile_machine_keys = [
diff --git a/multiqc/modules/bbmap/plot_bbsplit.py b/multiqc/modules/bbmap/plot_bbsplit.py
new file mode 100644
index 0000000000..2cedf8a93e
--- /dev/null
+++ b/multiqc/modules/bbmap/plot_bbsplit.py
@@ -0,0 +1,46 @@
+"""MultiQC submodule to plot BBSplit alignment distribution"""
+
+from multiqc.plots import bargraph
+
+# BBSplit stats file column indices
+BBSPLIT_COLS = [
+ "pct_unambiguous",
+ "unambiguous_mb",
+ "pct_ambiguous",
+ "ambiguous_mb",
+ "unambiguous_reads",
+ "ambiguous_reads",
+ "assigned_reads",
+ "assigned_bases",
+]
+
+
+def plot_bbsplit(samples, file_type, plot_title, plot_params):
+ """Create a stacked bar chart showing read distribution across reference genomes"""
+
+ # Prepare data structure for bargraph
+ data = {}
+ cats = {}
+
+ for s_name, sample in samples.items():
+ data[s_name] = {}
+
+ for ref_name, values in sample["data"].items():
+ assigned_reads = values[BBSPLIT_COLS.index("assigned_reads")]
+ data[s_name][ref_name] = assigned_reads
+
+ # Define category (reference genome) if not already done
+ if ref_name not in cats:
+ cats[ref_name] = {"name": ref_name}
+
+ # Configure the plot
+ pconfig = {
+ "id": "bbmap-" + file_type + "_plot",
+ "title": "BBTools: " + plot_title,
+ "ylab": "Number of Reads",
+ "cpswitch_counts_label": "Number of Reads",
+ "cpswitch_percent_label": "Percentage of Reads",
+ }
+ pconfig.update(plot_params)
+
+ return bargraph.plot(data, cats, pconfig)
diff --git a/multiqc/modules/biscuit/biscuit.py b/multiqc/modules/biscuit/biscuit.py
index 03955f1927..eff48f057a 100644
--- a/multiqc/modules/biscuit/biscuit.py
+++ b/multiqc/modules/biscuit/biscuit.py
@@ -9,8 +9,10 @@
class MultiqcModule(BaseMultiqcModule):
"""
- The module parses logs generated by BISCUIT and the quality control script, QC.sh, included with
- the BISCUIT software.
+ The module parses logs generated by the BISCUIT quality control script, `QC.sh`, which wraps
+ `biscuit qc` and `biscuit qc_coverage` and adds an extra metric of base-averaged cytosine
+ retention. It will search for all files output from `QC.sh`, though the user may run
+ `biscuit qc` or `biscuit qc_coverage` separately, if desired.
**Note**: As of MultiQC v1.9, the module supports only BISCUIT version v0.3.16 and onwards.
If you have BISCUIT data from before this, please use MultiQC v1.8.
@@ -30,12 +32,12 @@ def __init__(self):
anchor="biscuit",
href="https://github.com/huishenlab/biscuit",
info="Maps bisulfite converted DNA sequence reads and determines cytosine methylation states.",
- # Can't find a DOI // doi=
+ doi="10.1093/nar/gkae097",
)
- # Set up data structures
- self.mdata = {
- # General statistics
+ # Set up data structures for collected data
+ self.biscuit_data = {
+ # General alignment statistics
"align_mapq": {},
"align_strand": {},
"align_isize": {},
@@ -64,214 +66,203 @@ def __init__(self):
"read_avg_retention_rate": {},
}
- # NB: Cleaning filenames like this means that some MultiQC functionality, like -s / --fullnames doesn't work.
- # However, because some of the parsing relies on cleaned filenames, the above options break the
- # module if we use the centralised MultiQC functions.
- file_suffixes = [
- # General statistics
- ".txt",
- "_mapq_table",
- "_strand_table",
- "_isize_table",
- # Duplicate reporting
- "_dup_report",
- # Uniformity
- "_cv_table",
- # Base coverage
- "_covdist_all_base_botgc_table",
- "_covdist_all_base_table",
- "_covdist_all_base_topgc_table",
- "_covdist_q40_base_botgc_table",
- "_covdist_q40_base_table",
- "_covdist_q40_base_topgc_table",
- # CpG coverage
- "_covdist_all_cpg_botgc_table",
- "_covdist_all_cpg_table",
- "_covdist_all_cpg_topgc_table",
- "_covdist_q40_cpg_botgc_table",
- "_covdist_q40_cpg_table",
- "_covdist_q40_cpg_topgc_table",
- # Cytosine retention
- "_CpGRetentionByReadPos",
- "_CpHRetentionByReadPos",
- "_totalBaseConversionRate",
- "_totalReadConversionRate",
+ file_types = [
+ # Mapping quality distribution
+ ("align_mapq", parse_align_mapq),
+ # Bisulfite strand (OT/CTOT + OB/CTOB) distribution
+ ("align_strand", parse_align_strand),
+ # Insert size distribution
+ ("align_isize", parse_align_isize),
+ # Duplicate rates
+ ("dup_report", parse_dup_report),
+ # Coefficient of variation table
+ ("qc_cv", parse_qc_cv),
+ # Coverage distribution - all bases, bottom GC-content
+ ("covdist_all_base_botgc", parse_covdist),
+ # Coverage distribution - all bases
+ ("covdist_all_base", parse_covdist),
+ # Coverage distribution - all bases, top GC-content
+ ("covdist_all_base_topgc", parse_covdist),
+ # Coverage distribution - q40 bases, bottom GC-content
+ ("covdist_q40_base_botgc", parse_covdist),
+ # Coverage distribution - q40 bases
+ ("covdist_q40_base", parse_covdist),
+ # Coverage distribution - q40 bases, top GC-content
+ ("covdist_q40_base_topgc", parse_covdist),
+ # Coverage distribution - all cpgs, bottom GC-content
+ ("covdist_all_cpg_botgc", parse_covdist),
+ # Coverage distribution - all cpgs
+ ("covdist_all_cpg", parse_covdist),
+ # Coverage distribution - all cpgs, top GC-content
+ ("covdist_all_cpg_topgc", parse_covdist),
+ # Coverage distribution - q40 cpgs, bottom GC-content
+ ("covdist_q40_cpg_botgc", parse_covdist),
+ # Coverage distribution - q40 cpgs
+ ("covdist_q40_cpg", parse_covdist),
+ # Coverage distribution - q40 cpgs, top GC-content
+ ("covdist_q40_cpg_topgc", parse_covdist),
+ # CpG Retention by Position in Read
+ ("cpg_retention_readpos", parse_retention_readpos),
+ # CpH Retention by Position in Read
+ ("cph_retention_readpos", parse_retention_readpos),
+ # Base-averaged cytosine retention rate
+ ("base_avg_retention_rate", parse_avg_retention),
+ # Read-averaged cytosine retention rate
+ ("read_avg_retention_rate", parse_avg_retention),
]
- # Find and parse alignment reports
- for k in self.mdata:
- for f in self.find_log_files(f"biscuit/{k}"):
- s_name = f["fn"]
- for suffix in file_suffixes:
- s_name = s_name.replace(suffix, "")
- s_name = self.clean_s_name(s_name, f)
-
- # Add source file to multiqc_sources.txt
- self.add_data_source(f, s_name=s_name, section=k)
-
- if s_name in self.mdata[k]:
- log.debug(f"Duplicate sample name found in {f['fn']}! Overwriting: {s_name}")
-
- self.mdata[k][s_name] = getattr(self, f"parse_logs_{k}")(f["f"], f["fn"])
-
- for k in self.mdata:
- self.mdata[k] = self.ignore_samples(self.mdata[k])
-
- n_samples = max([len(self.mdata[k]) for k in self.mdata])
+ n_covdist_samples = 0
+ for file_type, parser_func in file_types:
+ collected_data = {}
+ for f in self.find_log_files(f"biscuit/{file_type}"):
+ parsed_data = parser_func(f["f"], f["fn"])
+ if parsed_data is not None:
+ s_name = self.clean_s_name(f["s_name"], f)
+ if self.is_ignore_sample(s_name):
+ continue
+
+ if s_name in collected_data:
+ log.debug(f"Duplicate sample name found in {f['fn']}! Overwriting: {s_name}")
+
+ # Add source file to multiqc_sources.txt
+ self.add_data_source(f, s_name=s_name, section=file_type)
+ collected_data[s_name] = parsed_data
+
+ self.biscuit_data[file_type] = collected_data
+
+ # Count the number of coverage distribution samples (denoted by "covdist_*")
+ #
+ # Perform counting here to reduce having to add 12 values together from the biscuit_data
+ # dictionary later
+ if file_type.startswith("covdist_"):
+ n_covdist_samples += len(collected_data)
+
+ # Retention report counts (retention by read position and average retention rates)
+ n_retention_readpos = len(self.biscuit_data["cpg_retention_readpos"]) + len(
+ self.biscuit_data["cph_retention_readpos"]
+ )
+ n_avg_retention = len(self.biscuit_data["base_avg_retention_rate"]) + len(
+ self.biscuit_data["read_avg_retention_rate"]
+ )
+ n_samples = max([len(self.biscuit_data[k]) for k in self.biscuit_data.keys()])
if n_samples == 0:
raise ModuleNoSamplesFound
-
log.info(f"Found {n_samples} samples")
- # Basic stats table
+ # Setup general statistics table columns
self.biscuit_stats_table()
- # Write data to file
- self.write_data_file(self.mdata, "biscuit")
-
# Superfluous function call to confirm that it is used in this module
# Replace None with actual version if it is available
self.add_software_version(None)
# Make report sections
- for k in self.mdata:
- if len(self.mdata[k]) > 0:
- log.debug(f"Found {len(self.mdata[k])} {k} reports")
- getattr(self, f"chart_{k}")()
+ if len(self.biscuit_data["align_mapq"]) > 0:
+ log.info(f"Found {len(self.biscuit_data['align_mapq'])} BISCUIT MAPQ reports")
+ self.chart_align_mapq()
+ if len(self.biscuit_data["align_strand"]) > 0:
+ log.info(f"Found {len(self.biscuit_data['align_strand'])} BISCUIT bisulfite strand reports")
+ self.chart_align_strand()
+ if len(self.biscuit_data["align_isize"]) > 0:
+ log.info(f"Found {len(self.biscuit_data['align_isize'])} BISCUIT insert size reports")
+ self.chart_align_isize()
+ if len(self.biscuit_data["dup_report"]) > 0:
+ log.info(f"Found {len(self.biscuit_data['dup_report'])} BISCUIT duplicate reports")
+ self.chart_dup_report()
+ if len(self.biscuit_data["qc_cv"]) > 0:
+ log.info(f"Found {len(self.biscuit_data['qc_cv'])} BISCUIT coefficient of variation reports")
+ self.chart_qc_cv()
+ if n_covdist_samples > 0:
+ log.info(f"Found {n_covdist_samples} BISCUIT coverage distribution reports")
+ self.chart_covdist()
+ if n_retention_readpos > 0:
+ log.info(f"Found {n_retention_readpos} BISCUIT retention by read position reports")
+ self.chart_retention_readpos()
+ if n_avg_retention > 0:
+ log.info(f"Found {n_avg_retention} BISCUIT average cytosine retention reports")
+ self.chart_avg_retention()
+
+ # Write data to file
+ self.write_data_file(self.biscuit_data, "biscuit", data_format="yaml")
def biscuit_stats_table(self):
- """
- Create general statistics table for BISCUIT data
- Inputs:
- Uses mdata['align_mapq'] and mdata['dup_report']
- Returns:
- Add columns to MultiQC general statistics table
- """
+ """BISCUIT data for the general statistics table"""
pd = {}
- # Calculate % aligned
- for s_name, dd in self.mdata["align_mapq"].items():
- if len(dd) > 0:
- pd[s_name] = {"aligned": dd["frc_align"]}
+ # Calculate percent aligned
+ for s_name, data in self.biscuit_data["align_mapq"].items():
+ pd[s_name] = {"aligned": data["frac_align"]}
- # Calculate % duplicated
- for s_name, dd in self.mdata["dup_report"].items():
+ # Calculate percent duplicated
+ for s_name, data in self.biscuit_data["dup_report"].items():
if s_name not in pd:
pd[s_name] = {}
- if "all" in dd and dd["all"] != -1:
- pd[s_name]["dup_all"] = dd["all"]
- if "q40" in dd and dd["q40"] != -1:
- pd[s_name]["dup_q40"] = dd["q40"]
+ if "all" in data:
+ pd[s_name]["dup_all"] = data["all"]
+ if "q40" in data:
+ pd[s_name]["dup_q40"] = data["q40"]
- pheader = {
+ header = {
+ "aligned": {
+ "title": "% Aligned",
+ "description": "Percentage of Reads Aligned",
+ "min": 0,
+ "max": 100,
+ "scale": "YlGn",
+ "suffix": "%",
+ },
"dup_q40": {
"title": "Dup. % for Q40 Reads",
+ "description": "Percentage of Duplicate Reads with MAPQ >= 40",
"min": 0,
"max": 100,
+ "scale": "Reds",
"suffix": "%",
- "scale": "YlOrBr",
"hidden": True,
},
- "dup_all": {"title": "Dup. % for All Reads", "min": 0, "max": 100, "suffix": "%", "scale": "Reds"},
- "aligned": {
- "title": "% Aligned",
+ "dup_all": {
+ "title": "Dup. % for All Reads",
+ "description": "Percentage of Duplicate Reads",
"min": 0,
"max": 100,
+ "scale": "Reds",
"suffix": "%",
- "scale": "RdYlGn",
- "format": "{:,.2f}",
},
}
- self.general_stats_addcols(pd, pheader)
-
- ########################################
- ##### General Mapping Information #####
- ########################################
- @staticmethod
- def parse_logs_align_mapq(f, fn):
- """
- Parse _mapq_table.txt
- Inputs:
- f - current matched file
- fn - filename
- Returns:
- data - dictionary of aligned mapq data
- """
- file_data = f.splitlines()[2:]
-
- # Handle missing data
- if len(file_data) == 0:
- log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
- return {}
-
- mapq = {}
- for line in file_data:
- s = line.split()
- mapq[s[0]] = s[1] # mapq[MAPQ] = number of reads
-
- data = {
- "frc_align": 0,
- "opt_align": 0,
- "sub_align": 0,
- "not_align": 0,
- "mapqs": dict(zip(range(61), [0 for _ in range(61)])),
- }
- if len(mapq) > 0:
- total = sum([int(cnt) for _, cnt in mapq.items() if _ != "unmapped"])
- for mq, cnt in mapq.items():
- if mq == "unmapped":
- data["not_align"] += int(cnt)
- else:
- data["mapqs"][int(mq)] = 100.0 * float(cnt) / total
- if int(mq) >= 40:
- data["opt_align"] += int(cnt)
- else:
- data["sub_align"] += int(cnt)
-
- data["frc_align"] = (
- 100
- * (data["opt_align"] + data["sub_align"])
- / (data["opt_align"] + data["sub_align"] + data["not_align"])
- )
+ self.general_stats_addcols(pd, header)
- return data
-
- ########################################
- #### Alignment Quality Report ####
- ########################################
+ ################################################################################
+ ## Plotting Functions ##
+ ################################################################################
def chart_align_mapq(self):
- """
- Chart _mapq_table.txt
- Inputs:
- No inputs
- Returns:
- No returns, generates Mapping Overview and Mapping Quality
- Distribution charts
- """
-
- #
- # Mapping Overview bar chart
- #
- pd = {}
-
- # Calculate alignment counts
- for s_name, dd in self.mdata["align_mapq"].items():
- if len(dd) > 0:
- pd[s_name] = {"opt_align": dd["opt_align"], "sub_align": dd["sub_align"], "not_align": dd["not_align"]}
-
- pheader = {
+ """Mapping overview plots"""
+ # Extract data
+ pd1 = {}
+ pd2 = {}
+ for s_name, d in self.biscuit_data["align_mapq"].items():
+ if len(d) > 0:
+ pd1[s_name] = {
+ "opt_align": d["opt_align"],
+ "sub_align": d["sub_align"],
+ "not_align": d["not_align"],
+ }
+ pd2[s_name] = d["mapqs"]
+
+ # Mapping Overview bar graph
+ pheader1 = {
"opt_align": {"color": "#1f78b4", "name": "Optimally Aligned Reads"},
"sub_align": {"color": "#a6cee3", "name": "Suboptimally Aligned Reads"},
"not_align": {"color": "#b2df8a", "name": "Unaligned Reads"},
}
- pconfig = {
+ pconfig1 = {
"id": "biscuit-mapping-overview-plot",
"title": "BISCUIT: Mapping Overview",
"ylab": "Number of Reads",
"cpswitch_counts_label": "# Reads",
+ "tt_decimals": 0,
}
self.add_section(
@@ -286,28 +277,19 @@ def chart_align_mapq(self):
that are optimally aligned. Note, suboptimally aligned reads
include both non-unique alignments and imperfect alignments.
""",
- plot=bargraph.plot(pd, pheader, pconfig),
+ plot=bargraph.plot(pd1, pheader1, pconfig1),
)
- #
# Mapping Quality Distribution line graph
- #
-
- # Calculate the % aligned for each mapping q score
- pd_mapq = {}
- for s_name, dd in self.mdata["align_mapq"].items():
- if len(dd) > 0:
- pd_mapq[s_name] = dd["mapqs"]
-
- pconfig = {
- "id": "biscuit_mapq",
+ pconfig2 = {
+ "id": "biscuit-mapq-plot",
"title": "BISCUIT: Distribution of Mapping Qualities",
"ymin": 0,
"xmin": 0,
"tt_label": "Q{point.x}: {point.y:.2f}% of mapped reads",
"ysuffix": "%",
"ylab": "% of primary mapped reads",
- "xlab": "Mapping quality score",
+ "xlab": "Mapping Quality Score",
}
self.add_section(
@@ -321,144 +303,61 @@ def chart_align_mapq(self):
A good quality sample should have a high quality mapping score
for the majority of alignments.
""",
- plot=linegraph.plot(pd_mapq, pconfig),
+ plot=linegraph.plot(pd2, pconfig2),
)
- ########################################
- #### Strand Alignment Report ####
- ########################################
- def parse_logs_align_strand(self, f, fn):
- """
- Parse _strand_table.txt
- Inputs:
- f - current matched file
- fn - filename
- Returns:
- data - dictionary of strand data for reads 1 and 2
- """
- patterns = [
- r"(R1)\s+\((f)\)\:\s+(\d+)\s+(\d+)",
- r"(R1)\s+\((r)\)\:\s+(\d+)\s+(\d+)",
- r"(R2)\s+\((f)\)\:\s+(\d+)\s+(\d+)",
- r"(R2)\s+\((r)\)\:\s+(\d+)\s+(\d+)",
- ]
-
- data = {"read1": {}, "read2": {}}
- for pat in patterns:
- m = re.search(pat, f, re.MULTILINE)
- if m is not None:
- if m.group(1) == "R1":
- if m.group(2) == "f":
- data["read1"]["ff"] = int(m.group(3))
- data["read1"]["fr"] = int(m.group(4))
- else:
- data["read1"]["rf"] = int(m.group(3))
- data["read1"]["rr"] = int(m.group(4))
- else:
- if m.group(2) == "f":
- data["read2"]["ff"] = int(m.group(3))
- data["read2"]["fr"] = int(m.group(4))
- else:
- data["read2"]["rf"] = int(m.group(3))
- data["read2"]["rr"] = int(m.group(4))
-
- return data
-
def chart_align_strand(self):
- """
- Chart _strand_table.txt
- Inputs:
- No inputs
- Returns:
- No returns, generates Mapping Strand Distribution chart
- """
-
+ """Chart _strand_table.txt"""
pd1 = {}
pd2 = {}
- for s_name, dd in self.mdata["align_strand"].items():
- if len(dd["read1"]) > 0:
- pd1[s_name] = dd["read1"]
- if len(dd["read2"]) > 0:
- pd2[s_name] = dd["read2"]
+ for s_name, d in self.biscuit_data["align_strand"].items():
+ if len(d["read1"]) > 0:
+ pd1[s_name] = d["read1"]
+ if len(d["read2"]) > 0:
+ pd2[s_name] = d["read2"]
pheader = {
- "ff": {"color": "#F53855", "name": "ff: Watson-Aligned, Watson-Bisulfite Conversion"},
- "fr": {"color": "#E37B40", "name": "fr: Watson-Aligned, Crick-Bisulfite Conversion"},
- "rf": {"color": "#46B29D", "name": "rf: Crick-Aligned, Watson-Bisulfite Conversion"},
- "rr": {"color": "#324D5C", "name": "rr: Crick-Aligned, Crick-Bisulfite Conversion"},
+ "ff": {"color": "#F53855", "name": "OT: Original Top strand"},
+ "fr": {"color": "#E37B40", "name": "CTOT: Complement to the Original Top strand"},
+ "rf": {"color": "#46B29D", "name": "CTOB: Complement to the Original Bottom strand"},
+ "rr": {"color": "#324D5C", "name": "OB: Original Bottom strand"},
}
pconfig = {
- "id": "biscuit_strands",
+ "id": "biscuit-strand-plot",
"title": "BISCUIT: Mapping Strand Distribution",
"ylab": "Number of Reads",
"cpswitch_counts_label": "# Reads",
+ "tt_decimals": 0,
"data_labels": [{"name": "Read 1"}, {"name": "Read 2"}],
}
- # TODO: When PBAT mode is implemented, add comment in help text about
- # how to interpret PBAT mode results
if max(len(pd1), len(pd2)) > 0:
self.add_section(
name="Mapping Strand Distribution",
- anchor="biscuit-strands",
+ anchor="biscuit-strand",
description="For primary alignments, shows the number of reads mapped to each strand.",
helptext="""
- Most bisulfite libraries typically map Read 1 to the parent
- strand (`ff`, `rr`) and Read 2 to the daughter / synthesized
- strand (`fr`, `rf`).
+ Most bisulfite libraries typically map Read 1 to the original
+ strand (`OT`, `OB`) and Read 2 to the synthesized complement
+ strand (`CTOT`, `CTOB`).
- Note that PBAT and many single-cell / low input
- libraries may not follow this assumption.
+ Note that PBAT or PBAT-based libraries (like single-cell or
+ other low-inputs preps) usually map to the opposite set of
+ strands - Read 1 maps to CTOT/CTOB and Read 2 maps to OT/OB.
""",
plot=bargraph.plot([pd1, pd2], [pheader, pheader], pconfig),
)
- ########################################
- #### Insert Size Report ####
- ########################################
- @staticmethod
- def parse_logs_align_isize(f, fn):
- """
- Parse _isize_table.txt
- Inputs:
- f - current matched file
- fn - filename
- Returns:
- data - dictionary of insert size data
- """
- file_data = f.splitlines()[2:]
-
- # Handle missing data
- if len(file_data) == 0:
- log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
- return {"no_data_available": 1}
-
- data = {"percent": {}, "readcnt": {}}
- for line in file_data:
- fields = line.split("\t")
- data["percent"][int(fields[0])] = 100.0 * float(fields[1])
- data["readcnt"][int(fields[0])] = float(fields[2])
-
- return data
-
def chart_align_isize(self):
- """
- Chart _isize_table.txt
- Inputs:
- No inputs
- Returns:
- No returns, generates Insert Size Distribution chart
- """
-
- pd_p = {}
- pd_r = {}
- for s_name, dd in self.mdata["align_isize"].items():
- if "no_data_available" not in dd.keys():
- pd_p[s_name] = dd["percent"]
- pd_r[s_name] = dd["readcnt"]
+ """Chart _isize_table.txt"""
+ pd1 = {}
+ pd2 = {}
+ for s_name, d in self.biscuit_data["align_isize"].items():
+ pd1[s_name] = d["percent"]
+ pd2[s_name] = d["readcnt"]
pconfig = {
- "id": "biscuit_isize",
+ "id": "biscuit-isize-plot",
"title": "BISCUIT: Insert Size Distribution",
"ymin": 0,
"xmin": 0,
@@ -494,60 +393,27 @@ def chart_align_isize(self):
Insert sizes are calculated for reads with a _"mapped in
proper pair"_ `samtools` flag, and `MAPQ >= 40`.
""",
- plot=linegraph.plot([pd_p, pd_r], pconfig),
+ plot=linegraph.plot([pd1, pd2], pconfig),
)
- ########################################
- #### Duplicate Report ####
- ########################################
- @staticmethod
- def parse_logs_dup_report(f, fn):
- """
- Parses _dup_report.txt
- Inputs:
- f - current matched file
- fn - filename
- Returns:
- data - dictionary of duplicate fractions
- """
- patterns = [
- (r"Number of duplicate reads:\s+(\d+)", r"Number of reads:\s+(\d+)", "all"),
- (r"Number of duplicate q40-reads:\s+(\d+)", r"Number of q40-reads:\s+(\d+)", "q40"),
- ]
-
- data = {}
- for pat_dup, pat_tot, key in patterns:
- m1 = re.search(pat_dup, f, re.MULTILINE)
- m2 = re.search(pat_tot, f, re.MULTILINE)
- if m1 is not None and m2 is not None:
- data[key] = 100.0 * float(m1.group(1)) / float(m2.group(1))
- else:
- data[key] = -1
-
- return data
-
def chart_dup_report(self):
- """
- Charts _dup_report.txt
- Inputs:
- No inputs
- Returns:
- No returns, generates Duplicate Rates chart
- """
-
- pd1 = {} # Overall duplicate rate
- pd2 = {} # MAPQ>=40 duplicate rate
- for s_name, dd in self.mdata["dup_report"].items():
- if "all" in dd and dd["all"] != -1:
- pd1[s_name] = {"dup_rate": dd["all"]}
- if "q40" in dd and dd["q40"] != -1:
- pd2[s_name] = {"dup_rate": dd["q40"]}
+ """Charts _dup_report.txt"""
+ pd1 = {}
+ pd2 = {}
+ for s_name, d in self.biscuit_data["dup_report"].items():
+ # Duplicate rates of zero are more likely to be missing data than a
+ # real value of zero. These are included in the general stats table
+ # above, so they are at least retained in part for the user.
+ if "all" in d and d["all"] > 0:
+ pd1[s_name] = {"dup_rate": d["all"]}
+ if "q40" in d and d["q40"] > 0:
+ pd2[s_name] = {"dup_rate": d["q40"]}
pheader = {
"dup_rate": {"color": "#a50f15", "name": "Duplicate Rate"},
}
pconfig = {
- "id": "biscuit_dup_report",
+ "id": "biscuit-dup-report-plot",
"cpswitch": False,
"cpswitch_c_active": False,
"title": "BISCUIT: Percentage of Duplicate Reads",
@@ -561,64 +427,25 @@ def chart_dup_report(self):
"tt_suffix": "%",
}
- if len(pd1) > 0:
+ if len(pd1) > 0 or len(pd2) > 0:
self.add_section(
name="Duplicate Rates",
anchor="biscuit-dup-report",
- description="Shows the percentage of total reads that are duplicates.",
+ description="Shows the percentage of reads that are duplicates.",
helptext="""
`MAPQ >= 40` shows the duplicate rate for just the reads
- with a mapping quality score of `MAPQ >= 40`.
+ with a mapping quality score of `MAPQ >= 40`. `All` shows
+ the overall duplicate rate. Samples with a duplicate rate of
+ zero are excluded from the plot, though they are retained in
+ the general stats table. These are likely due to missing data
+ rather than a true duplicate rate of zero and are excluded
+ for this reason.
""",
plot=bargraph.plot([pd1, pd2], [pheader, pheader], pconfig),
)
- ########################################
- #### Depths and Uniformity ####
- ########################################
- @staticmethod
- def parse_logs_qc_cv(f, fn):
- """
- Parses _cv_table.txt
- Inputs:
- f - current matched file
- fn - filename
- Returns:
- data - dictionary of depth uniformity measures
- """
-
- data = {}
- targets = [
- "all_base",
- "all_cpg",
- "q40_base",
- "q40_cpg",
- "all_base_botgc",
- "all_cpg_botgc",
- "q40_base_botgc",
- "q40_cpg_botgc",
- "all_base_topgc",
- "all_cpg_topgc",
- "q40_base_topgc",
- "q40_cpg_topgc",
- ]
- for t in targets:
- m = re.search(rf"{t}\t([\d\.]+)\t([\d\.]+)\t([\d\.]+)", f, re.MULTILINE)
- if m is not None:
- data[t] = {"mu": float(m.group(1)), "sigma": float(m.group(2)), "cv": float(m.group(3))}
- else:
- data[t] = {"mu": -1, "sigma": -1, "cv": -1}
-
- return data
-
def chart_qc_cv(self):
- """
- Charts _cv_table.txt
- Inputs:
- No inputs
- Returns:
- No returns, generates Sequencing Depth - Whole Genome chart
- """
+ """Charts _cv_table.txt"""
cats = [
("all_base", "a_b"),
@@ -635,14 +462,13 @@ def chart_qc_cv(self):
("q40_cpg_topgc", "q_c_t"),
]
- pd = dict()
- for s_name, dd in self.mdata["qc_cv"].items():
- data = dict()
+ pd = {}
+ for s_name, d in self.biscuit_data["qc_cv"].items():
+ data = {}
for cat, key in cats:
- if cat in dd:
- if dd[cat]["mu"] != -1:
- data["mu_" + key] = dd[cat]["mu"]
- data["cv_" + key] = dd[cat]["cv"]
+ if cat in d:
+ data["mu_" + key] = d[cat]["mu"]
+ data["cv_" + key] = d[cat]["cv"]
if len(data) > 0:
pd[s_name] = data
@@ -787,8 +613,9 @@ def chart_qc_cv(self):
},
),
}
+
pconfig = {
- "id": "biscuit_seq_depth",
+ "id": "biscuit-seq-depth-plot",
"title": "BISCUIT: Sequencing Depth",
"sort_rows": False,
}
@@ -799,7 +626,7 @@ def chart_qc_cv(self):
anchor="biscuit-seq-depth",
description="""
Shows the sequence depth mean and uniformity measured by the Coefficient of Variation
- (`CoV`, defined as `stddev/mean`).
+ (`CoV`, defined as `(std. dev.) / mean`).
""",
helptext="""
The plot shows coverage across different selections:
@@ -815,138 +642,30 @@ def chart_qc_cv(self):
plot=violin.plot(pd, pheader, pconfig),
)
- ########################################
- #### Base Coverage and CpG Coverage ####
- ########################################
- @staticmethod
- def parse_logs_covdist_all_base(f, fn):
- """
- Parses _covdist_all_base_botgc_table.txt
- _covdist_all_base_table.txt
- _covdist_all_base_topgc_table.txt
- _covdist_all_cpg_botgc_table.txt
- _covdist_all_cpg_table.txt
- _covdist_all_cpg_topgc_table.txt
- _covdist_q40_base_botgc_table.txt
- _covdist_q40_base_table.txt
- _covdist_q40_base_topgc_table.txt
- _covdist_q40_cpg_botgc_table.txt
- _covdist_q40_cpg_table.txt
- _covdist_q40_cpg_topgc_table.txt
- Inputs:
- f - current matched file
- fn - filename
- Returns:
- data - dictionary of coverage distributions up to 30X data
- """
- file_data = f.splitlines()[2:]
-
- # Handle missing data
- if len(file_data) == 0:
- log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
- return dict(zip([i for i in range(31)], [-1 for _ in range(31)]))
-
- dd = {}
- for line in file_data:
- fields = line.split()
- dd[int(float(fields[0]))] = int(float(fields[1]))
-
- covs = sorted([k for k in dd])[:31]
- _ccov_cnt = sum(dd.values())
-
- ccov_cnts = []
- for cov in covs:
- ccov_cnts.append(_ccov_cnt / 1000000.0)
- _ccov_cnt -= dd[cov]
-
- return dict(zip(covs, ccov_cnts))
-
- def parse_logs_covdist_all_base_botgc(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def parse_logs_covdist_all_base_topgc(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def parse_logs_covdist_q40_base(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def parse_logs_covdist_q40_base_botgc(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def parse_logs_covdist_q40_base_topgc(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def parse_logs_covdist_all_cpg(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def parse_logs_covdist_all_cpg_botgc(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def parse_logs_covdist_all_cpg_topgc(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def parse_logs_covdist_q40_cpg(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def parse_logs_covdist_q40_cpg_botgc(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def parse_logs_covdist_q40_cpg_topgc(self, f, fn):
- """Handled by parse_logs_covdist_all_base()"""
- return self.parse_logs_covdist_all_base(f, fn)
-
- def chart_covdist_all_base(self):
- """
- Charts _covdist_all_base_botgc_table.txt
- _covdist_all_base_table.txt
- _covdist_all_base_topgc_table.txt
- _covdist_all_cpg_botgc_table.txt
- _covdist_all_cpg_table.txt
- _covdist_all_cpg_topgc_table.txt
- _covdist_q40_base_botgc_table.txt
- _covdist_q40_base_table.txt
- _covdist_q40_base_topgc_table.txt
- _covdist_q40_cpg_botgc_table.txt
- _covdist_q40_cpg_table.txt
- _covdist_q40_cpg_topgc_table.txt
- Inputs:
- No inputs
- Returns:
- No returns, generates Cumulative Coverage chart
- """
-
+ def chart_covdist(self):
+ """Charts _covdist_*.txt"""
pd = [
- self.mdata["covdist_all_base"],
- self.mdata["covdist_q40_base"],
- self.mdata["covdist_all_cpg"],
- self.mdata["covdist_q40_cpg"],
- self.mdata["covdist_all_base_botgc"],
- self.mdata["covdist_q40_base_botgc"],
- self.mdata["covdist_all_cpg_botgc"],
- self.mdata["covdist_q40_cpg_botgc"],
- self.mdata["covdist_all_base_topgc"],
- self.mdata["covdist_q40_base_topgc"],
- self.mdata["covdist_all_cpg_topgc"],
- self.mdata["covdist_q40_cpg_topgc"],
+ self.biscuit_data["covdist_all_base"],
+ self.biscuit_data["covdist_q40_base"],
+ self.biscuit_data["covdist_all_cpg"],
+ self.biscuit_data["covdist_q40_cpg"],
+ self.biscuit_data["covdist_all_base_botgc"],
+ self.biscuit_data["covdist_q40_base_botgc"],
+ self.biscuit_data["covdist_all_cpg_botgc"],
+ self.biscuit_data["covdist_q40_cpg_botgc"],
+ self.biscuit_data["covdist_all_base_topgc"],
+ self.biscuit_data["covdist_q40_base_topgc"],
+ self.biscuit_data["covdist_all_cpg_topgc"],
+ self.biscuit_data["covdist_q40_cpg_topgc"],
]
pconfig = {
- "id": "biscuit_cumulative",
+ "id": "biscuit-cumulative-coverage-plot",
"title": "BISCUIT: Cumulative Coverage",
"ymin": 0,
"tt_label": "{point.x}X: {point.y:.2f}M",
"xlab": "Coverage",
- "ylab": "Millions of Bases",
+ "ylab": "Millions of Bases (or CpGs)",
"data_labels": [
{"name": "All Bases", "ylab": "Millions of Bases"},
{"name": "Q40 Bases", "ylab": "Millions of Bases"},
@@ -976,142 +695,21 @@ def chart_covdist_all_base(self):
plot=linegraph.plot(pd, pconfig),
)
- def chart_covdist_all_base_botgc(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- def chart_covdist_all_base_topgc(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- def chart_covdist_q40_base(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- def chart_covdist_q40_base_botgc(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- def chart_covdist_q40_base_topgc(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- def chart_covdist_all_cpg(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- def chart_covdist_all_cpg_botgc(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- def chart_covdist_all_cpg_topgc(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- def chart_covdist_q40_cpg(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- def chart_covdist_q40_cpg_botgc(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- def chart_covdist_q40_cpg_topgc(self):
- """Handled by chart_covdist_all_base()"""
- pass
-
- ########################################
- #### CpG Retention ####
- ########################################
- def parse_logs_cpg_retention_readpos(self, f, fn):
- """
- Parses _CpGRetentionByReadPos.txt
- _CpHRetentionByReadPos.txt
- Inputs:
- f - current matched file
- fn - filename
- Returns:
- data - dictionary of fraction of retained cytosines for reads 1 and 2
- in either a CpH or CpG context
- """
- file_data = f.splitlines()[2:]
-
- # Handle missing data
- if len(file_data) == 0:
- log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
- return {"no_data_available": 1}
-
- r1 = {"C": {}, "R": {}}
- r2 = {"C": {}, "R": {}}
- for line in file_data:
- fields = line.strip().split("\t")
-
- if fields[0] not in ["1", "2"] or fields[2] not in ["C", "R"]:
- return {}
- if fields[0] == "1":
- r1[fields[2]][int(fields[1])] = int(fields[3])
- elif fields[0] == "2":
- r2[fields[2]][int(fields[1])] = int(fields[3])
-
- r1rate = dict()
- for k in sorted(r1["C"].keys()):
- if k in r1["R"]:
- r1rate[k] = 100.0 * float(r1["R"][k]) / (r1["R"][k] + r1["C"][k])
-
- r2rate = dict()
- for k in sorted(r2["C"].keys()):
- if k in r2["R"]:
- r2rate[k] = 100.0 * float(r2["R"][k]) / (r2["R"][k] + r2["C"][k])
-
- return {"1": r1rate, "2": r2rate}
-
- def chart_cpg_retention_readpos(self):
- """
- Charts _CpGRetentionByReadPos.txt
- _CpHRetentionByReadPos.txt
- Inputs:
- No inputs
- Returns:
- No returns, generates Retenion vs. Base Position in Read chart
- """
-
+ def chart_retention_readpos(self):
+ """Charts _*RetentionByReadPos.txt"""
pd = [
- dict(
- [
- (s_name, dd["1"])
- for s_name, dd in self.mdata["cpg_retention_readpos"].items()
- if "no_data_available" not in dd.keys()
- ]
- ),
- dict(
- [
- (s_name, dd["2"])
- for s_name, dd in self.mdata["cpg_retention_readpos"].items()
- if "no_data_available" not in dd.keys()
- ]
- ),
- dict(
- [
- (s_name, dd["1"])
- for s_name, dd in self.mdata["cph_retention_readpos"].items()
- if "no_data_available" not in dd.keys()
- ]
- ),
- dict(
- [
- (s_name, dd["2"])
- for s_name, dd in self.mdata["cph_retention_readpos"].items()
- if "no_data_available" not in dd.keys()
- ]
- ),
+ dict([(s_name, dd["1"]) for s_name, dd in self.biscuit_data["cpg_retention_readpos"].items()]),
+ dict([(s_name, dd["2"]) for s_name, dd in self.biscuit_data["cpg_retention_readpos"].items()]),
+ dict([(s_name, dd["1"]) for s_name, dd in self.biscuit_data["cph_retention_readpos"].items()]),
+ dict([(s_name, dd["2"]) for s_name, dd in self.biscuit_data["cph_retention_readpos"].items()]),
]
pconfig = {
- "id": "biscuit_retention_cytosine",
+ "id": "biscuit-retention-cytosine-plot",
"title": "BISCUIT: Retention vs. Base Position in Read",
"xlab": "Position in Read",
"xsuffix": "bp",
- "ylab": "CpG Retention Rate (%)",
+ "ylab": "Retention Rate (%)",
"ymin": 0,
"ymax": 100,
"y_minrange": 0,
@@ -1132,85 +730,20 @@ def chart_cpg_retention_readpos(self):
plot=linegraph.plot(pd, pconfig),
)
- def parse_logs_cph_retention_readpos(self, f, fn):
- """Handled by parse_logs_cpg_retention_readpos()"""
- return self.parse_logs_cpg_retention_readpos(f, fn)
-
- def chart_cph_retention_readpos(self):
- """Handled by chart_cpg_retention_readpos()"""
- pass
-
- def parse_logs_read_avg_retention_rate(self, f, fn):
- """
- Parses _totalReadConversionRate.txt
- Inputs:
- f - current matched file
- fn - filename
- Returns:
- data - dictionary of read averaged fraction of retainied cytosines by context
- """
-
- file_data = f.splitlines()[2:]
-
- # Handle missing data
- if len(file_data) == 0:
- log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
- return {"no_data_available": 1}
-
- data = {}
- for line in file_data:
- fields = line.split("\t")
- # Skip rows that have NaNs as something went wrong in processing
- if "nan" in fields:
- log.debug(f"Found NaN in {fn}. Skipping.")
- continue
-
- # BISCUIT returns -1 if insufficient data. Only fill fields with value >= 0.
- if float(fields[0]) >= 0:
- data["rca"] = 100.0 * float(fields[0])
- if float(fields[1]) >= 0:
- data["rcc"] = 100.0 * float(fields[1])
- if float(fields[2]) >= 0:
- data["rcg"] = 100.0 * float(fields[2])
- if float(fields[3]) >= 0:
- data["rct"] = 100.0 * float(fields[3])
-
- return data
-
- def chart_read_avg_retention_rate(self):
- """
- Charts _totalReadConversionRate.txt
- _totalBaseConversionRate.txt
- Inputs:
- No inputs
- Returns:
- No returns, generates Cytosine Retention chart
- """
-
- pdata_byread = {}
- for s_name, dd in self.mdata["read_avg_retention_rate"].items():
- if "no_data_available" not in dd.keys():
- pdata_byread[s_name] = dd
-
- pdata_bybase = {}
- for s_name, dd in self.mdata["base_avg_retention_rate"].items():
- if "no_data_available" not in dd.keys():
- pdata_bybase[s_name] = dd
-
- pheader_byread = {
- "rca": {"color": "#D81B60", "name": "CpA Retention"},
- "rcc": {"color": "#1E88E5", "name": "CpC Retention"},
- "rcg": {"color": "#A0522D", "name": "CpG Retention"},
- "rct": {"color": "#004D40", "name": "CpT Retention"},
- }
- pheader_bybase = {
- "bca": {"color": "#D81B60", "name": "CpA Retention"},
- "bcc": {"color": "#1E88E5", "name": "CpC Retention"},
- "bcg": {"color": "#A0522D", "name": "CpG Retention"},
- "bct": {"color": "#004D40", "name": "CpT Retention"},
+ def chart_avg_retention(self):
+ """Charts _total*ReadConversionRate.txt"""
+ pd1 = self.biscuit_data["read_avg_retention_rate"]
+ pd2 = self.biscuit_data["base_avg_retention_rate"]
+
+ pheader = {
+ "ca": {"color": "#D81B60", "name": "CpA Retention"},
+ "cc": {"color": "#1E88E5", "name": "CpC Retention"},
+ "cg": {"color": "#A0522D", "name": "CpG Retention"},
+ "ct": {"color": "#004D40", "name": "CpT Retention"},
}
+
pconfig = {
- "id": "biscuit_retention",
+ "id": "biscuit-retention-plot",
"cpswitch": False,
"cpswitch_c_active": False,
"title": "BISCUIT: Cytosine Retention",
@@ -1219,7 +752,7 @@ def chart_read_avg_retention_rate(self):
"ymin": 0,
"ymax": 100,
"y_clipmax": 110,
- "stacking": None,
+ "stacking": "group",
"tt_decimals": 1,
"tt_suffix": "%",
}
@@ -1237,46 +770,250 @@ def chart_read_avg_retention_rate(self):
Note, if a sample is missing from the Base-averaged Retention table,
there wasn't sufficient data to plot that sample.
""",
- plot=bargraph.plot([pdata_byread, pdata_bybase], [pheader_byread, pheader_bybase], pconfig),
+ plot=bargraph.plot([pd1, pd2], [pheader, pheader], pconfig),
+ )
+
+
+################################################################################
+## Parsing Functions ##
+################################################################################
+def parse_align_mapq(f, fn):
+ """Parse _mapq_table.txt"""
+ file_data = f.splitlines()[2:]
+
+ # Handle missing data
+ if len(file_data) == 0:
+ log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
+ return None
+
+ mapq = {}
+ for line in file_data:
+ s = line.split()
+ mapq[s[0]] = s[1] # mapq[MAPQ] = number of reads
+
+ data = {
+ "frac_align": 0,
+ "opt_align": 0,
+ "sub_align": 0,
+ "not_align": 0,
+ "mapqs": dict(zip(range(61), [0 for _ in range(61)])),
+ }
+ if len(mapq) > 0:
+ total = sum([int(cnt) for _, cnt in mapq.items() if _ != "unmapped"])
+ for mq, cnt in mapq.items():
+ if mq == "unmapped":
+ data["not_align"] += int(cnt)
+ else:
+ data["mapqs"][int(mq)] = 100.0 * float(cnt) / total
+ if int(mq) >= 40:
+ data["opt_align"] += int(cnt)
+ else:
+ data["sub_align"] += int(cnt)
+
+ data["frac_align"] = (
+ 100 * (data["opt_align"] + data["sub_align"]) / (data["opt_align"] + data["sub_align"] + data["not_align"])
)
- def parse_logs_base_avg_retention_rate(self, f, fn):
- """
- Parses _totalBaseConversionRate.txt
- Inputs:
- f - current matched file
- fn - filename
- Returns:
- data - dictionary of base averaged fraction of retainied cytosines by context
- """
-
- file_data = f.splitlines()[2:]
-
- # Handle missing data
- if len(file_data) == 0:
- log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
- return {"no_data_available": 1}
-
- data = {}
- for line in file_data:
- fields = line.split("\t")
- # Skip rows that have NaNs as something went wrong in processing
- if "nan" in fields:
- log.debug(f"Found NaN in {fn}. Skipping.")
- continue
-
- # BISCUIT returns -1 if insufficient data. Only fill fields with value >= 0.
- if float(fields[0]) >= 0:
- data["bca"] = 100.0 * float(fields[0])
- if float(fields[1]) >= 0:
- data["bcc"] = 100.0 * float(fields[1])
- if float(fields[2]) >= 0:
- data["bcg"] = 100.0 * float(fields[2])
- if float(fields[3]) >= 0:
- data["bct"] = 100.0 * float(fields[3])
-
- return data
-
- def chart_base_avg_retention_rate(self):
- """Handled by chart_read_avg_retention_rate()"""
- pass
+ return data
+
+
+def parse_align_strand(f, fn):
+ """Parse _strand_table.txt"""
+ # Handle missing data
+ if len(f.splitlines()) <= 2:
+ log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
+ return None
+
+ patterns = [
+ r"(R1)\s+\((f)\)\:\s+(\d+)\s+(\d+)",
+ r"(R1)\s+\((r)\)\:\s+(\d+)\s+(\d+)",
+ r"(R2)\s+\((f)\)\:\s+(\d+)\s+(\d+)",
+ r"(R2)\s+\((r)\)\:\s+(\d+)\s+(\d+)",
+ ]
+
+ data = {"read1": {}, "read2": {}}
+ for pat in patterns:
+ m = re.search(pat, f, re.MULTILINE)
+ if m is not None:
+ if m.group(1) == "R1":
+ if m.group(2) == "f":
+ data["read1"]["ff"] = int(m.group(3))
+ data["read1"]["fr"] = int(m.group(4))
+ else:
+ data["read1"]["rf"] = int(m.group(3))
+ data["read1"]["rr"] = int(m.group(4))
+ else:
+ if m.group(2) == "f":
+ data["read2"]["ff"] = int(m.group(3))
+ data["read2"]["fr"] = int(m.group(4))
+ else:
+ data["read2"]["rf"] = int(m.group(3))
+ data["read2"]["rr"] = int(m.group(4))
+
+ return data
+
+
+def parse_align_isize(f, fn):
+ """Parse _isize_table.txt"""
+ file_data = f.splitlines()[2:]
+
+ # Handle missing data
+ if len(file_data) == 0:
+ log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
+ return None
+
+ data = {"percent": {}, "readcnt": {}}
+ for line in file_data:
+ fields = line.split("\t")
+ key = int(fields[0])
+ data["percent"][key] = 100.0 * float(fields[1])
+ data["readcnt"][key] = float(fields[2])
+
+ return data
+
+
+def parse_dup_report(f, fn):
+ """Parses _dup_report.txt"""
+ # Handle missing data
+ if len(f.splitlines()) != 5:
+ log.debug(f"Incomplete data available in {fn}. Will not fill corresponding entries.")
+ return None
+
+ patterns = [
+ (r"Number of duplicate reads:\s+(\d+)", r"Number of reads:\s+(\d+)", "all"),
+ (r"Number of duplicate q40-reads:\s+(\d+)", r"Number of q40-reads:\s+(\d+)", "q40"),
+ ]
+
+ data = {}
+ for pat_dup, pat_tot, key in patterns:
+ m1 = re.search(pat_dup, f, re.MULTILINE)
+ m2 = re.search(pat_tot, f, re.MULTILINE)
+ if m1 is not None and m2 is not None:
+ data[key] = 100.0 * float(m1.group(1)) / float(m2.group(1))
+ else:
+ log.debug(f"Incomplete data available in {fn}. Will not fill corresponding entries.")
+ return None
+
+ return data
+
+
+def parse_qc_cv(f, fn):
+ """Parses _cv_table.txt"""
+ # Handle missing data
+ if len(f.splitlines()) != 14:
+ log.debug(f"Incomplete data available in {fn}. Will not fill corresponding entries.")
+ return None
+
+ targets = [
+ "all_base",
+ "all_cpg",
+ "q40_base",
+ "q40_cpg",
+ "all_base_botgc",
+ "all_cpg_botgc",
+ "q40_base_botgc",
+ "q40_cpg_botgc",
+ "all_base_topgc",
+ "all_cpg_topgc",
+ "q40_base_topgc",
+ "q40_cpg_topgc",
+ ]
+
+ data = {}
+ for t in targets:
+ m = re.search(rf"{t}\t([\d\.]+)\t([\d\.]+)\t([\d\.]+)", f, re.MULTILINE)
+ if m is not None:
+ data[t] = {"mu": float(m.group(1)), "sigma": float(m.group(2)), "cv": float(m.group(3))}
+ else:
+ log.debug(f"Incomplete data available in {fn}. Will not fill corresponding entries.")
+ return None
+
+ return data
+
+
+def parse_covdist(f, fn):
+ """Parses _covdist_*.txt"""
+ file_data = f.splitlines()[2:]
+ # Handle missing data
+ if len(file_data) == 0:
+ log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
+ return None
+
+ data = {}
+ for line in file_data:
+ fields = line.split()
+ data[int(float(fields[0]))] = int(float(fields[1]))
+
+ covs = sorted([k for k in data])[:31]
+ _ccov_cnt = sum(data.values())
+
+ ccov_cnts = []
+ for cov in covs:
+ ccov_cnts.append(_ccov_cnt / 1000000.0)
+ _ccov_cnt -= data[cov]
+
+ return dict(zip(covs, ccov_cnts))
+
+
+def parse_retention_readpos(f, fn):
+ """Parses _*RetentionByReadPos.txt"""
+ file_data = f.splitlines()[2:]
+
+ # Handle missing data
+ if len(file_data) == 0:
+ log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
+ return None
+
+ r1 = {"C": {}, "R": {}}
+ r2 = {"C": {}, "R": {}}
+ for line in file_data:
+ fields = line.strip().split("\t")
+
+ if fields[0] not in ["1", "2"] or fields[2] not in ["C", "R"]:
+ return None
+ if fields[0] == "1":
+ r1[fields[2]][int(fields[1])] = int(fields[3])
+ elif fields[0] == "2":
+ r2[fields[2]][int(fields[1])] = int(fields[3])
+
+ r1rate = {}
+ for k in sorted(r1["C"].keys()):
+ if k in r1["R"]:
+ r1rate[k] = 100.0 * float(r1["R"][k]) / (r1["R"][k] + r1["C"][k])
+
+ r2rate = {}
+ for k in sorted(r2["C"].keys()):
+ if k in r2["R"]:
+ r2rate[k] = 100.0 * float(r2["R"][k]) / (r2["R"][k] + r2["C"][k])
+
+ return {"1": r1rate, "2": r2rate}
+
+
+def parse_avg_retention(f, fn):
+ """Parses _total*ConversionRate.txt"""
+ file_data = f.splitlines()[2:]
+
+ # Handle missing data
+ if len(file_data) == 0:
+ log.debug(f"No data available in {fn}. Will not fill corresponding entries.")
+ return None
+
+ data = {}
+ for line in file_data:
+ fields = line.split("\t")
+ # Skip rows that have NaNs as something went wrong in processing
+ if "nan" in fields:
+ log.debug(f"Found NaN in {fn}. Skipping.")
+ continue
+
+ # BISCUIT returns -1 if insufficient data. Only fill fields with value >= 0.
+ if float(fields[0]) >= 0:
+ data["ca"] = 100.0 * float(fields[0])
+ if float(fields[1]) >= 0:
+ data["cc"] = 100.0 * float(fields[1])
+ if float(fields[2]) >= 0:
+ data["cg"] = 100.0 * float(fields[2])
+ if float(fields[3]) >= 0:
+ data["ct"] = 100.0 * float(fields[3])
+
+ return data
diff --git a/multiqc/modules/biscuit/tests/__init__.py b/multiqc/modules/biscuit/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/multiqc/modules/biscuit/tests/test_biscuit.py b/multiqc/modules/biscuit/tests/test_biscuit.py
new file mode 100644
index 0000000000..eb92f88246
--- /dev/null
+++ b/multiqc/modules/biscuit/tests/test_biscuit.py
@@ -0,0 +1,50 @@
+import pytest
+
+from multiqc import config, report
+from multiqc.modules.biscuit import MultiqcModule
+from multiqc.utils import testing
+
+# NOTE: These tests could be fleshed out more (more inputs, more error cases, etc.)
+
+EPSILON = 0.0001
+
+
+@pytest.fixture
+def data_dir():
+ return testing.data_dir()
+
+
+def read_file(data_dir, fname):
+ with (data_dir / "modules/biscuit/v0.3.16.20200420" / fname).open() as fh:
+ return fh.read()
+
+
+@pytest.fixture
+def mapq_table(data_dir):
+ return read_file(data_dir, "tcga_lusc_normal_subsampled_mapq_table.txt")
+
+
+def test_mapq_table(mapq_table):
+ """Test that parsing the MAPQ table works as expected"""
+ from multiqc.modules.biscuit.biscuit import parse_align_mapq
+
+ parsed = parse_align_mapq(mapq_table, "tcga_lusc_normal_subsampled_mapq_table.txt")
+
+ # Four keys are expected (frac_align, opt_align, sub_align, not_align, mapqs)
+ assert len(parsed) == 5
+
+ # mapqs key should have 61 keys
+ assert len(parsed["mapqs"]) == 61
+
+ # Check correctly parsed values
+ assert abs(parsed["frac_align"] - 98.80779) < EPSILON
+ assert parsed["opt_align"] == 56535091
+ assert parsed["sub_align"] == 9287487
+ assert parsed["not_align"] == 794210
+
+ # Spot check MAPQ values
+ assert abs(parsed["mapqs"][0] - 9.41347) < EPSILON
+ assert abs(parsed["mapqs"][14] - 0.05310) < EPSILON
+ assert abs(parsed["mapqs"][26] - 0.04399) < EPSILON
+ assert abs(parsed["mapqs"][40] - 3.82724) < EPSILON
+ assert abs(parsed["mapqs"][60] - 78.25065) < EPSILON
diff --git a/multiqc/modules/deeptools/plotFingerprint.py b/multiqc/modules/deeptools/plotFingerprint.py
index 4ca9988108..91f364d49c 100644
--- a/multiqc/modules/deeptools/plotFingerprint.py
+++ b/multiqc/modules/deeptools/plotFingerprint.py
@@ -174,6 +174,6 @@ def parsePlotFingerprintOutRawCounts(self, f):
v2 = dict()
v2[0.0] = 0.0
for _ in x:
- v2[xp[_]] = cs[_]
+ v2[float(xp[_])] = float(cs[_])
d[k] = v2
return d
diff --git a/multiqc/modules/dragen_fastqc/assets/js/multiqc_dragen_fastqc.js b/multiqc/modules/dragen_fastqc/assets/js/multiqc_dragen_fastqc.js
index 201c515a0e..60777630b8 100644
--- a/multiqc/modules/dragen_fastqc/assets/js/multiqc_dragen_fastqc.js
+++ b/multiqc/modules/dragen_fastqc/assets/js/multiqc_dragen_fastqc.js
@@ -7,16 +7,8 @@
///////////////
// Global vars
-fastqc_passfails = {}; // { : { : { : { data } } }
fastqc_seq_content = {}; // { : { : data } }
-function load_fastqc_passfails() {
- $(".fastqc_passfails").each(function (i, elem) {
- var key_value = JSON.parse(elem.innerHTML);
- fastqc_passfails[key_value[0]] = key_value[1];
- });
-}
-
function load_fastqc_seq_content() {
$(".fastqc_seq_content").each(function (i, elem) {
var key_value = JSON.parse(elem.innerHTML);
@@ -26,16 +18,13 @@ function load_fastqc_seq_content() {
// Set up listeners etc on page load
callAfterDecompressed.push(function (mqc_plotdata) {
- load_fastqc_passfails();
load_fastqc_seq_content();
- // Go through each FastQC module in case there are multiple
- // #mqc-module-section-fastqc, #mqc-module-section-fastqc-1, ...
- // or #mqc-module-section-configured-anchor, #mqc-module-section-configured-anchor-1, ...
- var fastqc_modules = $(".fastqc_passfails").closest(".mqc-module-section");
+ // Go through each DRAGEN-FastQC module in case there are multiple
+ var fastqc_modules = $(".fastqc_seq_content").closest(".mqc-module-section");
fastqc_modules.each(function () {
var module_element = $(this);
- var module_key = module_element.attr("id").replace(/-/g, "_").replace("mqc_module_section_", "");
+ var module_key = module_element.data("moduleAnchor");
fastqc_module(module_element, module_key);
});
});
@@ -45,7 +34,6 @@ function fastqc_module(module_element, module_key) {
var s_height = 10;
var num_samples = 0;
var sample_names = [];
- var sample_statuses = [];
var labels = [];
var c_width = 0;
var c_height = 0;
@@ -54,8 +42,7 @@ function fastqc_module(module_element, module_key) {
var current_single_plot = undefined;
// Make a lookup hash of sample names, in case we rename stuff later
- module_element;
- orig_s_names = {};
+ var orig_s_names = {};
for (var s_name in fastqc_seq_content[module_key]) {
if (Object.prototype.hasOwnProperty.call(fastqc_seq_content[module_key], s_name)) {
orig_s_names[s_name] = s_name;
@@ -79,10 +66,6 @@ function fastqc_module(module_element, module_key) {
}
});
orig_s_names[s_name] = orig_s_name;
- if (fastqc_passfails.length !== 0) {
- let t_status = fastqc_passfails[module_key]["per_base_sequence_content"][s_name];
- sample_statuses[s_name] = t_status;
- }
p_data[s_name] = JSON.parse(JSON.stringify(data)); // clone data
var hide_sample = false;
@@ -121,8 +104,7 @@ function fastqc_module(module_element, module_key) {
if (hidden_samples > 0) {
module_element.find("#fastqc_seq_heatmap_div").prepend(
' \
-
\
-
Warning: ' +
+ ⚠
Warning: ' +
hidden_samples +
' samples hidden in toolbox. \
See toolbox. \
@@ -171,19 +153,9 @@ function fastqc_module(module_element, module_key) {
});
ypos = 0;
$.each(sample_names, function (idx, s_name) {
- // Add a 5px wide bar indicating either status or Highlight
- var status = sample_statuses[s_name];
+ // Add a 5px wide bar for highlights
var s_col = "#999999";
- if (status == "pass") {
- s_col = "#5cb85c";
- }
- if (status == "warn") {
- s_col = "#f0ad4e";
- }
- if (status == "fail") {
- s_col = "#d9534f";
- }
- // Override status colour with highlights
+ // Check for highlight colours
$.each(window.mqc_highlight_f_texts, function (idx, f_text) {
if (
(window.mqc_highlight_regex_mode && s_name.match(f_text)) ||
@@ -239,172 +211,6 @@ function fastqc_module(module_element, module_key) {
// Draw sequence content heatmap
fastqc_seq_content_heatmap();
- // Add the pass / warning / fails counts to each of the FastQC submodule headings
- $.each(fastqc_passfails[module_key], function (k, vals) {
- var total = 0;
- var v = { pass: 0, warn: 0, fail: 0 };
- $.each(vals, function (s_name, status) {
- total += 1;
- v[status] += 1;
- });
- var p_bar =
- '
\
-
' +
- v["pass"] +
- '
\
-
' +
- v["warn"] +
- '
\
-
' +
- v["fail"] +
- "
\
-
";
- module_element
- .find("[id^=fastqc_" + k + "]")
- .first()
- .append(p_bar);
- });
-
- // Create popovers on click
- module_element.find(".fastqc_passfail_progress .progress-bar").mouseover(function () {
- // Does this element already have a popover?
- if ($(this).attr("data-original-title")) {
- return false;
- }
- // Create it
- var pid = $(this).closest("h3").attr("id");
- var k = pid.substr(7);
- // Remove suffix when there are multiple fastqc sections
- var n = k.indexOf("-");
- k = k.substring(0, n != -1 ? n : k.length);
- var vals = fastqc_passfails[module_key][k];
- var passes = $(this).hasClass("progress-bar-success") ? true : false;
- var warns = $(this).hasClass("progress-bar-warning") ? true : false;
- var fails = $(this).hasClass("progress-bar-danger") ? true : false;
- var pclass = "";
- if (passes) {
- pclass = "success";
- }
- if (warns) {
- pclass = "warning";
- }
- if (fails) {
- pclass = "danger";
- }
- var samples = Array();
- $.each(vals, function (s_name, status) {
- if (status == "pass" && passes) {
- samples.push(s_name);
- } else if (status == "warn" && warns) {
- samples.push(s_name);
- } else if (status == "fail" && fails) {
- samples.push(s_name);
- }
- });
- $(this)
- .popover({
- title: $(this).attr("title"),
- content: samples.sort().join("
"),
- html: true,
- trigger: "hover click focus",
- placement: "bottom auto",
- template:
- '
',
- })
- .popover("show");
- });
-
- // Listener for Status highlight click
- module_element.find(".fastqc_passfail_progress").on("click", ".fastqc-status-highlight", function (e) {
- e.preventDefault();
- // Get sample names and highlight colour
- var samples = $(this).parent().parent().find(".popover-content").html().split("
");
- var f_col = $("#mqc_colour_filter_color").val();
- // Add sample names to the toolbox
- for (i = 0; i < samples.length; i++) {
- var f_text = samples[i];
- $("#mqc_col_filters").append(
- '
× ',
- );
- }
- // Apply highlights and open toolbox
- apply_mqc_highlights();
- mqc_toolbox_openclose("#mqc_cols", true);
- // Update next highlight colour
- mqc_colours_idx += 1;
- if (mqc_colours_idx >= mqc_colours.length) {
- mqc_colours_idx = 0;
- }
- $("#mqc_colour_filter_color").val(mqc_colours[mqc_colours_idx]);
- // Hide the popover
- $(this).closest(".popover").popover("hide");
- });
-
- // Listener for Status hide others click
- module_element.find(".fastqc_passfail_progress").on("click", ".fastqc-status-hideothers", function (e) {
- e.preventDefault();
- // Get sample names
- var samples = $(this).parent().parent().find(".popover-content").html().split("
");
- // Check if we're already hiding anything, remove after confirm if so
- if ($("#mqc_hidesamples_filters li").length > 0) {
- if (!confirm($("#mqc_hidesamples_filters li").length + " Hide filters already exist - discard?")) {
- return false;
- } else {
- $("#mqc_hidesamples_filters").empty();
- }
- }
- // Set to "show only" and disable regex
- $('.mqc_hidesamples_showhide[value="show"]').prop("checked", true);
- $("#mqc_hidesamples .mqc_regex_mode .re_mode").removeClass("on").addClass("off").text("off");
- // Add sample names to the toolbox
- for (i = 0; i < samples.length; i++) {
- var f_text = samples[i];
- $("#mqc_hidesamples_filters").append(
- '
× ',
- );
- }
- // Apply highlights and open toolbox
- apply_mqc_hidesamples();
- mqc_toolbox_openclose("#mqc_hidesamples", true);
- // Hide the popover
- $(this).closest(".popover").popover("hide");
- });
-
/////////
/// SEQ CONTENT HEATMAP LISTENERS
/////////
@@ -451,29 +257,8 @@ function fastqc_module(module_element, module_key) {
return false;
}
- // Show the pass/warn/fail status heading for this sample
- var s_status = sample_statuses[s_name];
- var s_status_class = "label-default";
- if (s_status == "pass") {
- s_status_class = "label-success";
- }
- if (s_status == "warn") {
- s_status_class = "label-warning";
- }
- if (s_status == "fail") {
- s_status_class = "label-danger";
- }
- module_element
- .find("#dragen_fastqc_per_base_sequence_content_plot_div .s_name")
- .html(
- '
' +
- s_name +
- '
' +
- s_status +
- " ",
- );
+ // Show the sample name
+ module_element.find("#dragen_fastqc_per_base_sequence_content_plot_div .s_name").html(s_name);
// Update the key with the raw data for this position
var hover_bp = Math.max(1, Math.floor((x / c_width) * max_bp));
@@ -504,9 +289,7 @@ function fastqc_module(module_element, module_key) {
// Remove sample name again when mouse leaves
module_element.find("#fastqc_seq_heatmap").mouseout(function (e) {
- module_element
- .find("#dragen_fastqc_per_base_sequence_content_plot_div .s_name")
- .html('
Rollover for sample name');
+ module_element.find("#dragen_fastqc_per_base_sequence_content_plot_div .s_name").html("Rollover for sample name");
module_element.find("#fastqc_seq_heatmap_key_pos").text("-");
module_element.find("#fastqc_seq_heatmap_key_t span").text("-");
module_element.find("#fastqc_seq_heatmap_key_c span").text("-");
@@ -577,8 +360,8 @@ function fastqc_module(module_element, module_key) {
\
Back to overview heatmap \
\
- « Prev \
- Next » \
+ « Prev \
+ Next » \
\
\
';
diff --git a/multiqc/modules/dragen_fastqc/content_metrics.py b/multiqc/modules/dragen_fastqc/content_metrics.py
index 33bb2377a6..2b9c413bf8 100644
--- a/multiqc/modules/dragen_fastqc/content_metrics.py
+++ b/multiqc/modules/dragen_fastqc/content_metrics.py
@@ -7,6 +7,7 @@
from multiqc.base_module import BaseMultiqcModule
from multiqc.plots import linegraph
from multiqc import report
+from multiqc.utils.material_icons import get_material_icon
from .util import average_from_range, average_pos_from_metric
@@ -140,10 +141,9 @@ def sequence_content_plot(self):
html = """
-
- Click a sample row to see a line plot for that dataset.
+ {hand_icon} Click a sample row to see a line plot for that dataset.
-
Rollover for sample name
+
Rollover for sample name
Position:
-
%T: -
@@ -163,6 +163,7 @@ def sequence_content_plot(self):
# Generate unique plot ID, needed in mqc_export_selectplots
id=report.save_htmlid("dragen_fastqc_per_base_sequence_content_plot"),
d=json.dumps([self.anchor.replace("-", "_"), data]),
+ hand_icon=get_material_icon("mdi:hand-pointing-up", 16),
)
self.add_section(
@@ -242,7 +243,6 @@ def adapter_content_plot(self):
"y_minrange": 5,
"ymin": 0,
"tt_label": "
Base {point.x} : {point.y:.2f}%",
- "hide_zero_cats": True,
"y_bands": [
{"from": 20, "to": 100, "color": "#990101", "opacity": 0.13},
{"from": 5, "to": 20, "color": "#a07300", "opacity": 0.13},
diff --git a/multiqc/modules/dragen_fastqc/dragen_fastqc.py b/multiqc/modules/dragen_fastqc/dragen_fastqc.py
index 2c42df2758..239c307c1b 100755
--- a/multiqc/modules/dragen_fastqc/dragen_fastqc.py
+++ b/multiqc/modules/dragen_fastqc/dragen_fastqc.py
@@ -69,7 +69,6 @@ def __init__(self):
os.path.dirname(__file__), "assets", "js", "multiqc_dragen_fastqc.js"
)
}
- self.intro += ''
data_by_sample = {}
for f in self.find_log_files("dragen_fastqc"):
diff --git a/multiqc/modules/fastp/fastp.py b/multiqc/modules/fastp/fastp.py
index e8c643a78c..0c36373321 100644
--- a/multiqc/modules/fastp/fastp.py
+++ b/multiqc/modules/fastp/fastp.py
@@ -14,14 +14,19 @@
class MultiqcModule(BaseMultiqcModule):
"""
- By default, the module generates the sample names based on the input FastQ file names in
- the command line used by fastp. If you prefer, you can tell the module to use
- the filenames as sample names instead. To do so, use the following config option:
+ By default, the module generates the sample names based on the `--report_title` / `-R`
+ option in the fastp command line (if present), or the input FastQ file names if not.
+
+ If you prefer, you can tell the module to use the filenames as sample names instead.
+ To do so, use the following config option:
```yaml
- fastp:
- s_name_filenames: true
+ use_filename_as_sample_name:
+ - fastp
```
+
+ See [Using log filenames as sample names](https://docs.seqera.io/multiqc/getting_started/config#using-log-filenames-as-sample-names)
+ for more details.
"""
def __init__(self):
@@ -31,10 +36,10 @@ def __init__(self):
href="https://github.com/OpenGene/fastp",
info="All-in-one FASTQ preprocessor (QC, adapters, trimming, filtering, splitting...)",
extra="""
- Fastp goes through fastq files in a folder and perform a series of quality control and filtering.
- Quality control and reporting are displayed both before and after filtering, allowing for a clear
- depiction of the consequences of the filtering process. Notably, the latter can be conducted on a
- variety of parameters including quality scores, length, as well as the presence of adapters, polyG,
+ Fastp goes through fastq files in a folder and perform a series of quality control and filtering.
+ Quality control and reporting are displayed both before and after filtering, allowing for a clear
+ depiction of the consequences of the filtering process. Notably, the latter can be conducted on a
+ variety of parameters including quality scores, length, as well as the presence of adapters, polyG,
or polyX tailing.""",
doi="10.1093/bioinformatics/bty560",
)
@@ -193,35 +198,43 @@ def parse_fastp_log(self, f) -> Tuple[Optional[str], Dict]:
s_name = f["s_name"]
if s_name is None:
- # Parse the "command" line usually found in the JSON, and use the first input
- # FastQ file name to fetch the sample name.
+ # Parse the "command" line usually found in the JSON, and use the report title
+ # if present, otherwise fall back to the input FastQ file names.
cmd = parsed_json["command"].strip()
- # On caveat is that the command won't have file names escaped properly,
- # so we need some special logic to account for names with spaces:
- # "fastp -c -g -y -i Sample 1 1.fastq.gz -o ..."
- # "fastp -c -g -y --in1 Sample 1 1.fastq.gz --out1 ..."
- # "fastp -c -g -y --in1 Sample 1 1.fastq.gz --in2 Sample 1_2.fastq.gz --out1 ..."
- #
- # Using a regex that extracts everything between "-i " or "--in1 " and " -".
- # It still won't work exactly right for file names with dashes following a
- # space, but that's a pretty rare case, and will still extract something
- # meaningful.
- s_names = []
- m = re.search(r"(-i|--in1)\s(.+?)(?:\s-|$)", cmd)
+
+ # First, try to extract --report_title / -R if present
+ # The value can contain spaces and should be everything until the next option or end
+ m = re.search(r"(-R|--report_title)\s(.+?)(?:\s-|$)", cmd)
if m:
- s_names.append(m.group(2))
- # Second input for paired end?
- m = re.search(r"(-I|--in2)\s(.+?)(?:\s-|$)", cmd)
+ s_name = self.clean_s_name(m.group(2), f)
+ else:
+ # Fall back to input file names
+ # On caveat is that the command won't have file names escaped properly,
+ # so we need some special logic to account for names with spaces:
+ # "fastp -c -g -y -i Sample 1 1.fastq.gz -o ..."
+ # "fastp -c -g -y --in1 Sample 1 1.fastq.gz --out1 ..."
+ # "fastp -c -g -y --in1 Sample 1 1.fastq.gz --in2 Sample 1_2.fastq.gz --out1 ..."
+ #
+ # Using a regex that extracts everything between "-i " or "--in1 " and " -".
+ # It still won't work exactly right for file names with dashes following a
+ # space, but that's a pretty rare case, and will still extract something
+ # meaningful.
+ s_names = []
+ m = re.search(r"(-i|--in1)\s(.+?)(?:\s-|$)", cmd)
if m:
s_names.append(m.group(2))
- s_name = self.clean_s_name(s_names, f)
- else:
- s_name = f["s_name"]
- log.warning(
- f"Could not parse sample name from the fastp command:\n{cmd}\n"
- f"Falling back to extracting it from the file name: "
- f'"{f["fn"]}" -> "{s_name}"'
- )
+ # Second input for paired end?
+ m = re.search(r"(-I|--in2)\s(.+?)(?:\s-|$)", cmd)
+ if m:
+ s_names.append(m.group(2))
+ s_name = self.clean_s_name(s_names, f)
+ else:
+ s_name = f["s_name"]
+ log.warning(
+ f"Could not parse sample name from the fastp command:\n{cmd}\n"
+ f"Falling back to extracting it from the file name: "
+ f'"{f["fn"]}" -> "{s_name}"'
+ )
self.add_data_source(f, s_name)
return s_name, parsed_json
diff --git a/multiqc/modules/fastqc/assets/css/multiqc_fastqc.css b/multiqc/modules/fastqc/assets/css/multiqc_fastqc.css
index 3065180bf0..45e2257622 100644
--- a/multiqc/modules/fastqc/assets/css/multiqc_fastqc.css
+++ b/multiqc/modules/fastqc/assets/css/multiqc_fastqc.css
@@ -1,54 +1,5 @@
/* CSS for the FastQC MultiQC Module */
-/* FastQC statuses popovers */
-.fastqc_passfail_progress .progress-bar {
- cursor: pointer;
- -webkit-print-color-adjust: exact !important;
- color-adjust: exact !important;
- color: #fff !important;
-}
-/* Set colours to !important so that they print */
-.progress-bar-success {
- background-color: #5cb85c !important;
-}
-.progress-bar-warning {
- background-color: #f0ad4e !important;
-}
-.progress-bar-succedangerss {
- background-color: #d9534f !important;
-}
-.popover-fastqc-status .popover-content {
- font-size: 11px;
- max-height: 300px;
- white-space: nowrap;
- overflow: auto;
-}
-.popover-success .popover-title {
- background-color: #dff0d8;
- color: #3c763d;
-}
-.popover-warning .popover-title {
- background-color: #fcf8e3;
- color: #8a6d3b;
-}
-.popover-danger .popover-title {
- background-color: #f2dede;
- color: #a94442;
-}
-.fastqc-popover-intro {
- margin: 5px 5px 0;
- padding: 3px 5px;
- border: 1px dashed #ccc;
- border-radius: 5px;
- font-size: 10px;
- color: #999;
- background-color: #fafafa;
-}
-.fastqc-popover-intro a {
- color: #999;
- text-decoration: underline;
-}
-
/* Other stuff */
.showhide_orig {
padding: 15px;
@@ -67,12 +18,6 @@
cursor: pointer;
}
-.fastqc_passfail_progress {
- width: 100px;
- display: inline-block;
- margin: 0 0 -2px 20px;
-}
-
#fastqc_seq_heatmap {
cursor: pointer;
}
diff --git a/multiqc/modules/fastqc/assets/js/multiqc_fastqc.js b/multiqc/modules/fastqc/assets/js/multiqc_fastqc.js
index adbd414e09..bde316e462 100644
--- a/multiqc/modules/fastqc/assets/js/multiqc_fastqc.js
+++ b/multiqc/modules/fastqc/assets/js/multiqc_fastqc.js
@@ -7,16 +7,8 @@
///////////////
// Global vars
-fastqc_passfails = {}; // {
: { : { : { data } } }
fastqc_seq_content = {}; // { : { : data } }
-function load_fastqc_passfails() {
- $(".fastqc_passfails").each(function (i, elem) {
- var key_value = JSON.parse(elem.innerHTML);
- fastqc_passfails[key_value[0]] = key_value[1];
- });
-}
-
function load_fastqc_seq_content() {
$(".fastqc_seq_content").each(function (i, elem) {
var key_value = JSON.parse(elem.innerHTML);
@@ -31,7 +23,7 @@ callAfterDecompressed.push(function (mqc_plotdata) {
// Go through each FastQC module in case there are multiple
// #mqc-module-section-fastqc, #mqc-module-section-fastqc-1, ...
// or #mqc-module-section-configured-anchor, #mqc-module-section-configured-anchor-1, ...
- var fastqc_modules = $(".fastqc_passfails").closest(".mqc-module-section");
+ var fastqc_modules = $(".fastqc_seq_content").closest(".mqc-module-section");
fastqc_modules.each(function () {
var module_element = $(this);
var module_key = module_element.data("moduleAnchor");
@@ -44,7 +36,6 @@ function fastqc_module(module_element, module_key) {
var s_height = 10;
var num_samples = 0;
var sample_names = [];
- var sample_statuses = [];
var labels = [];
var c_width = 0;
var c_height = 0;
@@ -64,7 +55,6 @@ function fastqc_module(module_element, module_key) {
function fastqc_seq_content_heatmap() {
// Get sample names, rename and skip hidden samples
sample_names = [];
- sample_statuses = [];
var p_data = {};
var hidden_samples = 0;
$.each(fastqc_seq_content[module_key], function (s_name, data) {
@@ -79,10 +69,6 @@ function fastqc_module(module_element, module_key) {
}
});
module_element.orig_s_names[s_name] = orig_s_name;
- if (fastqc_passfails[module_key] !== undefined) {
- let t_status = fastqc_passfails[module_key]["per_base_sequence_content"][s_name];
- sample_statuses[s_name] = t_status;
- }
p_data[s_name] = JSON.parse(JSON.stringify(data)); // clone data
var hide_sample = false;
@@ -121,8 +107,7 @@ function fastqc_module(module_element, module_key) {
if (hidden_samples > 0) {
module_element.find("#fastqc_seq_heatmap_div").prepend(
' \
-
\
-
Warning: ' +
+ ⚠
Warning: ' +
hidden_samples +
' samples hidden in toolbox. \
See toolbox. \
@@ -171,21 +156,9 @@ function fastqc_module(module_element, module_key) {
});
ypos = 0;
$.each(sample_names, function (idx, s_name) {
- // Add a 5px wide bar indicating either status or Highlight
+ // Add a 5px wide bar for highlights
let s_col = "#999999";
- if (sample_statuses[s_name] !== undefined) {
- let status = sample_statuses[s_name];
- if (status === "pass") {
- s_col = "#5cb85c";
- }
- if (status === "warn") {
- s_col = "#f0ad4e";
- }
- if (status === "fail") {
- s_col = "#d9534f";
- }
- }
- // Override status colour with highlights
+ // Check for highlight colours
$.each(window.mqc_highlight_f_texts, function (idx, f_text) {
if (
(window.mqc_highlight_regex_mode && s_name.match(f_text)) ||
@@ -241,172 +214,6 @@ function fastqc_module(module_element, module_key) {
// Draw sequence content heatmap
fastqc_seq_content_heatmap();
- // Add the pass / warning / fails counts to each of the FastQC submodule headings
- $.each(fastqc_passfails[module_key], function (k, vals) {
- var total = 0;
- var v = { pass: 0, warn: 0, fail: 0 };
- $.each(vals, function (s_name, status) {
- total += 1;
- v[status] += 1;
- });
- var p_bar =
- '
\
-
' +
- v["pass"] +
- '
\
-
' +
- v["warn"] +
- '
\
-
' +
- v["fail"] +
- "
\
-
";
- module_element
- .find("h3[id*=fastqc_" + k + "]")
- .first()
- .append(p_bar);
- });
-
- // Create popovers on click
- module_element.find(".fastqc_passfail_progress .progress-bar").mouseover(function () {
- // Does this element already have a popover?
- if ($(this).attr("data-original-title")) {
- return false;
- }
- // Create it
- let pid = $(this).closest(".mqc-module-section").data("module-anchor");
- let k = pid.substr(7);
- // Remove suffix when there are multiple fastqc sections
- let n = k.indexOf("-");
- k = k.substring(0, n !== -1 ? n : k.length);
- let vals = fastqc_passfails[module_key][k];
- let passes = $(this).hasClass("progress-bar-success") ? true : false;
- let warns = $(this).hasClass("progress-bar-warning") ? true : false;
- let fails = $(this).hasClass("progress-bar-danger") ? true : false;
- let pclass = "";
- if (passes) {
- pclass = "success";
- }
- if (warns) {
- pclass = "warning";
- }
- if (fails) {
- pclass = "danger";
- }
- let samples = Array();
- $.each(vals, function (s_name, status) {
- if (status === "pass" && passes) {
- samples.push(s_name);
- } else if (status === "warn" && warns) {
- samples.push(s_name);
- } else if (status === "fail" && fails) {
- samples.push(s_name);
- }
- });
- $(this)
- .popover({
- title: $(this).attr("title"),
- content: samples.sort().join("
"),
- html: true,
- trigger: "hover click focus",
- placement: "bottom auto",
- template:
- '
',
- })
- .popover("show");
- });
-
- // Listener for Status highlight click
- module_element.find(".fastqc_passfail_progress").on("click", ".fastqc-status-highlight", function (e) {
- e.preventDefault();
- // Get sample names and highlight colour
- let samples = $(this).parent().parent().find(".popover-content").html().split("
");
- let f_col = $("#mqc_colour_filter_color").val();
- // Add sample names to the toolbox
- for (let i = 0; i < samples.length; i++) {
- let f_text = samples[i];
- $("#mqc_col_filters").append(
- '
× ',
- );
- }
- // Apply highlights and open toolbox
- apply_mqc_highlights();
- mqc_toolbox_openclose("#mqc_cols", true);
- // Update next highlight colour
- mqc_colours_idx += 1;
- if (mqc_colours_idx >= mqc_colours.length) {
- mqc_colours_idx = 0;
- }
- $("#mqc_colour_filter_color").val(mqc_colours[mqc_colours_idx]);
- // Hide the popover
- $(this).closest(".popover").popover("hide");
- });
-
- // Listener for Status hide others click
- module_element.find(".fastqc_passfail_progress").on("click", ".fastqc-status-hideothers", function (e) {
- e.preventDefault();
- // Get sample names
- let samples = $(this).parent().parent().find(".popover-content").html().split("
");
- // Check if we're already hiding anything, remove after confirm if so
- if ($("#mqc_hidesamples_filters li").length > 0) {
- if (!confirm($("#mqc_hidesamples_filters li").length + " Hide filters already exist - discard?")) {
- return false;
- } else {
- $("#mqc_hidesamples_filters").empty();
- }
- }
- // Set to "show only" and disable regex
- $('.mqc_hidesamples_showhide[value="show"]').prop("checked", true);
- $("#mqc_hidesamples .mqc_regex_mode .re_mode").removeClass("on").addClass("off").text("off");
- // Add sample names to the toolbox
- for (let i = 0; i < samples.length; i++) {
- let f_text = samples[i];
- $("#mqc_hidesamples_filters").append(
- '
× ',
- );
- }
- // Apply highlights and open toolbox
- apply_mqc_hidesamples();
- mqc_toolbox_openclose("#mqc_hidesamples", true);
- // Hide the popover
- $(this).closest(".popover").popover("hide");
- });
-
/////////
/// SEQ CONTENT HEATMAP LISTENERS
/////////
@@ -453,22 +260,8 @@ function fastqc_module(module_element, module_key) {
return false;
}
- // Show the pass/warn/fail status heading for this sample
- let s_status = sample_statuses[s_name];
- let s_status_class = "label-default";
- if (s_status === "pass") {
- s_status_class = "label-success";
- }
- if (s_status === "warn") {
- s_status_class = "label-warning";
- }
- if (s_status === "fail") {
- s_status_class = "label-danger";
- }
- let sampleLabel = '
' + s_name;
- if (s_status !== undefined) {
- sampleLabel += '
' + s_status + " ";
- }
+ // Show the sample name
+ let sampleLabel = s_name;
module_element.find("#fastqc_per_base_sequence_content_plot_div .s_name").html(sampleLabel);
// Update the key with the raw data for this position
@@ -500,9 +293,7 @@ function fastqc_module(module_element, module_key) {
// Remove sample name again when mouse leaves
module_element.find("#fastqc_seq_heatmap").mouseout(function (e) {
- module_element
- .find("#fastqc_per_base_sequence_content_plot_div .s_name")
- .html('
Rollover for sample name');
+ module_element.find("#fastqc_per_base_sequence_content_plot_div .s_name").html("Rollover for sample name");
module_element.find("#fastqc_seq_heatmap_key_pos").text("-");
module_element.find("#fastqc_seq_heatmap_key_t span").text("-");
module_element.find("#fastqc_seq_heatmap_key_c span").text("-");
@@ -612,10 +403,10 @@ function fastqc_module(module_element, module_key) {
module_key +
'_sequence_content_single_back">Back to overview heatmap \
\
- « Prev \
- Next » \
\
diff --git a/multiqc/modules/fastqc/fastqc.py b/multiqc/modules/fastqc/fastqc.py
index 0acb9d28a9..40f1d2b357 100755
--- a/multiqc/modules/fastqc/fastqc.py
+++ b/multiqc/modules/fastqc/fastqc.py
@@ -22,6 +22,7 @@
from multiqc.plots.linegraph import LinePlotConfig, Series
from multiqc.plots.table_object import ColumnKey, InputRow, SampleName
from multiqc.types import Anchor, LoadedFileDict
+from multiqc.utils.material_icons import get_material_icon
log = logging.getLogger(__name__)
@@ -276,37 +277,27 @@ def __init__(self):
# Add to the general statistics table
self.fastqc_general_stats()
- status_checks = getattr(config, "fastqc_config", {}).get("status_checks", True)
-
- # Add the statuses to the intro for multiqc_fastqc.js JavaScript to pick up
+ # Collect statuses for status bars
statuses: Dict[str, Dict[SampleName, str]] = dict()
- if status_checks:
- for s_name in self.fastqc_data:
- for section, status in self.fastqc_data[s_name]["statuses"].items():
- try:
- statuses[section][s_name] = status
- except KeyError:
- statuses[section] = {s_name: status}
-
- self.intro += ''.format(
- json.dumps([self.anchor.replace("-", "_"), statuses])
- )
- if status_checks:
- self.intro += ''
+ for s_name in self.fastqc_data:
+ for section, status in self.fastqc_data[s_name]["statuses"].items():
+ try:
+ statuses[section][s_name] = status
+ except KeyError:
+ statuses[section] = {s_name: status}
# Now add each section in order
self.read_count_plot()
- self.sequence_quality_plot(status_checks)
- self.per_seq_quality_plot(status_checks)
+ self.sequence_quality_plot(statuses.get("per_base_sequence_quality", {}))
+ self.per_seq_quality_plot(statuses.get("per_sequence_quality_scores", {}))
self.sequence_content_plot()
- self.gc_content_plot(status_checks)
- self.n_content_plot(status_checks)
- self.seq_length_dist_plot(status_checks)
- self.seq_dup_levels_plot(status_checks)
+ self.gc_content_plot(statuses.get("per_sequence_gc_content", {}))
+ self.n_content_plot(statuses.get("per_base_n_content", {}))
+ self.seq_length_dist_plot(statuses.get("sequence_length_distribution", {}))
+ self.seq_dup_levels_plot(statuses.get("sequence_duplication_levels", {}))
self.overrepresented_sequences()
- self.adapter_content_plot(status_checks)
- if status_checks:
- self.status_heatmap()
+ self.adapter_content_plot(statuses.get("adapter_content", {}))
+ self.status_heatmap()
# Write the summary stats to a file
dump_data: Dict[SampleName, Dict[str, Any]] = dict()
@@ -615,7 +606,7 @@ def read_count_plot(self):
plot=bargraph.plot(data_by_sample, pcats, pconfig),
)
- def sequence_quality_plot(self, status_checks: bool = True):
+ def sequence_quality_plot(self, section_statuses: Dict[SampleName, str]):
"""Create the HTML for the phred quality score plot"""
data_by_sample: Dict[str, Dict[int, float]] = dict()
@@ -629,6 +620,12 @@ def sequence_quality_plot(self, status_checks: bool = True):
log.debug("sequence_quality not found in FastQC reports")
return None
+ # Convert status dict format
+ status_dict: Dict[Literal["pass", "warn", "fail"], List[str]] = {"pass": [], "warn": [], "fail": []}
+ for s_name, status in section_statuses.items():
+ if status in status_dict:
+ status_dict[status].append(s_name)
+
pconfig = {
"id": f"{self.anchor}_per_base_sequence_quality_plot",
"title": "FastQC: Mean Quality Scores",
@@ -638,19 +635,14 @@ def sequence_quality_plot(self, status_checks: bool = True):
"xmin": 0,
"x_decimals": False,
"tt_label": "
Base {point.x} : {point.y:.2f}",
- "showlegend": False if status_checks else True,
+ "showlegend": False,
+ "colors": self.get_status_cols("per_base_sequence_quality"),
+ "y_bands": [
+ {"from": 28, "to": 100, "color": "#009500", "opacity": 0.13},
+ {"from": 20, "to": 28, "color": "#a07300", "opacity": 0.13},
+ {"from": 0, "to": 20, "color": "#990101", "opacity": 0.13},
+ ],
}
- if status_checks:
- pconfig.update(
- {
- "colors": self.get_status_cols("per_base_sequence_quality"),
- "y_bands": [
- {"from": 28, "to": 100, "color": "#009500", "opacity": 0.13},
- {"from": 20, "to": 28, "color": "#a07300", "opacity": 0.13},
- {"from": 0, "to": 20, "color": "#990101", "opacity": 0.13},
- ],
- }
- )
self.add_section(
name="Sequence Quality Histograms",
@@ -669,9 +661,10 @@ def sequence_quality_plot(self, status_checks: bool = True):
common to see base calls falling into the orange area towards the end of a read._
""",
plot=linegraph.plot(data_by_sample, pconfig),
+ statuses=status_dict if section_statuses else None,
)
- def per_seq_quality_plot(self, status_checks: bool = True):
+ def per_seq_quality_plot(self, section_statuses: Dict[SampleName, str]):
"""Create the HTML for the per sequence quality score plot"""
data_by_sample: Dict[str, Dict[int, float]] = dict()
@@ -683,6 +676,12 @@ def per_seq_quality_plot(self, status_checks: bool = True):
log.debug("per_seq_quality not found in FastQC reports")
return None
+ # Convert status dict format
+ status_dict: Dict[Literal["pass", "warn", "fail"], List[str]] = {"pass": [], "warn": [], "fail": []}
+ for s_name, status in section_statuses.items():
+ if status in status_dict:
+ status_dict[status].append(s_name)
+
pconfig = {
"id": f"{self.anchor}_per_sequence_quality_scores_plot",
"title": "FastQC: Per Sequence Quality Scores",
@@ -692,19 +691,14 @@ def per_seq_quality_plot(self, status_checks: bool = True):
"xmin": 0,
"x_decimals": False,
"tt_label": "
Phred {point.x} : {point.y} reads",
- "showlegend": False if status_checks else True,
+ "showlegend": False,
+ "colors": self.get_status_cols("per_sequence_quality_scores"),
+ "x_bands": [
+ {"from": 28, "to": 100, "color": "#009500", "opacity": 0.13},
+ {"from": 20, "to": 28, "color": "#a07300", "opacity": 0.13},
+ {"from": 0, "to": 20, "color": "#990101", "opacity": 0.13},
+ ],
}
- if status_checks:
- pconfig.update(
- {
- "colors": self.get_status_cols("per_sequence_quality_scores"),
- "x_bands": [
- {"from": 28, "to": 100, "color": "#009500", "opacity": 0.13},
- {"from": 20, "to": 28, "color": "#a07300", "opacity": 0.13},
- {"from": 0, "to": 20, "color": "#990101", "opacity": 0.13},
- ],
- }
- )
self.add_section(
name="Per Sequence Quality Scores",
anchor="fastqc_per_sequence_quality_scores",
@@ -718,6 +712,7 @@ def per_seq_quality_plot(self, status_checks: bool = True):
represent only a small percentage of the total sequences._
""",
plot=linegraph.plot(data_by_sample, pconfig),
+ statuses=status_dict if section_statuses else None,
)
def sequence_content_plot(self):
@@ -750,10 +745,10 @@ def sequence_content_plot(self):
dump = json.dumps([self.anchor, data_by_sample])
html = f"""
-
+ ${get_material_icon("mdi:hand-pointing-up", 16)}
Click a sample row to see a line plot for that dataset.
-
Rollover for sample name
+
Rollover for sample name
Position:
-
%T: -
@@ -807,7 +802,7 @@ def sequence_content_plot(self):
content=html,
)
- def gc_content_plot(self, status_checks: bool = True):
+ def gc_content_plot(self, section_statuses: Dict[SampleName, str]):
"""Create the HTML for the FastQC GC content plot"""
data_by_sample: Dict[str, Dict[int, float]] = dict()
@@ -828,6 +823,12 @@ def gc_content_plot(self, status_checks: bool = True):
log.debug("per_sequence_gc_content not found in FastQC reports")
return None
+ # Convert status dict format
+ status_dict: Dict[Literal["pass", "warn", "fail"], List[str]] = {"pass": [], "warn": [], "fail": []}
+ for s_name, status in section_statuses.items():
+ if status in status_dict:
+ status_dict[status].append(s_name)
+
pconfig = {
"id": f"{self.anchor}_per_sequence_gc_content_plot",
"title": "FastQC: Per Sequence GC Content",
@@ -841,14 +842,9 @@ def gc_content_plot(self, status_checks: bool = True):
{"name": "Percentages", "ylab": "Percentage", "tt_suffix": "%"},
{"name": "Counts", "ylab": "Count", "tt_suffix": ""},
],
- "showlegend": False if status_checks else True,
+ "showlegend": False,
+ "colors": self.get_status_cols("per_sequence_gc_content"),
}
- if status_checks:
- pconfig.update(
- {
- "colors": self.get_status_cols("per_sequence_gc_content"),
- }
- )
# Try to find and plot a theoretical GC line
theoretical_gc: Optional[List[Tuple[float, float]]] = None
@@ -895,7 +891,7 @@ def gc_content_plot(self, status_checks: bool = True):
"dash": "dash",
"width": 2,
"color": "black",
- "showlegend": False if status_checks else True,
+ "showlegend": False,
}
s1: Series[float, float] = Series(
path_in_cfg=("fastqc-gc-content-plot", "theoretical-gc-content"),
@@ -934,9 +930,10 @@ def gc_content_plot(self, status_checks: bool = True):
GC content should be._
""",
plot=linegraph.plot([data_norm_by_sample, data_by_sample], pconfig),
+ statuses=status_dict if section_statuses else None,
)
- def n_content_plot(self, status_checks: bool = True):
+ def n_content_plot(self, section_statuses: Dict[SampleName, str]):
"""Create the HTML for the per base N content plot"""
data_by_sample: Dict[str, Dict[int, int]] = dict()
@@ -950,6 +947,12 @@ def n_content_plot(self, status_checks: bool = True):
log.debug("per_base_n_content not found in FastQC reports")
return None
+ # Convert status dict format
+ status_dict: Dict[Literal["pass", "warn", "fail"], List[str]] = {"pass": [], "warn": [], "fail": []}
+ for s_name, status in section_statuses.items():
+ if status in status_dict:
+ status_dict[status].append(s_name)
+
pconfig = {
"id": f"{self.anchor}_per_base_n_content_plot",
"title": "FastQC: Per Base N Content",
@@ -960,19 +963,14 @@ def n_content_plot(self, status_checks: bool = True):
"ymin": 0,
"xmin": 0,
"tt_label": "
Base {point.x} : {point.y:.2f}%",
- "showlegend": False if status_checks else True,
+ "showlegend": False,
+ "colors": self.get_status_cols("per_base_n_content"),
+ "y_bands": [
+ {"from": 20, "to": 100, "color": "#990101", "opacity": 0.13},
+ {"from": 5, "to": 20, "color": "#a07300", "opacity": 0.13},
+ {"from": 0, "to": 5, "color": "#009500", "opacity": 0.13},
+ ],
}
- if status_checks:
- pconfig.update(
- {
- "colors": self.get_status_cols("per_base_n_content"),
- "y_bands": [
- {"from": 20, "to": 100, "color": "#990101", "opacity": 0.13},
- {"from": 5, "to": 20, "color": "#a07300", "opacity": 0.13},
- {"from": 0, "to": 5, "color": "#009500", "opacity": 0.13},
- ],
- }
- )
self.add_section(
name="Per Base N Content",
@@ -991,9 +989,10 @@ def n_content_plot(self, status_checks: bool = True):
make valid base calls._
""",
plot=linegraph.plot(data_by_sample, pconfig),
+ statuses=status_dict if section_statuses else None,
)
- def seq_length_dist_plot(self, status_checks: bool = True):
+ def seq_length_dist_plot(self, section_statuses: Dict[SampleName, str]):
"""Create the HTML for the Sequence Length Distribution plot"""
cnt_by_range_by_sample: Dict[str, Dict[int, int]] = dict()
@@ -1014,6 +1013,12 @@ def seq_length_dist_plot(self, status_checks: bool = True):
log.debug("sequence_length_distribution not found in FastQC reports")
return None
+ # Convert status dict format
+ status_dict: Dict[Literal["pass", "warn", "fail"], List[str]] = {"pass": [], "warn": [], "fail": []}
+ for s_name, status in section_statuses.items():
+ if status in status_dict:
+ status_dict[status].append(s_name)
+
if only_single_length:
lengths_line = ", ".join([f"{length:,.0f}bp" for length in list(all_ranges_across_samples)])
desc = f"All samples have sequences of a single length ({lengths_line})"
@@ -1023,6 +1028,7 @@ def seq_length_dist_plot(self, status_checks: bool = True):
name="Sequence Length Distribution",
anchor="fastqc_sequence_length_distribution",
content=f'
{desc}
',
+ statuses=status_dict if section_statuses else None,
)
else:
pconfig = LinePlotConfig(
@@ -1032,18 +1038,18 @@ def seq_length_dist_plot(self, status_checks: bool = True):
xlab="Sequence Length (bp)",
ymin=0,
tt_label="
{point.x} bp : {point.y}",
- showlegend=False if status_checks else True,
+ showlegend=False,
+ colors=self.get_status_cols("sequence_length_distribution"),
)
- if status_checks:
- pconfig.colors = self.get_status_cols("sequence_length_distribution")
self.add_section(
name="Sequence Length Distribution",
anchor="fastqc_sequence_length_distribution",
description="The distribution of fragment sizes (read lengths) found. See the [FastQC help](http://www.bioinformatics.babraham.ac.uk/projects/fastqc/Help/3%20Analysis%20Modules/7%20Sequence%20Length%20Distribution.html)",
plot=linegraph.plot(cnt_by_range_by_sample, pconfig),
+ statuses=status_dict if section_statuses else None,
)
- def seq_dup_levels_plot(self, status_checks: bool = True):
+ def seq_dup_levels_plot(self, section_statuses: Dict[SampleName, str]):
"""Create the HTML for the Sequence Duplication Levels plot"""
data: Dict[SampleName, Dict[Union[float, str], Any]] = dict()
@@ -1065,6 +1071,13 @@ def seq_dup_levels_plot(self, status_checks: bool = True):
if len(data) == 0:
log.debug("sequence_length_distribution not found in FastQC reports")
return None
+
+ # Convert status dict format
+ status_dict: Dict[Literal["pass", "warn", "fail"], List[str]] = {"pass": [], "warn": [], "fail": []}
+ for s_name, status in section_statuses.items():
+ if status in status_dict:
+ status_dict[status].append(s_name)
+
pconfig = {
"id": f"{self.anchor}_sequence_duplication_levels_plot",
"title": "FastQC: Sequence Duplication Levels",
@@ -1075,10 +1088,9 @@ def seq_dup_levels_plot(self, status_checks: bool = True):
"ymin": 0,
"tt_decimals": 2,
"tt_suffix": "%",
- "showlegend": False if status_checks else True,
+ "showlegend": False,
+ "colors": self.get_status_cols("sequence_duplication_levels"),
}
- if status_checks:
- pconfig["colors"] = self.get_status_cols("sequence_duplication_levels")
self.add_section(
name="Sequence Duplication Levels",
@@ -1110,6 +1122,7 @@ def seq_dup_levels_plot(self, status_checks: bool = True):
right of the plot._
""",
plot=linegraph.plot(data, pconfig),
+ statuses=status_dict if section_statuses else None,
)
def overrepresented_sequences(self):
@@ -1287,7 +1300,7 @@ class Metrics(TypedDict):
),
)
- def adapter_content_plot(self, status_checks: bool = True):
+ def adapter_content_plot(self, section_statuses: Dict[SampleName, str]):
"""Create the HTML for the FastQC adapter plot"""
pct_by_pos_by_sample: Dict[str, Dict[int, int]] = dict()
@@ -1312,6 +1325,13 @@ def adapter_content_plot(self, status_checks: bool = True):
k: d for k, d in pct_by_pos_by_sample.items() if max(pct_by_pos_by_sample[k].values()) >= 0.1
}
+ # Convert status dict format
+ status_dict: Dict[Literal["pass", "warn", "fail"], List[str]] = {"pass": [], "warn": [], "fail": []}
+ for s_name, status in section_statuses.items():
+ if status in status_dict:
+ status_dict[status].append(s_name)
+
+ status_checks = getattr(config, "fastqc_config", {}).get("status_checks", True)
pconfig: Dict[str, Any] = {
"id": f"{self.anchor}_adapter_content_plot",
"title": "FastQC: Adapter Content",
@@ -1364,6 +1384,7 @@ def adapter_content_plot(self, status_checks: bool = True):
""",
plot=plot,
content=content,
+ statuses=status_dict if section_statuses else None,
)
def status_heatmap(self):
@@ -1395,7 +1416,7 @@ def status_heatmap(self):
"max": 1,
"square": False,
"colstops": [
- [0, "#ffffff"],
+ [0, "#ffffff00"],
[0.25, "#d9534f"],
[0.5, "#fee391"],
[1, "#5cb85c"],
diff --git a/multiqc/modules/glimpse/err_grp.py b/multiqc/modules/glimpse/err_grp.py
index 3a7c9855d6..ee4b92bbef 100644
--- a/multiqc/modules/glimpse/err_grp.py
+++ b/multiqc/modules/glimpse/err_grp.py
@@ -106,28 +106,28 @@ def accuracy_plot(module, data):
pconfig={
"data_labels": [
{
- "name": "Best genotype r-squared (SNPs)",
- "ylab": "Best genotype r-squared (SNPs)",
+ "name": "Best genotype r
2 (SNPs)",
+ "ylab": "Best genotype r
2 (SNPs)",
},
{
- "name": "Imputed dosage r-squared (SNPs)",
- "ylab": "Imputed dosage r-squared (SNPs)",
+ "name": "Imputed dosage r
2 (SNPs)",
+ "ylab": "Imputed dosage r
2 (SNPs)",
},
{
- "name": "Best genotype r-squared (indels)",
- "ylab": "Best genotype r-squared (indels)",
+ "name": "Best genotype r
2 (indels)",
+ "ylab": "Best genotype r
2 (indels)",
},
{
- "name": "Imputed dosage r-squared (indels)",
- "ylab": "Imputed dosage r-squared (indels)",
+ "name": "Imputed dosage r
2 (indels)",
+ "ylab": "Imputed dosage r
2 (indels)",
},
{
- "name": "Best genotype r-squared (SNPs + indels)",
- "ylab": "Best genotype r-squared (SNPs + indels)",
+ "name": "Best genotype r
2 (SNPs + indels)",
+ "ylab": "Best genotype r
2 (SNPs + indels)",
},
{
- "name": "Imputed dosage r-squared (SNPs + indels)",
- "ylab": "Imputed dosage r-squared (SNPs + indels)",
+ "name": "Imputed dosage r
2 (SNPs + indels)",
+ "ylab": "Imputed dosage r
2 (SNPs + indels)",
},
],
"id": "glimpse-err-grp-plot",
@@ -139,6 +139,7 @@ def accuracy_plot(module, data):
"xmax": 0.5,
"ymin": 0,
"ymax": 1.1,
+ "axis_controlled_by_switches": ["xaxis", "yaxis"],
"title": "Glimpse concordance by allele frequency bins",
},
),
diff --git a/multiqc/modules/glimpse/err_spl.py b/multiqc/modules/glimpse/err_spl.py
index a688a258f3..7d107943ef 100644
--- a/multiqc/modules/glimpse/err_spl.py
+++ b/multiqc/modules/glimpse/err_spl.py
@@ -144,6 +144,7 @@ def parse_glimpse_err_spl(module: BaseMultiqcModule) -> int:
"max": 100,
"suffix": "%",
"scale": "YlOrRd",
+ "format": "{:,.2f}",
},
"RA_het_mismatches_rate_percent": {
"title": "Reference-Alternate heterozygous mismatches rate",
@@ -152,6 +153,7 @@ def parse_glimpse_err_spl(module: BaseMultiqcModule) -> int:
"max": 100,
"suffix": "%",
"scale": "YlOrRd",
+ "format": "{:,.2f}",
},
"AA_hom_mismatches_rate_percent": {
"title": "Alternate-Alternate homozygous mismatches rate",
@@ -160,6 +162,7 @@ def parse_glimpse_err_spl(module: BaseMultiqcModule) -> int:
"max": 100,
"suffix": "%",
"scale": "YlOrRd",
+ "format": "{:,.2f}",
},
"non_reference_discordance_rate_percent": {
"title": "Non-reference discordance rate",
@@ -168,20 +171,23 @@ def parse_glimpse_err_spl(module: BaseMultiqcModule) -> int:
"max": 100,
"suffix": "%",
"scale": "YlOrRd",
+ "format": "{:,.2f}",
},
"best_gt_rsquared": {
- "title": "Best genotype r-squared",
- "description": "Best genotype r-squared",
+ "title": "Best genotype r
2 ",
+ "description": "Best genotype r
2 ",
"min": 0,
"max": 1,
"scale": "YlGn",
+ "format": "{:,.4f}",
},
"imputed_ds_rsquared": {
- "title": "Imputed dosage r-squared",
- "description": "Imputed dosage r-squared",
+ "title": "Imputed dosage r
2 ",
+ "description": "Imputed dosage r
2 ",
"min": 0,
"max": 1,
"scale": "YlGn",
+ "format": "{:,.4f}",
},
}
diff --git a/multiqc/modules/hicup/hicup.py b/multiqc/modules/hicup/hicup.py
index 054a31c5a2..753313c078 100644
--- a/multiqc/modules/hicup/hicup.py
+++ b/multiqc/modules/hicup/hicup.py
@@ -1,4 +1,5 @@
import logging
+import re
from multiqc import config
from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound
@@ -30,9 +31,9 @@ def __init__(self):
log.info(f"Found {len(self.hicup_data)} reports")
- # Superfluous function call to confirm that it is used in this module
- # Replace None with actual version if it is available
- self.add_software_version(None)
+ # Parse version from HTML reports
+ for f in self.find_log_files("hicup/html"):
+ self.parse_hicup_html(f)
# Write parsed data to a file
self.write_data_file(self.hicup_data, "multiqc_hicup")
@@ -262,3 +263,15 @@ def hicup_dedup_chart(self):
}
return bargraph.plot(self.hicup_data, keys, config)
+
+ def parse_hicup_html(self, f):
+ """Parse HiCUP HTML reports to extract version information."""
+ match = re.search(r"HiCUP.*?\((\d+\.\d+[\.\d]*)\)", f["f"])
+ if match:
+ version = match.group(1)
+ # Try to derive sample name from HTML filename
+ # HTML files are named like: Sample-1.A002.C8DRAANXX.s_2.r_1_2.HiCUP_summary_report.html
+ suffix = ".HiCUP_summary_report.html"
+ base_name = f["fn"][: -len(suffix)] if f["fn"].endswith(suffix) else f["fn"]
+ s_name = self.clean_s_name(base_name, f)
+ self.add_software_version(version, s_name)
diff --git a/multiqc/modules/homer/tagdirectory.py b/multiqc/modules/homer/tagdirectory.py
index e9986e16d5..e67a66438a 100644
--- a/multiqc/modules/homer/tagdirectory.py
+++ b/multiqc/modules/homer/tagdirectory.py
@@ -342,13 +342,13 @@ def parse_tag_info_chrs(self, f, convChr=True):
if any(x in key for x in remove):
continue
try:
- vT = float(s[1].strip())
- vU = float(s[2].strip())
+ vU = float(s[1].strip())
+ vT = float(s[2].strip())
except ValueError:
continue
- parsed_data_total[key] = vT
parsed_data_uniq[key] = vU
+ parsed_data_total[key] = vT
return [parsed_data_total, parsed_data_uniq]
diff --git a/multiqc/modules/humid/clusters.py b/multiqc/modules/humid/clusters.py
index f80eb9d0e0..dd2ca697cc 100644
--- a/multiqc/modules/humid/clusters.py
+++ b/multiqc/modules/humid/clusters.py
@@ -51,7 +51,6 @@ def add_to_humid_section(self):
"xlab": "Cluster size",
"logswitch": True,
"logswitch_active": True,
- "hide_zero_cats": False,
}
self.add_section(
name="Cluster statistics",
diff --git a/multiqc/modules/humid/counts.py b/multiqc/modules/humid/counts.py
index 66fe164402..c1e9ff4ba9 100644
--- a/multiqc/modules/humid/counts.py
+++ b/multiqc/modules/humid/counts.py
@@ -51,7 +51,6 @@ def add_to_humid_section(self):
"xlab": "Number of identical reads in a node",
"logswitch": True,
"logswitch_active": True,
- "hide_zero_cats": False,
}
self.add_section(
name="Counts statistics",
diff --git a/multiqc/modules/humid/neighbours.py b/multiqc/modules/humid/neighbours.py
index 275fc16c9f..7e693b038f 100644
--- a/multiqc/modules/humid/neighbours.py
+++ b/multiqc/modules/humid/neighbours.py
@@ -51,7 +51,6 @@ def add_to_humid_section(self):
"xlab": "Number of neighbours",
"logswitch": True,
"logswitch_active": True,
- "hide_zero_cats": False,
}
self.add_section(
name="Neighbour statistics",
diff --git a/multiqc/modules/librarian/librarian.py b/multiqc/modules/librarian/librarian.py
index 6f56942479..727d2cca6a 100644
--- a/multiqc/modules/librarian/librarian.py
+++ b/multiqc/modules/librarian/librarian.py
@@ -34,11 +34,11 @@ def __init__(self):
href="https://github.com/DesmondWillowbrook/Librarian",
info="Predicts the sequencing library type from the base composition of a FastQ file.",
extra="""
- Librarian reads from high throughput sequencing experiments show base compositions that are
- characteristic for their library type. For example, data from RNA-seq and WGBS-seq libraries show markedly
+ Librarian reads from high throughput sequencing experiments show base compositions that are
+ characteristic for their library type. For example, data from RNA-seq and WGBS-seq libraries show markedly
different distributions of G, A, C and T across the reads.
-
- Librarian makes use of different composition signatures for library quality control: Test library
+
+ Librarian makes use of different composition signatures for library quality control: Test library
compositions are extracted and compared against previously published data sets from mouse and human.
""",
doi="10.12688/f1000research.125325.1",
@@ -118,7 +118,7 @@ def plot_librarian_heatmap(self):
"square": False,
"xcats_samples": False,
"ycats_samples": True,
- "colstops": [[0, "#FFFFFF"], [1, "#FF0000"]],
+ "colstops": [[0, "#FFFFFF00"], [1, "#FF0000"]],
"tt_decimals": 0,
}
diff --git a/multiqc/modules/lima/lima.py b/multiqc/modules/lima/lima.py
index 0a409311ca..52969fa5cd 100644
--- a/multiqc/modules/lima/lima.py
+++ b/multiqc/modules/lima/lima.py
@@ -129,7 +129,7 @@ def parse_lima_counts(self, file_content, f):
# A dictionary to store the results
lima_counts = dict()
for line in file_content:
- spline = line.strip().split()
+ spline = line.strip().split("\t")
data = {field: value for field, value in zip(header, spline)}
first_barcode = data["IdxFirstNamed"]
diff --git a/multiqc/modules/minionqc/minionqc.py b/multiqc/modules/minionqc/minionqc.py
index ee42c7d107..ef142ac327 100755
--- a/multiqc/modules/minionqc/minionqc.py
+++ b/multiqc/modules/minionqc/minionqc.py
@@ -7,6 +7,7 @@
from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound
from multiqc.plots import linegraph, table
+from multiqc.utils.material_icons import get_material_icon
log = logging.getLogger(__name__)
@@ -23,8 +24,8 @@ def __init__(self):
href="https://github.com/roblanf/minion_qc",
info="Quality control for ONT (Oxford Nanopore) long reads",
extra="""
- It uses the `sequencing_summary.txt` files produced by ONT (Oxford Nanopore Technologies)
- long-read base-callers to perform QC on the reads. It allows quick-and-easy comparison of data from
+ It uses the `sequencing_summary.txt` files produced by ONT (Oxford Nanopore Technologies)
+ long-read base-callers to perform QC on the reads. It allows quick-and-easy comparison of data from
multiple flowcells
""",
doi="10.1093/bioinformatics/bty654",
@@ -194,9 +195,9 @@ def table_qfiltered(self):
", ".join(list(self.q_threshold_list))
)
if len(self.q_threshold_list) > 1:
- description += """
+ description += f"""
-
+ ${get_material_icon("mdi:warning", 16)}
Warning! More than one quality thresholds were present.
"""
diff --git a/multiqc/modules/mirtrace/mirtrace.py b/multiqc/modules/mirtrace/mirtrace.py
index 8930e218d3..e053f2b4df 100755
--- a/multiqc/modules/mirtrace/mirtrace.py
+++ b/multiqc/modules/mirtrace/mirtrace.py
@@ -20,7 +20,7 @@ def __init__(self):
sequencing depth and miRNA complexity and also identifies the presence of both
miRNAs and undesirable sequences derived from tRNAs, rRNAs, or Illumina artifact
sequences.
-
+
miRTrace also profiles clade-specific miRNAs based on a comprehensive catalog
of clade-specific miRNA families identified previously. With this information,
miRTrace can detect exogenous miRNAs, which could be contamination derived,
@@ -253,10 +253,10 @@ def mirtrace_length_plot(self):
"x_decimals": False,
"tt_label": "
Read Length (bp) {point.x} : {point.y} Read Count",
"x_bands": [
- {"from": 40, "to": 50, "color": "#ffebd1"},
- {"from": 26, "to": 40, "color": "#e2f5ff"},
- {"from": 18, "to": 26, "color": "#e5fce0"},
- {"from": 0, "to": 18, "color": "#ffffe2"},
+ {"from": 40, "to": 50, "color": "#ff9100", "opacity": 0.1},
+ {"from": 26, "to": 40, "color": "#00a6ff", "opacity": 0.1},
+ {"from": 18, "to": 26, "color": "#23bc00", "opacity": 0.1},
+ {"from": 0, "to": 18, "color": "#e5e500", "opacity": 0.1},
],
}
diff --git a/multiqc/modules/mosdepth/mosdepth.py b/multiqc/modules/mosdepth/mosdepth.py
index 6c5b7b7c0b..9fffaff972 100755
--- a/multiqc/modules/mosdepth/mosdepth.py
+++ b/multiqc/modules/mosdepth/mosdepth.py
@@ -313,7 +313,6 @@ def __init__(self):
"tt_suffix": "x",
"smooth_points": 500,
"logswitch": True,
- "hide_zero_cats": False,
"categories": True,
},
)
diff --git a/multiqc/modules/motus/motus.py b/multiqc/modules/motus/motus.py
index fc58c88740..221d3acf54 100644
--- a/multiqc/modules/motus/motus.py
+++ b/multiqc/modules/motus/motus.py
@@ -157,75 +157,80 @@ def motus_general_stats(self):
def motus_filtering_bargraph_plot(self):
"""mOTUs read counts for general stats"""
- common = {
- "min": 0,
- "modify": lambda x: float(x) * config.read_count_multiplier,
- "suffix": f"{config.read_count_prefix} reads",
- "tt_decimals": 0,
- "shared_key": "read_count",
- }
cats = {
- "Number of reads after filtering": dict(common, **{"name": "Reads after mapping"}),
- "Discarded reads": dict(common, **{"name": "Unmapped reads"}),
+ "Number of reads after filtering": {"name": "Reads after mapping"},
+ "Discarded reads": {"name": "Unmapped reads"},
}
+ # Apply read count multiplier to data
+ plot_data = {}
+ for s_name, data in self.motus_data.items():
+ plot_data[s_name] = {
+ "Number of reads after filtering": float(data.get("Number of reads after filtering", 0))
+ * config.read_count_multiplier,
+ "Discarded reads": float(data.get("Discarded reads", 0)) * config.read_count_multiplier,
+ }
+
self.add_section(
name="mOTUs: Read filtering information",
anchor="motus-filtering",
description="Read filtering statistics (i.e. mapping of reads to the mOTUs marker database).",
plot=bargraph.plot(
- self.motus_data,
+ plot_data,
cats,
{
"id": "motus-filtering-reads",
"title": "Motus: Read filtering information",
"ylab": "Reads",
+ "ysuffix": f" {config.read_count_prefix} reads",
+ "tt_decimals": 0,
},
),
)
def motus_mapping_bargraph_plot(self):
"""mOTUs bar chart of insert types"""
- common = {
- "min": 0,
- "modify": lambda x: float(x) * config.read_count_multiplier,
- "suffix": f"{config.read_count_prefix} reads",
- "tt_decimals": 0,
- "shared_key": "read_count",
- }
cats = {
- "Unique mappers": dict(common, **{"name": "Unique mapped inserts", "color": "#3aba5e"}),
- "Multiple mappers": dict(common, **{"name": "Multiple mapped inserts", "color": "#ebbe59"}),
- "Ignored multiple mapper without unique hit": dict(
- common, **{"name": "Ignored multi-mapped inserts", "color": "#cf5565"}
- ),
+ "Unique mappers": {"name": "Unique mapped inserts", "color": "#3aba5e"},
+ "Multiple mappers": {"name": "Multiple mapped inserts", "color": "#ebbe59"},
+ "Ignored multiple mapper without unique hit": {"name": "Ignored multi-mapped inserts", "color": "#cf5565"},
}
+ # Apply read count multiplier to data
+ plot_data = {}
+ for s_name, data in self.motus_data.items():
+ plot_data[s_name] = {
+ "Unique mappers": float(data.get("Unique mappers", 0)) * config.read_count_multiplier,
+ "Multiple mappers": float(data.get("Multiple mappers", 0)) * config.read_count_multiplier,
+ "Ignored multiple mapper without unique hit": float(
+ data.get("Ignored multiple mapper without unique hit", 0)
+ )
+ * config.read_count_multiplier,
+ }
+
self.add_section(
name="Motus: Insert mapping information",
anchor="motus-mapping",
description="How inserts was classified after alignment to MGCs.",
plot=bargraph.plot(
- self.motus_data,
+ plot_data,
cats,
{
"id": "motus-mapping-inserts",
"title": "Motus: Insert mapping information",
"ylab": "Inserts",
+ "ysuffix": f" {config.read_count_prefix} reads",
+ "tt_decimals": 0,
},
),
)
def motus_motus_bargraph_plot(self):
"""mOTUs bar chart of mOTU types"""
- common = {
- "min": 0,
- "tt_decimals": 0,
- }
cats = {
- "Number of ref-mOTUs": dict(common, **{"name": "Known mOTUs"}),
- "Number of meta-mOTUs": dict(common, **{"name": "(Unknown) Metagenome mOTUs"}),
- "Number of ext-mOTUs": dict(common, **{"name": "(Unknown) MAG mOTUs"}),
+ "Number of ref-mOTUs": {"name": "Known mOTUs"},
+ "Number of meta-mOTUs": {"name": "(Unknown) Metagenome mOTUs"},
+ "Number of ext-mOTUs": {"name": "(Unknown) MAG mOTUs"},
}
self.add_section(
@@ -239,6 +244,7 @@ def motus_motus_bargraph_plot(self):
"id": "motus-identification-types",
"title": "Motus: mOTU identification information",
"ylab": "Motus",
+ "tt_decimals": 0,
},
),
)
diff --git a/multiqc/modules/ngsderive/ngsderive.py b/multiqc/modules/ngsderive/ngsderive.py
index 7d6905b0c7..3bd9455c32 100644
--- a/multiqc/modules/ngsderive/ngsderive.py
+++ b/multiqc/modules/ngsderive/ngsderive.py
@@ -16,7 +16,7 @@ def __init__(self):
href="https://github.com/stjudecloud/ngsderive",
info="Forensic tool for by backwards computing library information in sequencing data",
extra="""
- Results are provided as a 'best guess' — the tool does not claim 100% accuracy and results
+ Results are provided as a 'best guess' — the tool does not claim 100% accuracy and results
should be considered with that understanding. Please see the documentation for more information.
""",
# Can't find a DOI // doi=
@@ -189,9 +189,9 @@ def add_instrument_data(self):
self.write_data_file(self.instrument, "ngsderive_instrument")
bgcols = {
- "low confidence": "#f8d7da",
- "medium confidence": "#fff3cd",
- "high confidence": "#d1e7dd",
+ "low confidence": "#e5001336",
+ "medium confidence": "#f7bd0052",
+ "high confidence": "#00c36b4a",
}
cond_formatting_rules = {
"pass": [{"s_eq": "high confidence"}],
diff --git a/multiqc/modules/pangolin/pangolin.py b/multiqc/modules/pangolin/pangolin.py
index 5249e1c7eb..a13df2187e 100644
--- a/multiqc/modules/pangolin/pangolin.py
+++ b/multiqc/modules/pangolin/pangolin.py
@@ -18,7 +18,7 @@ def __init__(self):
info="Uses variant calls to assign SARS-CoV-2 genome sequences to global lineages.",
extra="""
Implements the dynamic nomenclature of SARS-CoV-2 lineages, known as the Pango nomenclature.
- It allows a user to assign a SARS-CoV-2 genome sequence the most likely lineage (Pango lineage)
+ It allows a user to assign a SARS-CoV-2 genome sequence the most likely lineage (Pango lineage)
to SARS-CoV-2 query sequences.
""",
doi="10.1093/ve/veab064",
@@ -49,7 +49,7 @@ def __init__(self):
for idx, k in enumerate(self.lineage_colours):
self.lineage_colours[k] = cols.get_colour(idx)
# Manually add back None as grey
- self.lineage_colours["None"] = "#EFEFEF"
+ self.lineage_colours["None"] = "#8888882F"
self.pangolin_general_stats_table()
diff --git a/multiqc/modules/picard/IlluminaBasecallingMetrics.py b/multiqc/modules/picard/IlluminaBasecallingMetrics.py
index 1b878a924c..6f1f03ae04 100644
--- a/multiqc/modules/picard/IlluminaBasecallingMetrics.py
+++ b/multiqc/modules/picard/IlluminaBasecallingMetrics.py
@@ -105,16 +105,16 @@ def lane_metrics_plot(self, data):
plot_cats = [
{
- "PF_BASES": {"title": "Passing Filter Bases"},
- "NPF_BASES": {"title": "Non Passing Filter Bases"},
+ "PF_BASES": {"name": "Passing Filter Bases"},
+ "NPF_BASES": {"name": "Non Passing Filter Bases"},
},
{
- "PF_READS": {"title": "Passing Filter Reads"},
- "NPF_READS": {"title": "Non Passing Filter Reads"},
+ "PF_READS": {"name": "Passing Filter Reads"},
+ "NPF_READS": {"name": "Non Passing Filter Reads"},
},
{
- "PF_CLUSTERS": {"title": "Passing Filter Clusters"},
- "NPF_CLUSTERS": {"title": "Non Passing Filter Clusters"},
+ "PF_CLUSTERS": {"name": "Passing Filter Clusters"},
+ "NPF_CLUSTERS": {"name": "Non Passing Filter Clusters"},
},
]
tdata = {}
diff --git a/multiqc/modules/picard/RnaSeqMetrics.py b/multiqc/modules/picard/RnaSeqMetrics.py
index 04bebe8f21..828fef4385 100644
--- a/multiqc/modules/picard/RnaSeqMetrics.py
+++ b/multiqc/modules/picard/RnaSeqMetrics.py
@@ -5,6 +5,7 @@
from multiqc.modules.picard import util
from multiqc.plots import bargraph, linegraph
+from multiqc.utils.material_icons import get_material_icon
# Initialise the logger
log = logging.getLogger(__name__)
@@ -142,7 +143,7 @@ def parse_reports(module):
missing_samples = f"
{len(rrna_missing)} samples "
warn_rrna = f"""
-
+ ${get_material_icon("mdi:warning", 16)}
Picard was run without an rRNA annotation file {missing_samples}, therefore the ribosomal assignment is not available. To correct, rerun with the
RIBOSOMAL_INTERVALS parameter, as documented
here .
"""
diff --git a/multiqc/modules/picard/ValidateSamFile.py b/multiqc/modules/picard/ValidateSamFile.py
index 532c3ac99e..ede9a77788 100644
--- a/multiqc/modules/picard/ValidateSamFile.py
+++ b/multiqc/modules/picard/ValidateSamFile.py
@@ -241,11 +241,11 @@ def _add_section_to_report(module, data):
"This tool reports on the validity of a SAM or BAM file relative to the SAM-format specification."
),
helptext="""
- A detailed table is only shown if errors or warnings are found. Details
- about the errors and warnings are only shown if a `SUMMARY` report was
+ A detailed table is only shown if errors or warnings are found. Details
+ about the errors and warnings are only shown if a `SUMMARY` report was
parsed.
- For more information on the warnings, errors and possible fixes please
+ For more information on the warnings, errors and possible fixes please
read [this broadinstitute article](
https://software.broadinstitute.org/gatk/documentation/article.php?id
=7571).""",
@@ -300,7 +300,7 @@ def _generate_overview_note(pass_count, only_warning_count, error_count, total_c
if b[0]:
note_html.append(
f'
1 else "sample"} {b[2]}">{int(b[0])}
'
+ f'data-bs-toggle="tooltip" title="{int(b[0])} {"samples" if b[0] > 1 else "sample"} {b[2]}">{int(b[0])}
'
)
note_html.append("
")
diff --git a/multiqc/modules/qc3C/qc3C.py b/multiqc/modules/qc3C/qc3C.py
index fd99ebb94a..8d6dac9098 100644
--- a/multiqc/modules/qc3C/qc3C.py
+++ b/multiqc/modules/qc3C/qc3C.py
@@ -950,7 +950,7 @@ def _none_to(x, y):
if parsed[k] is None:
parsed[k] = "Error - adjusted value would exceed 100"
else:
- parsed[k] = np.array(parsed[k]).mean() * 100
+ parsed[k] = float(np.array(parsed[k]).mean() * 100)
self.qc3c_data["kmer"][s_name] = {
"k_qc3C_version": parsed["runtime_info"]["qc3C_version"],
diff --git a/multiqc/modules/qorts/qorts.py b/multiqc/modules/qorts/qorts.py
index 14a38ef1f1..06c7a2a62f 100644
--- a/multiqc/modules/qorts/qorts.py
+++ b/multiqc/modules/qorts/qorts.py
@@ -41,9 +41,9 @@ def __init__(self):
log.info(f"Found {len(self.qorts_data)} logs")
- # Superfluous function call to confirm that it is used in this module
- # Replace None with actual version if it is available
- self.add_software_version(None)
+ # Parse version from log files
+ for f in self.find_log_files("qorts/log"):
+ self.parse_qorts_log(f)
self.write_data_file(self.qorts_data, "multiqc_qorts")
@@ -300,3 +300,14 @@ def qorts_strandedness_plot(self):
""",
plot=bargraph.plot(self.qorts_data, cats, pconfig),
)
+
+ def parse_qorts_log(self, f):
+ """Parse QoRTs log files to extract version information."""
+ match = re.search(r"Starting QoRTs v(\d+\.\d+[\.\d]*)", f["f"])
+ if match:
+ # Derive sample name from directory (same logic as parse_qorts for single-sample)
+ s_name = self.clean_s_name(os.path.basename(os.path.normpath(f["root"])), f)
+ # Only associate with sample if it exists in our data
+ if s_name not in self.qorts_data:
+ s_name = None
+ self.add_software_version(match.group(1), s_name)
diff --git a/multiqc/modules/qualimap/QM_BamQC.py b/multiqc/modules/qualimap/QM_BamQC.py
index ff171e9006..5d3710d818 100644
--- a/multiqc/modules/qualimap/QM_BamQC.py
+++ b/multiqc/modules/qualimap/QM_BamQC.py
@@ -6,7 +6,7 @@
import re
from multiqc import config, BaseMultiqcModule
-from multiqc.modules.qualimap import parse_numerals, get_s_name
+from multiqc.modules.qualimap import parse_numerals, get_s_name, parse_version
from multiqc.plots import linegraph
from multiqc.utils.util_functions import update_dict
@@ -46,9 +46,12 @@ def parse_reports(module: BaseMultiqcModule):
if num_parsed == 0:
return 0
- # Superfluous function call to confirm that it is used in this module
- # Replace None with actual version if it is available
- module.add_software_version(None)
+ # Parse version from HTML reports
+ for f in module.find_log_files("qualimap/bamqc/html"):
+ version = parse_version(f)
+ if version:
+ s_name = get_s_name(module, f)
+ module.add_software_version(version, s_name, "BamQC")
threshs, hidden_threshs = config.get_cov_thresholds("qualimap_config")
diff --git a/multiqc/modules/qualimap/QM_RNASeq.py b/multiqc/modules/qualimap/QM_RNASeq.py
index c3a0602694..c8e66c1699 100644
--- a/multiqc/modules/qualimap/QM_RNASeq.py
+++ b/multiqc/modules/qualimap/QM_RNASeq.py
@@ -6,7 +6,7 @@
from typing import Dict
from multiqc import BaseMultiqcModule, config
-from multiqc.modules.qualimap import get_s_name, parse_numerals
+from multiqc.modules.qualimap import get_s_name, parse_numerals, parse_version
from multiqc.plots import bargraph, linegraph
log = logging.getLogger(__name__)
@@ -150,9 +150,12 @@ def parse_reports(module: BaseMultiqcModule) -> int:
cov_hist[s_name] = d
- # Superfluous function call to confirm that it is used in this module
- # Replace None with actual version if it is available
- module.add_software_version(None)
+ # Parse version from HTML reports
+ for f in module.find_log_files("qualimap/rnaseq/html"):
+ version = parse_version(f)
+ if version:
+ s_name = get_s_name(module, f)
+ module.add_software_version(version, s_name, "RNASeq")
# Filter to strip out ignored sample names
genome_results = module.ignore_samples(genome_results)
diff --git a/multiqc/modules/qualimap/__init__.py b/multiqc/modules/qualimap/__init__.py
index 42328d7766..8ae8b821bc 100755
--- a/multiqc/modules/qualimap/__init__.py
+++ b/multiqc/modules/qualimap/__init__.py
@@ -1,6 +1,7 @@
import logging
import os
-from typing import Dict, Union
+import re
+from typing import Dict, Optional, Union
from multiqc import BaseMultiqcModule
from multiqc.modules.qualimap.qualimap import MultiqcModule
@@ -9,6 +10,16 @@
log = logging.getLogger(__name__)
+VERSION_REGEX = r"Generated by QualiMap v\.(\d+\.\d+[\.\d\-a-zA-Z]*)"
+
+
+def parse_version(f) -> Optional[str]:
+ """Parse QualiMap version from HTML report."""
+ match = re.search(VERSION_REGEX, f["f"])
+ if match:
+ return match.group(1)
+ return None
+
def parse_numerals(
preparsed_d: Dict[str, str],
@@ -101,6 +112,4 @@ def parse_numerals(
def get_s_name(module: BaseMultiqcModule, f):
s_name = os.path.basename(os.path.dirname(f["root"]))
s_name = module.clean_s_name(s_name, f)
- if s_name.endswith(".qc"):
- s_name = s_name[:-3]
return s_name
diff --git a/multiqc/modules/ribotish/__init__.py b/multiqc/modules/ribotish/__init__.py
new file mode 100644
index 0000000000..37060ba6e0
--- /dev/null
+++ b/multiqc/modules/ribotish/__init__.py
@@ -0,0 +1,3 @@
+from .ribotish import MultiqcModule
+
+__all__ = ["MultiqcModule"]
diff --git a/multiqc/modules/ribotish/ribotish.py b/multiqc/modules/ribotish/ribotish.py
new file mode 100644
index 0000000000..05d3275efa
--- /dev/null
+++ b/multiqc/modules/ribotish/ribotish.py
@@ -0,0 +1,477 @@
+import ast
+import logging
+from typing import Dict
+
+from multiqc import config
+from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound
+from multiqc.plots import bargraph, heatmap, linegraph
+from multiqc.plots.table_object import ColumnDict
+
+log = logging.getLogger(__name__)
+
+
+class MultiqcModule(BaseMultiqcModule):
+ """
+ Ribo-TISH is a tool for identifying translated ORFs from Ribo-seq data.
+ This module parses the `*_qual.txt` output files to visualize reading frame
+ quality metrics across different read lengths.
+
+ The module creates one of two visualizations:
+ 1. A stacked bar chart showing the proportion of reads in each reading frame
+ (Frame 0, 1, 2) for read lengths 25-34nt
+ 2. A heatmap showing the percentage distribution of read lengths within each sample
+ """
+
+ def __init__(self):
+ super(MultiqcModule, self).__init__(
+ name="Ribo-TISH",
+ anchor="ribotish",
+ href="https://github.com/zhpn1024/ribotish",
+ info="Identifies translated ORFs from Ribo-seq data and reports reading frame quality metrics.",
+ doi="10.1186/s13059-017-1316-1",
+ )
+
+ # Store parsed data
+ self.ribotish_data: Dict[str, Dict] = {}
+ self.frame_proportions: Dict[str, Dict] = {}
+
+ # Parse all *_qual.txt files
+ for f in self.find_log_files("ribotish/qual"):
+ parsed_data = self.parse_ribotish_qual(f)
+ if parsed_data:
+ sample_name = f["s_name"]
+ self.ribotish_data[sample_name] = parsed_data
+ self.add_data_source(f)
+
+ # Filter to strip out ignored sample names
+ self.ribotish_data = self.ignore_samples(self.ribotish_data)
+
+ if not self.ribotish_data:
+ raise ModuleNoSamplesFound
+
+ log.info(f"Found {len(self.ribotish_data)} Ribo-TISH reports")
+
+ # Calculate frame proportions for all samples
+ self.calculate_frame_proportions()
+
+ # Call add_software_version even though version is not available
+ self.add_software_version(None)
+
+ # Add sections with plots
+ self.add_frame_proportion_bargraph()
+ self.add_read_length_distribution()
+ self.add_general_stats()
+
+ # Write data file at the end
+ self.write_data_file(self.ribotish_data, "multiqc_ribotish")
+
+ def parse_ribotish_qual(self, f) -> Dict:
+ """
+ Parse Ribo-TISH *_qual.txt file.
+
+ The relevant information is on line 4 (0-indexed line 3) in Python dict format:
+ {25: [frame0_count, frame1_count, frame2_count], 26: [...], ...}
+
+ See: https://github.com/zhpn1024/ribotish#offset-parameter-file
+
+ Returns a dict with read lengths as keys and [f0, f1, f2] counts as values.
+ """
+ try:
+ lines = f["f"].splitlines()
+ if len(lines) < 4:
+ log.warning(f"File {f['fn']} has fewer than 4 lines, skipping")
+ return {}
+
+ input_string = lines[3].strip()
+
+ # Remove optional $ prefix if present
+ if input_string.startswith("$"):
+ input_string = input_string[1:].strip()
+
+ # Parse Python dict literal safely with ast.literal_eval
+ ribo_seq_counts = ast.literal_eval(input_string)
+
+ # Validate the parsed data structure
+ if not isinstance(ribo_seq_counts, dict):
+ log.warning(f"File {f['fn']}: expected dict, got {type(ribo_seq_counts).__name__}")
+ return {}
+
+ # Validate that all values are lists with exactly 3 elements
+ for read_length, counts in ribo_seq_counts.items():
+ if not isinstance(counts, list):
+ log.warning(
+ f"File {f['fn']}: expected list for read length {read_length}, got {type(counts).__name__}"
+ )
+ return {}
+ if len(counts) != 3:
+ log.warning(
+ f"File {f['fn']}: expected 3 frame counts for read length {read_length}, got {len(counts)}"
+ )
+ return {}
+ # Validate that all counts are numeric
+ if not all(isinstance(c, (int, float)) for c in counts):
+ log.warning(f"File {f['fn']}: non-numeric counts found for read length {read_length}")
+ return {}
+
+ log.debug(f"Successfully parsed {f['fn']} with {len(ribo_seq_counts)} read lengths")
+ return ribo_seq_counts
+
+ except (SyntaxError, ValueError, IndexError) as e:
+ log.warning(f"Error parsing {f['fn']}: {e}")
+ return {}
+
+ def calculate_frame_proportions(self):
+ """Calculate frame proportions for each sample and read length."""
+ for sample_name, ribo_seq_counts in self.ribotish_data.items():
+ self.frame_proportions[sample_name] = {}
+
+ for length, counts in ribo_seq_counts.items():
+ total = sum(counts)
+ if total > 0:
+ self.frame_proportions[sample_name][length] = {
+ "f0_prop": counts[0] / float(total),
+ "f1_prop": counts[1] / float(total),
+ "f2_prop": counts[2] / float(total),
+ "total": total,
+ }
+ else:
+ self.frame_proportions[sample_name][length] = {
+ "f0_prop": 0.0,
+ "f1_prop": 0.0,
+ "f2_prop": 0.0,
+ "total": 0.0,
+ }
+
+ def add_frame_proportion_bargraph(self):
+ """
+ Create a stacked bar graph showing frame proportions for all read lengths.
+ All read lengths are shown side-by-side in a single plot for visual comparison.
+ Each sample-length combination gets its own bar.
+ """
+ # First, collect all read lengths across all samples
+ all_lengths_set: set[int] = set()
+ for sample_data in self.frame_proportions.values():
+ all_lengths_set.update(sample_data.keys())
+ all_lengths = sorted(all_lengths_set)
+
+ plot_data = {}
+ sample_groups: Dict[str, list] = {}
+
+ for length in all_lengths:
+ group_name = f"{length}nt"
+ length_group = []
+ for sample_name in sorted(self.frame_proportions.keys()):
+ if length in self.frame_proportions[sample_name]:
+ props = self.frame_proportions[sample_name][length]
+ sample_key = f"{sample_name}_{length}nt"
+ plot_data[sample_key] = {
+ "Frame 0": props["f0_prop"] * 100.0,
+ "Frame 1": props["f1_prop"] * 100.0,
+ "Frame 2": props["f2_prop"] * 100.0,
+ }
+ length_group.append([sample_key, sample_name])
+ if length_group:
+ sample_groups[group_name] = length_group
+
+ pconfig = {
+ "id": "ribotish_frame_proportions",
+ "title": "Ribo-TISH: Reading Frame Proportions by Read Length",
+ "ylab": "Proportion of Reads (%)",
+ "stacking": "normal",
+ "hide_zero_cats": False,
+ "ymax": 100,
+ "use_legend": True,
+ "cpswitch": False,
+ "sample_groups": sample_groups,
+ "x_lines": [
+ {
+ "color": "#0000ff",
+ "dash": "dash",
+ "value": 33.33,
+ "width": 2,
+ "label": "Random distribution (1/3)",
+ },
+ {
+ "color": "#0000ff",
+ "dash": "dash",
+ "value": 66.67,
+ "width": 2,
+ "label": "Random distribution (2/3)",
+ },
+ ],
+ }
+
+ # Define categories (let MultiQC assign default colors)
+ cats = {
+ "Frame 0": {"name": "Frame 0"},
+ "Frame 1": {"name": "Frame 1"},
+ "Frame 2": {"name": "Frame 2"},
+ }
+
+ plot_html = bargraph.plot(plot_data, cats, pconfig)
+
+ self.add_section(
+ name="Reading Frame Proportions",
+ anchor="ribotish_frame_proportions_section",
+ description="Proportion of reads in each reading frame (Frame 0, 1, 2) for different read lengths (25-34nt). "
+ "Frame assignment is based on P-site positions as determined by Ribo-TISH. "
+ "Some degree of frame preference (enrichment in Frame 0) is typically expected in Ribo-seq data.",
+ helptext="""
+ This plot shows the distribution of reads across the three reading frames for each read length,
+ based on P-site positions.
+
+ * **Frame 0**: The primary reading frame
+ * **Frame 1**: Offset by 1 nucleotide from Frame 0
+ * **Frame 2**: Offset by 2 nucleotides from Frame 0
+
+ Ribo-seq data typically shows enrichment in Frame 0, particularly for read lengths 28-30nt,
+ though the degree of frame preference can vary depending on the experimental protocol
+ (e.g., RNase I vs. MNase treatment).
+ Read lengths are shown as separate bars for each sample.
+ """,
+ plot=plot_html,
+ )
+
+ def add_read_length_distribution(self):
+ """
+ Create plots showing the percentage distribution of read lengths.
+ Provides both line graph and heatmap views with a switcher.
+ """
+ # Collect all read lengths
+ all_lengths_set: set[int] = set()
+ for sample_data in self.frame_proportions.values():
+ all_lengths_set.update(sample_data.keys())
+ all_lengths = sorted(all_lengths_set)
+
+ # Calculate sample totals once for efficiency
+ sample_totals = {}
+ for sample_name in self.frame_proportions.keys():
+ sample_totals[sample_name] = sum(props["total"] for props in self.frame_proportions[sample_name].values())
+
+ if len(sample_totals) <= 30:
+ # Prepare data for line graph
+ line_data: dict[str, dict[int, float]] = {}
+ for sample_name in sorted(self.frame_proportions.keys()):
+ sample_total = sample_totals[sample_name]
+ line_data[sample_name] = {}
+ for length in all_lengths:
+ if length in self.frame_proportions[sample_name]:
+ count = self.frame_proportions[sample_name][length]["total"]
+ percentage = (count / sample_total * 100.0) if sample_total > 0 else 0
+ line_data[sample_name][length] = percentage
+ else:
+ line_data[sample_name][length] = 0
+
+ # Create line graph plot
+ line_pconfig = {
+ "id": "ribotish_read_length_line",
+ "title": "Ribo-TISH: Read Length Distribution",
+ "xlab": "Read Length (nt)",
+ "ylab": "% of Total Reads",
+ "smooth_points": 50,
+ "smooth_points_sumcounts": False,
+ "tt_label": "{point.x}nt: {point.y:.1f}%",
+ }
+ line_plot = linegraph.plot(line_data, line_pconfig)
+
+ # Combine plots with data_labels for switching
+ self.add_section(
+ name="Read Length Distribution",
+ anchor="ribotish_read_length_dist_section",
+ description="Percentage of reads at each read length for each sample. "
+ "Ribo-seq data typically shows enrichment around 28-30nt, representing ribosome-protected fragments.",
+ helptext="""
+ This plot shows what percentage of total reads each read length represents for each sample.
+
+ * Each line represents a different sample
+ * Peaks indicate the most common read lengths
+ * Multiple samples can be easily compared
+
+ The expected read length distribution can vary depending on the experimental protocol and organism.
+ """,
+ plot=line_plot,
+ )
+
+ else:
+ # Prepare data for heatmap
+ samples = sorted(self.frame_proportions.keys())
+ heatmap_data = []
+ for sample_name in samples:
+ sample_total = sample_totals[sample_name]
+ row = []
+ for length in all_lengths:
+ if length in self.frame_proportions[sample_name]:
+ count = self.frame_proportions[sample_name][length]["total"]
+ percentage = (count / sample_total * 100.0) if sample_total > 0 else 0
+ row.append(percentage)
+ else:
+ row.append(0)
+ heatmap_data.append(row)
+
+ # Create heatmap plot
+ heatmap_pconfig = {
+ "id": "ribotish_read_length_heatmap",
+ "title": "Ribo-TISH: Read Length Distribution (Heatmap)",
+ "xlab": "Read Length (nt)",
+ "ylab": "Sample",
+ "zlab": "% of Total Reads",
+ "square": False,
+ "tt_decimals": 1,
+ "legend": True,
+ "xcats_samples": False,
+ "ycats_samples": False,
+ "cluster_rows": False,
+ "cluster_cols": False,
+ "display_values": False,
+ "colstops": [[0, "#ffffff"], [0.5, "#4575b4"], [1, "#313695"]],
+ }
+ xcats = [f"{length}nt" for length in all_lengths]
+ ycats = samples
+ heatmap_plot = heatmap.plot(heatmap_data, xcats=xcats, ycats=ycats, pconfig=heatmap_pconfig)
+
+ # Add heatmap as a second section for alternative view
+ self.add_section(
+ name="Read Length Distribution (Heatmap)",
+ anchor="ribotish_read_length_heatmap_section",
+ description="Alternative heatmap view of read length distribution. "
+ "Useful for comparing many samples at once.",
+ helptext="""
+ This heatmap shows what percentage of total reads each read length represents for each sample.
+
+ * Rows are samples, columns are read lengths
+ * Darker blue indicates higher percentage of reads
+ * Lighter colors indicate fewer reads
+
+ This view is particularly useful when comparing many samples simultaneously.
+ """,
+ plot=heatmap_plot,
+ )
+
+ def add_general_stats(self):
+ """Add key metrics to the general statistics table."""
+ headers = {}
+
+ # Weighted average Frame 0 proportion (most important metric)
+ headers["weighted_f0_prop"] = ColumnDict(
+ {
+ "title": "Weighted F0 %",
+ "description": "Frame 0 proportion weighted by read counts across all lengths - primary quality metric",
+ "suffix": "%",
+ "scale": "RdYlGn",
+ "format": "{:,.1f}",
+ "min": 33,
+ "max": 100,
+ }
+ )
+
+ # Percentage of reads in optimal range
+ headers["optimal_range_pct"] = ColumnDict(
+ {
+ "title": "% in 28-30nt",
+ "description": "Percentage of reads in optimal ribosome footprint range (28-30nt)",
+ "suffix": "%",
+ "scale": "Blues",
+ "format": "{:,.1f}",
+ "max": 100,
+ }
+ )
+
+ # Best Frame 0 proportion and length
+ headers["best_f0_prop"] = ColumnDict(
+ {
+ "title": "Best F0 %",
+ "description": "Highest Frame 0 proportion achieved at any read length",
+ "suffix": "%",
+ "scale": "RdYlGn",
+ "format": "{:,.1f}",
+ "min": 33,
+ "max": 100,
+ }
+ )
+
+ headers["best_f0_length"] = ColumnDict(
+ {
+ "title": "Best F0 Length (nt)",
+ "description": "Read length with highest Frame 0 proportion",
+ "scale": "Greens",
+ "format": "{:,.0f}",
+ }
+ )
+
+ headers["peak_length"] = ColumnDict(
+ {
+ "title": "Peak Length (nt)",
+ "description": "Read length with highest total read count",
+ "scale": "Blues",
+ "format": "{:,.0f}",
+ }
+ )
+
+ headers["length_range"] = ColumnDict(
+ {
+ "title": "Length Range (nt)",
+ "description": "Range of read lengths detected (min-max)",
+ "scale": "Greys",
+ }
+ )
+
+ # Total reads (hidden by default)
+ headers["total_reads"] = ColumnDict(
+ {
+ "title": "Total Reads",
+ "description": f"Total number of reads across all lengths ({config.read_count_desc})",
+ "scale": "Purples",
+ "hidden": True,
+ "shared_key": "read_count",
+ }
+ )
+
+ # Calculate statistics for general stats
+ stats_data = {}
+ for sample_name, length_props in self.frame_proportions.items():
+ # Skip samples with no data
+ if not length_props:
+ continue
+
+ # Calculate total reads and weighted Frame 0
+ total_reads = 0
+ weighted_f0_sum = 0
+ for length, props in length_props.items():
+ count = props["total"]
+ total_reads += count
+ weighted_f0_sum += props["f0_prop"] * count
+
+ weighted_f0_prop = (weighted_f0_sum / total_reads * 100.0) if total_reads > 0 else 0
+
+ # Calculate percentage of reads in optimal range (28-30nt)
+ optimal_lengths = [28, 29, 30]
+ optimal_reads = 0
+ for length in optimal_lengths:
+ if length in length_props:
+ optimal_reads += length_props[length]["total"]
+
+ optimal_range_pct = (optimal_reads / total_reads * 100.0) if total_reads > 0 else 0
+
+ # Find length with best frame 0 proportion
+ best_f0_length = max(length_props.keys(), key=lambda k: length_props[k]["f0_prop"])
+ best_f0_prop = length_props[best_f0_length]["f0_prop"] * 100.0
+
+ # Find length with highest total count
+ peak_length = max(length_props.keys(), key=lambda k: length_props[k]["total"])
+
+ # Calculate read length range
+ min_length = min(length_props.keys())
+ max_length = max(length_props.keys())
+ length_range = f"{min_length}-{max_length}"
+
+ stats_data[sample_name] = {
+ "weighted_f0_prop": weighted_f0_prop,
+ "optimal_range_pct": optimal_range_pct,
+ "best_f0_prop": best_f0_prop,
+ "best_f0_length": best_f0_length,
+ "peak_length": peak_length,
+ "length_range": length_range,
+ "total_reads": total_reads,
+ }
+
+ self.general_stats_addcols(stats_data, headers)
diff --git a/multiqc/modules/ribotish/tests/__init__.py b/multiqc/modules/ribotish/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/multiqc/modules/ribotish/tests/test_ribotish.py b/multiqc/modules/ribotish/tests/test_ribotish.py
new file mode 100644
index 0000000000..f3c3391db8
--- /dev/null
+++ b/multiqc/modules/ribotish/tests/test_ribotish.py
@@ -0,0 +1,214 @@
+"""Tests for the Ribo-TISH module."""
+
+import pytest
+
+from multiqc import report
+from multiqc.base_module import ModuleNoSamplesFound
+from multiqc.modules.ribotish import MultiqcModule
+
+
+# Valid qual file content - line 4 has the frame counts dict
+VALID_QUAL_BASIC = """\
+# comment line 1
+# comment line 2
+# comment line 3
+{28: [100, 20, 30], 29: [150, 25, 25], 30: [80, 10, 10]}
+"""
+
+VALID_QUAL_WITH_DOLLAR = """\
+# comment line 1
+# comment line 2
+# comment line 3
+$ {28: [100, 20, 30], 29: [150, 25, 25]}
+"""
+
+VALID_QUAL_SINGLE_LENGTH = """\
+line1
+line2
+line3
+{28: [100, 20, 30]}
+"""
+
+VALID_QUAL_ALL_ZEROS = """\
+line1
+line2
+line3
+{25: [0, 0, 0], 26: [0, 0, 0], 27: [0, 0, 0]}
+"""
+
+# Invalid qual file contents
+INVALID_TOO_FEW_LINES = """line1
+line2
+line3"""
+
+INVALID_NOT_A_DICT = """\
+line1
+line2
+line3
+this is not a dict
+"""
+
+INVALID_SYNTAX_ERROR = """\
+line1
+line2
+line3
+{25: [1, 2, 3"""
+
+INVALID_LIST_NOT_DICT = """\
+line1
+line2
+line3
+[1, 2, 3]
+"""
+
+INVALID_VALUES_NOT_LISTS = """\
+line1
+line2
+line3
+{25: 'not a list', 26: 123}
+"""
+
+INVALID_WRONG_LIST_LENGTH = """\
+line1
+line2
+line3
+{25: [1, 2], 26: [1, 2, 3, 4]}
+"""
+
+INVALID_NON_NUMERIC = """\
+line1
+line2
+line3
+{25: ['a', 'b', 'c']}
+"""
+
+
+@pytest.fixture
+def run_ribotish_module(tmp_path):
+ """Factory to run ribotish module with custom file content."""
+
+ def _run_module(file_content: str, filename: str = "test_qual.txt"):
+ # Write file to temp path
+ test_file = tmp_path / filename
+ test_file.write_text(file_content)
+
+ # Set up report to search this file
+ report.reset()
+ report.analysis_files = [test_file]
+ report.search_files(["ribotish"])
+
+ # Run the module
+ return MultiqcModule()
+
+ return _run_module
+
+
+class TestRibotishParsing:
+ """Test parsing of qual files."""
+
+ def test_valid_basic_parsing(self, run_ribotish_module):
+ """Test parsing a valid qual file."""
+ module = run_ribotish_module(VALID_QUAL_BASIC)
+
+ assert "test" in module.ribotish_data
+ data = module.ribotish_data["test"]
+
+ assert 28 in data
+ assert 29 in data
+ assert 30 in data
+ assert data[28] == [100, 20, 30]
+ assert data[29] == [150, 25, 25]
+ assert data[30] == [80, 10, 10]
+
+ def test_dollar_prefix_stripped(self, run_ribotish_module):
+ """Test that $ prefix on line 4 is properly stripped."""
+ module = run_ribotish_module(VALID_QUAL_WITH_DOLLAR)
+
+ assert "test" in module.ribotish_data
+ assert 28 in module.ribotish_data["test"]
+ assert 29 in module.ribotish_data["test"]
+
+ def test_single_read_length(self, run_ribotish_module):
+ """Test handling of single read length."""
+ module = run_ribotish_module(VALID_QUAL_SINGLE_LENGTH)
+
+ assert "test" in module.ribotish_data
+ assert len(module.ribotish_data["test"]) == 1
+ assert 28 in module.ribotish_data["test"]
+
+
+class TestRibotishMalformedFiles:
+ """Test handling of malformed qual files."""
+
+ def test_file_with_fewer_than_4_lines(self, run_ribotish_module):
+ """Test file with <4 lines is skipped."""
+ with pytest.raises(ModuleNoSamplesFound):
+ run_ribotish_module(INVALID_TOO_FEW_LINES)
+
+ def test_file_with_invalid_dict_format(self, run_ribotish_module):
+ """Test file with invalid dict format on line 4."""
+ with pytest.raises(ModuleNoSamplesFound):
+ run_ribotish_module(INVALID_NOT_A_DICT)
+
+ def test_file_with_syntax_error(self, run_ribotish_module):
+ """Test file with Python syntax error on line 4."""
+ with pytest.raises(ModuleNoSamplesFound):
+ run_ribotish_module(INVALID_SYNTAX_ERROR)
+
+ def test_file_with_list_instead_of_dict(self, run_ribotish_module):
+ """Test file where line 4 is a list instead of dict."""
+ with pytest.raises(ModuleNoSamplesFound):
+ run_ribotish_module(INVALID_LIST_NOT_DICT)
+
+ def test_file_with_wrong_value_type(self, run_ribotish_module):
+ """Test file where dict values are not lists."""
+ with pytest.raises(ModuleNoSamplesFound):
+ run_ribotish_module(INVALID_VALUES_NOT_LISTS)
+
+ def test_file_with_wrong_list_length(self, run_ribotish_module):
+ """Test file where frame count lists don't have exactly 3 elements."""
+ with pytest.raises(ModuleNoSamplesFound):
+ run_ribotish_module(INVALID_WRONG_LIST_LENGTH)
+
+ def test_file_with_non_numeric_counts(self, run_ribotish_module):
+ """Test file where frame counts contain non-numeric values."""
+ with pytest.raises(ModuleNoSamplesFound):
+ run_ribotish_module(INVALID_NON_NUMERIC)
+
+
+class TestRibotishFrameCalculations:
+ """Test frame proportion calculations."""
+
+ def test_frame_proportions_sum_to_one(self, run_ribotish_module):
+ """Test that frame proportions sum to 1.0 for each length."""
+ module = run_ribotish_module(VALID_QUAL_BASIC)
+
+ sample_name = "test"
+ for length, props in module.frame_proportions[sample_name].items():
+ total_prop = props["f0_prop"] + props["f1_prop"] + props["f2_prop"]
+ assert abs(total_prop - 1.0) < 0.001, f"Proportions don't sum to 1.0 for length {length}"
+
+ def test_frame_proportions_correct_values(self, run_ribotish_module):
+ """Test that frame proportions are calculated correctly."""
+ module = run_ribotish_module(VALID_QUAL_SINGLE_LENGTH)
+
+ sample_name = "test"
+ props = module.frame_proportions[sample_name][28]
+
+ # counts are [100, 20, 30], total = 150
+ assert abs(props["f0_prop"] - 100 / 150) < 0.001
+ assert abs(props["f1_prop"] - 20 / 150) < 0.001
+ assert abs(props["f2_prop"] - 30 / 150) < 0.001
+ assert props["total"] == 150
+
+ def test_all_zeros_handled(self, run_ribotish_module):
+ """Test handling of all zeros in counts."""
+ module = run_ribotish_module(VALID_QUAL_ALL_ZEROS)
+
+ sample_name = "test"
+ for length in [25, 26, 27]:
+ props = module.frame_proportions[sample_name][length]
+ assert props["f0_prop"] == 0
+ assert props["f1_prop"] == 0
+ assert props["f2_prop"] == 0
+ assert props["total"] == 0
diff --git a/multiqc/modules/rna_seqc/rna_seqc.py b/multiqc/modules/rna_seqc/rna_seqc.py
index c88186403d..9be7d291b9 100644
--- a/multiqc/modules/rna_seqc/rna_seqc.py
+++ b/multiqc/modules/rna_seqc/rna_seqc.py
@@ -1,4 +1,5 @@
import logging
+import re
from multiqc import config
from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound
@@ -77,9 +78,9 @@ def __init__(self):
log.info(f"Found {num_found} samples")
- # Superfluous function call to confirm that it is used in this module
- # Replace None with actual version if it is available
- self.add_software_version(None)
+ # Parse version from HTML reports
+ for f in self.find_log_files("rna_seqc/html"):
+ self.parse_rnaseqc_html(f)
# Write metrics to a file
self.write_data_file(self.rna_seqc_metrics, "multiqc_rna_seqc")
@@ -420,3 +421,9 @@ def bam_statplot(self):
helptext="Note that many of these statistics are only available from RNA-SeQC v2.x",
plot=violin.plot(self.rna_seqc_metrics, keys, pconfig),
)
+
+ def parse_rnaseqc_html(self, f):
+ """Parse RNA-SeQC HTML reports to extract version information."""
+ match = re.search(r"RNA-SeQC\s*v(\d+\.\d+[\.\d]*)", f["f"])
+ if match:
+ self.add_software_version(match.group(1))
diff --git a/multiqc/modules/samtools/coverage.py b/multiqc/modules/samtools/coverage.py
index f37d6ae720..f61a920336 100644
--- a/multiqc/modules/samtools/coverage.py
+++ b/multiqc/modules/samtools/coverage.py
@@ -235,7 +235,6 @@ def lineplot_per_region(module, data_by_sample: Dict):
"categories": True,
"smooth_points": 500,
"logswitch": True,
- "hide_zero_cats": False,
"data_labels": data_labels,
},
),
@@ -276,6 +275,7 @@ def parse_single_report(f) -> Dict[str, Dict[str, Union[int, float]]]:
fields = line.strip().split("\t")
if len(fields) != len(EXPECTED_COLUMNS):
logging.warning(f"Skipping line with {len(fields)} fields, expected {len(EXPECTED_COLUMNS)}: {line}")
+ continue
rname, startpos, endpos, numreads, covbases, coverage, meandepth, meanbaseq, meanmapq = fields
if rname in parsed_data:
logging.warning(f"Duplicate region found in '{f['s_name']}': {rname}")
diff --git a/multiqc/modules/samtools/tests/test_coverage.py b/multiqc/modules/samtools/tests/test_coverage.py
new file mode 100644
index 0000000000..aab0a185be
--- /dev/null
+++ b/multiqc/modules/samtools/tests/test_coverage.py
@@ -0,0 +1,82 @@
+import pytest
+
+from multiqc.modules.samtools.coverage import parse_single_report
+
+
+@pytest.fixture
+def valid_coverage_report():
+ """A valid samtools coverage report."""
+ return {
+ "f": (
+ "#rname\tstartpos\tendpos\tnumreads\tcovbases\tcoverage\tmeandepth\tmeanbaseq\tmeanmapq\n"
+ "chr1\t1\t100\t50\t80\t80.0\t5.5\t30.0\t40.0\n"
+ "chr2\t1\t200\t100\t150\t75.0\t4.2\t28.0\t35.0\n"
+ ),
+ "s_name": "test_sample",
+ }
+
+
+@pytest.fixture
+def report_with_extra_fields():
+ """A samtools coverage report with extra fields (e.g., user-added 'all' line)."""
+ return {
+ "f": (
+ "#rname\tstartpos\tendpos\tnumreads\tcovbases\tcoverage\tmeandepth\tmeanbaseq\tmeanmapq\n"
+ "chr1\t1\t100\t50\t80\t80.0\t5.5\t30.0\t40.0\n"
+ "all\t1\t300\t150\t230\t76.67\t4.85\t29.0\t37.5\textra_field\n"
+ ),
+ "s_name": "test_sample",
+ }
+
+
+@pytest.fixture
+def report_with_fewer_fields():
+ """A samtools coverage report with missing fields."""
+ return {
+ "f": (
+ "#rname\tstartpos\tendpos\tnumreads\tcovbases\tcoverage\tmeandepth\tmeanbaseq\tmeanmapq\n"
+ "chr1\t1\t100\t50\t80\t80.0\t5.5\t30.0\t40.0\n"
+ "chr2\t1\t200\t100\t150\t75.0\t4.2\n"
+ ),
+ "s_name": "test_sample",
+ }
+
+
+def test_valid_coverage_report(valid_coverage_report):
+ """Test that valid coverage reports are parsed correctly."""
+ result = parse_single_report(valid_coverage_report)
+
+ assert len(result) == 2
+ assert "chr1" in result
+ assert "chr2" in result
+ assert result["chr1"]["startpos"] == 1
+ assert result["chr1"]["endpos"] == 100
+ assert result["chr1"]["numreads"] == 50
+ assert result["chr1"]["coverage"] == 80.0
+ assert result["chr2"]["numreads"] == 100
+
+
+def test_report_with_extra_fields(report_with_extra_fields):
+ """Test that lines with extra fields are skipped without crashing.
+
+ This is a regression test for issue #3343 where extra fields would cause
+ ValueError: too many values to unpack.
+ """
+ result = parse_single_report(report_with_extra_fields)
+
+ # Only the valid line should be parsed
+ assert len(result) == 1
+ assert "chr1" in result
+ # The 'all' line with extra fields should have been skipped
+ assert "all" not in result
+
+
+def test_report_with_fewer_fields(report_with_fewer_fields):
+ """Test that lines with fewer fields are skipped without crashing."""
+ result = parse_single_report(report_with_fewer_fields)
+
+ # Only the valid line should be parsed
+ assert len(result) == 1
+ assert "chr1" in result
+ # The chr2 line with fewer fields should have been skipped
+ assert "chr2" not in result
diff --git a/multiqc/modules/seqkit/__init__.py b/multiqc/modules/seqkit/__init__.py
new file mode 100644
index 0000000000..72a50173c9
--- /dev/null
+++ b/multiqc/modules/seqkit/__init__.py
@@ -0,0 +1,3 @@
+from .seqkit import MultiqcModule
+
+__all__ = ["MultiqcModule"]
diff --git a/multiqc/modules/seqkit/seqkit.py b/multiqc/modules/seqkit/seqkit.py
new file mode 100644
index 0000000000..4be662875b
--- /dev/null
+++ b/multiqc/modules/seqkit/seqkit.py
@@ -0,0 +1,55 @@
+"""MultiQC module to parse output from SeqKit"""
+
+import logging
+
+from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound
+
+from .stats import parse_seqkit_stats
+
+log = logging.getLogger(__name__)
+
+
+class MultiqcModule(BaseMultiqcModule):
+ """
+ SeqKit is a cross-platform and ultrafast toolkit for FASTA/Q file manipulation.
+
+ Supported commands:
+
+ - `stats`
+
+ The module parses output from `seqkit stats` which provides simple statistics of
+ FASTA/Q files including sequence counts, total length, N50, GC content, and quality
+ metrics for FASTQ files.
+
+ #### stats
+
+ The `seqkit stats` command produces tabular output with columns for file, format,
+ type, num_seqs, sum_len, min_len, avg_len, max_len, and optionally Q1, Q2, Q3,
+ sum_gap, N50, Q20(%), Q30(%), AvgQual, and GC(%) when run with the `--all` flag.
+
+ To generate output suitable for MultiQC, run seqkit stats with the `--tabular` flag:
+
+ ```bash
+ seqkit stats --all --tabular *.fastq.gz > seqkit_stats.tsv
+ ```
+ """
+
+ def __init__(self):
+ super(MultiqcModule, self).__init__(
+ name="SeqKit",
+ anchor="seqkit",
+ href="https://bioinf.shenwei.me/seqkit/",
+ info="Cross-platform and ultrafast toolkit for FASTA/Q file manipulation.",
+ doi="10.1371/journal.pone.0163962",
+ )
+
+ n = dict()
+
+ # Call submodule functions
+ n["stats"] = parse_seqkit_stats(self)
+ if n["stats"] > 0:
+ log.info(f"Found {n['stats']} stats reports")
+
+ # Exit if we didn't find anything
+ if sum(n.values()) == 0:
+ raise ModuleNoSamplesFound
diff --git a/multiqc/modules/seqkit/stats.py b/multiqc/modules/seqkit/stats.py
new file mode 100644
index 0000000000..c1545d1b39
--- /dev/null
+++ b/multiqc/modules/seqkit/stats.py
@@ -0,0 +1,316 @@
+"""MultiQC submodule to parse output from seqkit stats"""
+
+import logging
+from typing import Dict, Optional
+
+from multiqc import BaseMultiqcModule, config
+from multiqc.plots import bargraph, table
+
+log = logging.getLogger(__name__)
+
+
+def parse_seqkit_stats(module: BaseMultiqcModule) -> int:
+ """Find seqkit stats logs and parse their data"""
+
+ seqkit_stats: Dict[str, Dict] = {}
+
+ for f in module.find_log_files("seqkit/stats"):
+ parsed_data = parse_stats_report(f["f"], f["s_name"])
+ for s_name, data in parsed_data.items():
+ s_name = module.clean_s_name(s_name, f)
+ if s_name in seqkit_stats:
+ log.debug(f"Duplicate sample name found! Overwriting: {s_name}")
+ module.add_data_source(f, s_name=s_name, section="stats")
+ seqkit_stats[s_name] = data
+
+ # Superfluous function call to confirm that it is used in this module
+ # Replace None with actual version if it is available
+ module.add_software_version(None, s_name)
+
+ # Filter to strip out ignored sample names
+ seqkit_stats = module.ignore_samples(seqkit_stats)
+
+ if len(seqkit_stats) == 0:
+ return 0
+
+ # General Stats Table - MultiQC automatically filters missing columns
+ general_stats_headers: Dict = {
+ "num_seqs": {
+ "title": "# Sequences",
+ "description": f"Number of sequences ({config.read_count_desc})",
+ "scale": "Blues",
+ "shared_key": "read_count",
+ },
+ "sum_len": {
+ "title": "Total bp",
+ "description": f"Total number of bases ({config.base_count_desc})",
+ "scale": "Greens",
+ "shared_key": "base_count",
+ "hidden": True,
+ },
+ "avg_len": {
+ "title": "Avg Length",
+ "description": "Average sequence length",
+ "scale": "Purples",
+ "suffix": " bp",
+ },
+ "GC_pct": {
+ "title": "GC%",
+ "description": "GC content percentage",
+ "min": 0,
+ "max": 100,
+ "scale": "RdYlBu",
+ "suffix": "%",
+ },
+ "Q30_pct": {
+ "title": "Q30%",
+ "description": "Percentage of bases with quality score >= 30",
+ "min": 0,
+ "max": 100,
+ "scale": "RdYlGn",
+ "suffix": "%",
+ },
+ "AvgQual": {
+ "title": "Avg Qual",
+ "description": "Average quality score",
+ "scale": "RdYlGn",
+ "hidden": True,
+ },
+ "N50": {
+ "title": "N50",
+ "description": "N50 sequence length",
+ "scale": "Oranges",
+ "format": "{:,.0f}",
+ "suffix": " bp",
+ "hidden": True,
+ },
+ }
+
+ # Get general stats headers using the utility function
+ stats_headers = module.get_general_stats_headers(all_headers=general_stats_headers)
+
+ # Add headers to general stats table
+ if stats_headers:
+ module.general_stats_addcols(seqkit_stats, stats_headers, namespace="seqkit")
+
+ # Create detailed table with all columns
+ table_headers: Dict = {
+ "format": {
+ "title": "Format",
+ "description": "File format (FASTA or FASTQ)",
+ },
+ "type": {
+ "title": "Type",
+ "description": "Sequence type (DNA, RNA, Protein)",
+ },
+ "num_seqs": {
+ "title": "# Seqs",
+ "description": f"Number of sequences ({config.read_count_desc})",
+ "shared_key": "read_count",
+ "scale": "Blues",
+ },
+ "sum_len": {
+ "title": "Total Length",
+ "description": f"Total number of bases/residues ({config.base_count_desc})",
+ "shared_key": "base_count",
+ "scale": "Greens",
+ },
+ "min_len": {
+ "title": "Min Length",
+ "description": "Minimum sequence length",
+ "format": "{:,.0f}",
+ "scale": "Purples",
+ "hidden": True,
+ },
+ "avg_len": {
+ "title": "Avg Length",
+ "description": "Average sequence length",
+ "scale": "Purples",
+ },
+ "Q1": {
+ "title": "Q1 Length",
+ "description": "First quartile of sequence length",
+ "format": "{:,.0f}",
+ "scale": "Purples",
+ "hidden": True,
+ },
+ "Q2": {
+ "title": "Median Length",
+ "description": "Second quartile (Median) sequence length",
+ "format": "{:,.0f}",
+ "scale": "Purples",
+ },
+ "Q3": {
+ "title": "Q3 Length",
+ "description": "Third quartile of sequence length",
+ "format": "{:,.0f}",
+ "scale": "Purples",
+ "hidden": True,
+ },
+ "max_len": {
+ "title": "Max Length",
+ "description": "Maximum sequence length",
+ "format": "{:,.0f}",
+ "scale": "Purples",
+ "hidden": True,
+ },
+ "sum_gap": {
+ "title": "Gaps",
+ "description": "Total number of gaps",
+ "format": "{:,.0f}",
+ "scale": "OrRd",
+ },
+ "N50": {
+ "title": "N50",
+ "description": "N50 sequence length",
+ "format": "{:,.0f}",
+ "scale": "Oranges",
+ },
+ "N50_num": {
+ "title": "N50 Count",
+ "description": "Number of sequences >= N50",
+ "format": "{:,.0f}",
+ "scale": "PuBuGn",
+ },
+ "Q20_pct": {
+ "title": "Q20%",
+ "description": "Percentage of bases with quality score >= 20",
+ "min": 0,
+ "max": 100,
+ "suffix": "%",
+ "scale": "RdYlGn",
+ },
+ "Q30_pct": {
+ "title": "Q30%",
+ "description": "Percentage of bases with quality score >= 30",
+ "min": 0,
+ "max": 100,
+ "suffix": "%",
+ "scale": "RdYlGn",
+ },
+ "AvgQual": {
+ "title": "Avg Quality",
+ "description": "Average quality score",
+ "scale": "RdYlGn",
+ },
+ "GC_pct": {
+ "title": "GC%",
+ "description": "GC content percentage",
+ "min": 0,
+ "max": 100,
+ "suffix": "%",
+ "scale": "RdYlBu",
+ },
+ "sum_n": {
+ "title": "# N Bases",
+ "description": f"Total number of N bases ({config.base_count_desc})",
+ "modify": lambda x: x * config.base_count_multiplier,
+ "format": "{:,.2f} " + config.base_count_prefix,
+ "scale": "Reds",
+ },
+ }
+
+ # Add table section
+ module.add_section(
+ name="Stats",
+ anchor="seqkit-stats",
+ description="Statistics from
seqkit stats showing sequence file metrics.",
+ plot=table.plot(
+ seqkit_stats,
+ table_headers,
+ pconfig={
+ "id": "seqkit-stats-table",
+ "title": "SeqKit: Stats",
+ "namespace": "seqkit",
+ },
+ ),
+ )
+
+ # Create bar plot for sequence counts and lengths
+ bargraph_data_seqs = {s_name: {"Sequences": d.get("num_seqs", 0)} for s_name, d in seqkit_stats.items()}
+
+ module.add_section(
+ name="Sequence Counts",
+ anchor="seqkit-stats-seqcounts",
+ description="Number of sequences per sample.",
+ plot=bargraph.plot(
+ bargraph_data_seqs,
+ pconfig={
+ "id": "seqkit-stats-seqcounts-plot",
+ "title": "SeqKit: Sequence Counts",
+ "ylab": "Number of Sequences",
+ "cpswitch": False,
+ },
+ ),
+ )
+
+ # Write parsed report data to a file
+ module.write_data_file(seqkit_stats, "multiqc_seqkit_stats")
+
+ return len(seqkit_stats)
+
+
+def parse_stats_report(file_content: str, fallback_sample_name: Optional[str] = None) -> Dict[str, Dict]:
+ """
+ Parse seqkit stats output file.
+
+ Returns a dictionary with sample names as keys and parsed data as values.
+ Handles both tab-separated (--tabular flag) and space-separated (default) output.
+ """
+ parsed_data: Dict[str, Dict] = {}
+ lines = file_content.strip().split("\n")
+
+ if len(lines) < 2:
+ return {}
+
+ # Determine delimiter: tab-separated or space-separated
+ header_line = lines[0]
+ delimiter = "\t" if "\t" in header_line else None
+ headers = header_line.split(delimiter)
+ headers = [h.strip() for h in headers]
+
+ # Check if this looks like seqkit stats output
+ expected_headers = ["file", "format", "type", "num_seqs", "sum_len", "min_len", "avg_len", "max_len"]
+ if not all(h in headers for h in expected_headers):
+ log.debug(f"Skipping '{fallback_sample_name}' as didn't have expected headers")
+ return {}
+
+ # Mapping for columns with special characters in names
+ column_renames = {
+ "Q20(%)": "Q20_pct",
+ "Q30(%)": "Q30_pct",
+ "GC(%)": "GC_pct",
+ }
+
+ # Parse data lines
+ for line in lines[1:]:
+ if not line.strip():
+ continue
+
+ values = line.split(delimiter)
+ if len(values) < len(headers):
+ continue
+
+ row = dict(zip(headers, [v.strip() for v in values]))
+ data: Dict = {}
+
+ # Get sample name from file column
+ file_value = row.get("file")
+ if file_value is None or file_value == "-":
+ # Use sample name from filename for stdin input
+ sample_name = str(fallback_sample_name)
+ else:
+ # Will clean sample name in main function
+ sample_name = str(file_value)
+
+ # Parse remaining columns
+ for header, value in row.items():
+ key = column_renames.get(header, header)
+ try:
+ data[key] = float(value.replace(",", ""))
+ except (ValueError, AttributeError):
+ data[header] = value
+
+ parsed_data[sample_name] = data
+
+ return parsed_data
diff --git a/multiqc/modules/seqkit/tests/__init__.py b/multiqc/modules/seqkit/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/multiqc/modules/seqkit/tests/test_stats.py b/multiqc/modules/seqkit/tests/test_stats.py
new file mode 100644
index 0000000000..4d4c89f815
--- /dev/null
+++ b/multiqc/modules/seqkit/tests/test_stats.py
@@ -0,0 +1,148 @@
+"""Tests for the seqkit stats module"""
+
+import pytest
+
+from multiqc.modules.seqkit.stats import parse_stats_report
+
+
+# Sample seqkit stats output with all columns (--all --tabular)
+SAMPLE_STATS_ALL = """file format type num_seqs sum_len min_len avg_len max_len Q1 Q2 Q3 sum_gap N50 N50_num Q20(%) Q30(%) AvgQual GC(%) sum_n
+sample1.fq.gz FASTQ DNA 1000000 100000000 100 100.0 100 100 100 100 0 100 1 98.5 86.2 25.64 31.85 1000
+sample2.fq.gz FASTQ DNA 2000000 200000000 100 100.0 100 100 100 100 0 100 1 99.1 97.3 29.80 34.54 500
+"""
+
+# Sample seqkit stats output with basic columns only
+SAMPLE_STATS_BASIC = """file format type num_seqs sum_len min_len avg_len max_len
+reads.fastq FASTQ DNA 50000 5000000 80 100.0 150
+assembly.fasta FASTA DNA 1000 10000000 500 10000.0 50000
+"""
+
+# Sample with Windows-style paths
+SAMPLE_STATS_WINDOWS_PATH = """file format type num_seqs sum_len min_len avg_len max_len
+C:\\data\\reads.fq.gz FASTQ DNA 1000 100000 100 100.0 100
+"""
+
+# Sample with stdin input
+SAMPLE_STATS_STDIN = """file format type num_seqs sum_len min_len avg_len max_len
+- FASTQ DNA 5000 500000 100 100.0 100
+"""
+
+# Empty file (just header)
+SAMPLE_STATS_EMPTY = """file format type num_seqs sum_len min_len avg_len max_len
+"""
+
+# Invalid file (missing required columns)
+SAMPLE_STATS_INVALID = """name count length
+sample1 1000 100000
+"""
+
+# Space-separated format (default seqkit stats output without --tabular)
+SAMPLE_STATS_SPACE_SEPARATED = """file format type num_seqs sum_len min_len avg_len max_len
+sample1.fq.gz FASTQ DNA 1000000 100000000 100 100.0 100
+sample2.fq.gz FASTQ DNA 2000000 200000000 100 100.0 100
+"""
+
+
+class TestParseStatsReport:
+ """Tests for parse_stats_report function"""
+
+ def test_parse_all_columns(self):
+ """Test parsing output with all columns from --all flag"""
+ result = parse_stats_report(SAMPLE_STATS_ALL)
+
+ assert len(result) == 2
+ assert "sample1.fq.gz" in result
+ assert "sample2.fq.gz" in result
+
+ # Check sample1 data
+ s1 = result["sample1.fq.gz"]
+ assert s1["file"] == "sample1.fq.gz"
+ assert s1["format"] == "FASTQ"
+ assert s1["type"] == "DNA"
+ assert s1["num_seqs"] == 1000000
+ assert s1["sum_len"] == 100000000
+ assert s1["min_len"] == 100
+ assert s1["avg_len"] == 100.0
+ assert s1["max_len"] == 100
+ assert s1["Q1"] == 100
+ assert s1["Q2"] == 100
+ assert s1["Q3"] == 100
+ assert s1["sum_gap"] == 0
+ assert s1["N50"] == 100
+ assert s1["N50_num"] == 1
+ assert s1["Q20_pct"] == 98.5
+ assert s1["Q30_pct"] == 86.2
+ assert s1["AvgQual"] == 25.64
+ assert s1["GC_pct"] == 31.85
+ assert s1["sum_n"] == 1000
+
+ def test_parse_basic_columns(self):
+ """Test parsing output with basic columns only"""
+ result = parse_stats_report(SAMPLE_STATS_BASIC)
+
+ assert len(result) == 2
+ assert "reads.fastq" in result
+ assert "assembly.fasta" in result
+
+ # Check reads data
+ reads = result["reads.fastq"]
+ assert reads["format"] == "FASTQ"
+ assert reads["num_seqs"] == 50000
+ assert reads["avg_len"] == 100.0
+
+ # Check assembly data
+ assembly = result["assembly.fasta"]
+ assert assembly["format"] == "FASTA"
+ assert assembly["num_seqs"] == 1000
+ assert assembly["avg_len"] == 10000.0
+
+ # These columns should not be present in basic output
+ assert "Q20_pct" not in reads
+ assert "N50" not in reads
+
+ def test_parse_windows_path(self):
+ """Test parsing handles Windows-style paths correctly"""
+ result = parse_stats_report(SAMPLE_STATS_WINDOWS_PATH)
+
+ assert len(result) == 1
+ # parse_stats_report returns raw file value; path cleanup is done by clean_s_name later
+ assert "C:\\data\\reads.fq.gz" in result
+
+ def test_parse_stdin_with_fallback_name(self):
+ """Test parsing uses fallback sample name for stdin input"""
+ result = parse_stats_report(SAMPLE_STATS_STDIN, fallback_sample_name="my_sample")
+
+ assert len(result) == 1
+ assert "my_sample" in result
+ assert result["my_sample"]["num_seqs"] == 5000
+
+ def test_parse_empty_file(self):
+ """Test parsing empty file returns empty dict"""
+ result = parse_stats_report(SAMPLE_STATS_EMPTY)
+ assert result == {}
+
+ def test_parse_invalid_format(self):
+ """Test parsing invalid format returns empty dict"""
+ result = parse_stats_report(SAMPLE_STATS_INVALID)
+ assert result == {}
+
+ def test_parse_single_line(self):
+ """Test parsing file with only header line"""
+ result = parse_stats_report("file\tformat\ttype\tnum_seqs\tsum_len\tmin_len\tavg_len\tmax_len")
+ assert result == {}
+
+ def test_parse_space_separated(self):
+ """Test parsing space-separated output (default seqkit stats format)"""
+ result = parse_stats_report(SAMPLE_STATS_SPACE_SEPARATED)
+
+ assert len(result) == 2
+ assert "sample1.fq.gz" in result
+ assert "sample2.fq.gz" in result
+
+ # Check sample1 data
+ s1 = result["sample1.fq.gz"]
+ assert s1["format"] == "FASTQ"
+ assert s1["type"] == "DNA"
+ assert s1["num_seqs"] == 1000000
+ assert s1["sum_len"] == 100000000
+ assert s1["avg_len"] == 100.0
diff --git a/multiqc/modules/snpsplit/snpsplit.py b/multiqc/modules/snpsplit/snpsplit.py
index 300dd2fde8..d2e33e75e6 100644
--- a/multiqc/modules/snpsplit/snpsplit.py
+++ b/multiqc/modules/snpsplit/snpsplit.py
@@ -1,5 +1,6 @@
import logging
import re
+from datetime import date, datetime
import yaml
@@ -74,7 +75,11 @@ def parse_new_snpsplit_log(f):
if sk.startswith(prefix):
key = sk[len(prefix) :]
flat_key = f"{k.lower()}_{key}"
- flat_data[flat_key] = data[k][sk]
+ value = data[k][sk]
+ # Convert datetime objects to ISO format strings for JSON serialization
+ if isinstance(value, (datetime, date)):
+ value = value.isoformat()
+ flat_data[flat_key] = value
input_fn = data["Meta"]["infile"]
flat_data["version"] = data["Meta"]["version"]
return [input_fn, flat_data]
diff --git a/multiqc/modules/spaceranger/count.py b/multiqc/modules/spaceranger/count.py
index 75520b43bb..8b2169e804 100644
--- a/multiqc/modules/spaceranger/count.py
+++ b/multiqc/modules/spaceranger/count.py
@@ -174,7 +174,7 @@ def parse_count_html(module: BaseMultiqcModule):
warnings_headers[alarm["id"]] = {
"title": alarm["id"].replace("_", " ").title(),
"description": alarm["title"],
- "bgcols": {"FAIL": "#f7dddc"},
+ "bgcols": {"FAIL": "#e5001336"},
}
# Extract data for plots
diff --git a/multiqc/modules/sylphtax/__init__.py b/multiqc/modules/sylphtax/__init__.py
new file mode 100644
index 0000000000..ef5e9cf55d
--- /dev/null
+++ b/multiqc/modules/sylphtax/__init__.py
@@ -0,0 +1,3 @@
+from .sylphtax import MultiqcModule
+
+__all__ = ["MultiqcModule"]
diff --git a/multiqc/modules/sylphtax/sylphtax.py b/multiqc/modules/sylphtax/sylphtax.py
new file mode 100644
index 0000000000..ed0b5a06ad
--- /dev/null
+++ b/multiqc/modules/sylphtax/sylphtax.py
@@ -0,0 +1,358 @@
+import logging
+import re
+
+from multiqc import config
+from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound
+from multiqc.plots import bargraph
+
+log = logging.getLogger(__name__)
+
+
+class MultiqcModule(BaseMultiqcModule):
+ """
+ The module supports outputs from sylphtax, that look like the following:
+
+ ```tsv
+ clade_name relative_abundance sequence_abundance ANI (if strain-level)
+ d__Bacteria 100.00010000000002 99.99999999999999 NA
+ d__Bacteria|p__Bacillota 24.640800000000002 18.712699999999998 NA
+ d__Bacteria|p__Bacillota_A 47.333499999999994 52.5969 NA
+ ```
+
+ A bar graph is generated that shows the relative abundance for each sample that
+ fall into the top-10 categories for each taxa rank. The top categories are calculated
+ by summing the relative abundances across all samples.
+
+ The number of top categories to plot can be customized in the config file:
+
+ ```yaml
+ sylphtax:
+ top_n: 10
+ ```
+ """
+
+ def __init__(
+ self,
+ name="Sylph-tax",
+ anchor="sylphtax",
+ href=["https://sylph-docs.github.io/", "https://sylph-docs.github.io/sylph-tax/"],
+ info="Taxonomic profiling of metagenomic reads.",
+ doi="10.1038/s41587-024-02412-y",
+ ):
+ super(MultiqcModule, self).__init__(
+ name=name,
+ anchor=anchor,
+ href=href,
+ info=info,
+ doi=doi,
+ )
+ # Taxonomic ranks: include Sylph’s domain/realm/strain if desired
+ self.t_ranks = {
+ "t": "Strain",
+ "s": "Species",
+ "g": "Genus",
+ "f": "Family",
+ "o": "Order",
+ "c": "Class",
+ "p": "Phylum",
+ "k": "Kingdom",
+ "d": "Domain",
+ "r": "Realm",
+ "u": "No Taxonomy",
+ }
+
+ self.top_n = getattr(config, "sylphtax", {}).get("top_n", 10)
+
+ self.sylph_raw_data = dict()
+ for f in self.find_log_files("sylphtax", filehandles=True):
+ self.parse_logs(f)
+ self.add_data_source(f)
+
+ self.sylph_raw_data = self.ignore_samples(self.sylph_raw_data)
+
+ if len(self.sylph_raw_data) == 0:
+ raise ModuleNoSamplesFound
+
+ log.info(f"Found {len(self.sylph_raw_data)} reports")
+
+ self.add_software_version(None)
+
+ # Sum percentages across all samples, so that we can pick top species
+ self.sylph_total_pct = dict()
+ self.sum_sample_abundances()
+ self.general_stats_cols()
+ self.top_taxa_barplot()
+ self.write_data_file(self.sylph_raw_data, f"multiqc_{self.anchor}")
+
+ def parse_logs(self, f):
+ """
+ Parses a sylphtax .sylphmpa file to extract:
+ 1. A rank code, indicating:
+ * (k)ingdom
+ * (p)hylum
+ * (c)lass
+ * (o)rder
+ * (f)amily
+ * (g)enus
+ * (s)pecies
+ * s(t)rain
+ 2. The last clade name (taxonomy)
+ 3. Relative abundance of this clade (percentage)
+ """
+ regex_clade_rel = re.compile(
+ r"^(?P
[^\t#]+)\t(?P[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[Ee][+-]?\d+)?)"
+ )
+ regex_last_tax_rank = re.compile(r"(?:^|[|;])([drkpcofgst])__([^|;]+)$")
+ regex_comments = re.compile(r"^\s*#")
+ regex_header = re.compile(r"^clade_name\t", re.IGNORECASE)
+
+ data = []
+ for line in f["f"]:
+ if regex_comments.search(line) or regex_header.search(line):
+ continue
+
+ m = regex_clade_rel.match(line)
+ if not m:
+ log.debug(f"{f['s_name']}: Could not parse line: {line.strip()}")
+ continue
+
+ clade = m.group("clade")
+ rel = float(m.group("rel_abundance"))
+
+ # Handle bare NO_TAXONOMY rows (no rank code present)
+ if clade == "NO_TAXONOMY":
+ for rank in list("kcpofgsd"):
+ data.append(
+ {
+ "tax_rank": rank,
+ "taxonomy": "No Taxonomy (see strain)",
+ "rel_abundance": 100,
+ }
+ )
+ continue
+
+ # Extract last rank and taxonomy from clade path
+ match_last_rank = regex_last_tax_rank.search(clade)
+ if match_last_rank:
+ row = {
+ "tax_rank": match_last_rank.group(1),
+ "taxonomy": match_last_rank.group(2),
+ "rel_abundance": rel,
+ }
+ data.append(row)
+ continue
+
+ # Fallback: single-token clade_name like "d__Bacteria"
+ m_single = re.match(r"^([drkpcofgst])__([^\t|;]+)$", clade)
+ if m_single:
+ row = {
+ "tax_rank": m_single.group(1),
+ "taxonomy": m_single.group(2),
+ "rel_abundance": rel,
+ }
+ data.append(row)
+ else:
+ log.debug(f"{f['s_name']}: Could not parse clade_name: {clade}")
+
+ self.sylph_raw_data[f["s_name"]] = data
+
+ def sum_sample_abundances(self):
+ """Sum relative abundance across all samples for sylph data"""
+
+ # Sum the percentages for each taxa across all samples
+ # Allows us to pick the top taxa for each rank
+ for s_name, data in self.sylph_raw_data.items():
+ for row in data:
+ tax_rank = row["tax_rank"]
+ taxonomy = row["taxonomy"]
+
+ if tax_rank not in self.sylph_total_pct:
+ self.sylph_total_pct[tax_rank] = dict()
+
+ if taxonomy not in self.sylph_total_pct[tax_rank]:
+ self.sylph_total_pct[tax_rank][taxonomy] = 0
+ self.sylph_total_pct[tax_rank][taxonomy] += row["rel_abundance"]
+
+ def general_stats_cols(self):
+ # Find top taxa in the most specific non-strain rank available
+ top_taxa = []
+ top_rank_code = None
+ top_rank_name = None
+
+ for rank_code, rank_name in self.t_ranks.items():
+ if rank_code == "t" or rank_code == "u":
+ continue
+ try:
+ sorted_pct = sorted(
+ self.sylph_total_pct[rank_code].items(),
+ key=lambda x: x[1],
+ reverse=True,
+ )
+ if sorted_pct:
+ top_taxa = [taxonomy for taxonomy, _ in sorted_pct[: self.top_n]]
+ top_rank_code = rank_code
+ top_rank_name = rank_name
+ break
+ except KeyError:
+ # Rank not present; try next
+ pass
+
+ # Fallbacks: strain first, then no taxonomy
+ if not top_taxa:
+ if "t" in self.sylph_total_pct:
+ sorted_pct = sorted(
+ self.sylph_total_pct["t"].items(),
+ key=lambda x: x[1],
+ reverse=True,
+ )
+ if sorted_pct:
+ top_taxa = [taxonomy for taxonomy, _ in sorted_pct[: self.top_n]]
+ top_rank_code = "t"
+ top_rank_name = self.t_ranks["t"]
+ elif "u" in self.sylph_total_pct:
+ sorted_pct = sorted(
+ self.sylph_total_pct["u"].items(),
+ key=lambda x: x[1],
+ reverse=True,
+ )
+ if sorted_pct:
+ top_taxa = [taxonomy for taxonomy, _ in sorted_pct[: self.top_n]]
+ top_rank_code = "u"
+ top_rank_name = self.t_ranks["u"]
+
+ # If still no taxa found, skip adding columns
+ if not top_taxa or top_rank_code is None:
+ log.warning("Sylph: No taxa found to populate General Stats")
+ return
+
+ # Column headers
+ headers = dict()
+ top_one_hkey = f"% {top_taxa[0]}"
+ headers[top_one_hkey] = {
+ "title": top_one_hkey,
+ "description": "Percentage of reads that were the top {} over all samples - {}".format(
+ top_rank_name, top_taxa[0]
+ ),
+ "suffix": "%",
+ "max": 100,
+ "scale": "PuBuGn",
+ }
+ headers["% Top"] = {
+ "title": f"% Top {self.top_n} {top_rank_name}",
+ "description": f"Percentage of reads that were classified by one of the top-{self.top_n} {top_rank_name} ({', '.join(top_taxa)})",
+ "suffix": "%",
+ "max": 100,
+ "scale": "PuBu",
+ }
+
+ # Populate table data
+ tdata = {}
+ for s_name, d in self.sylph_raw_data.items():
+ tdata[s_name] = {}
+ for row in d:
+ percent = row["rel_abundance"]
+ if row["tax_rank"] == top_rank_code and row["taxonomy"] in top_taxa:
+ tdata[s_name]["% Top"] = percent + tdata[s_name].get("% Top", 0)
+ if row["tax_rank"] == top_rank_code and row["taxonomy"] == top_taxa[0]:
+ tdata[s_name][top_one_hkey] = percent
+
+ # Ensure presence of the single-top key even if absent in sample
+ if top_one_hkey not in tdata[s_name]:
+ tdata[s_name][top_one_hkey] = 0
+
+ self.general_stats_addcols(tdata, headers)
+
+ def top_taxa_barplot(self):
+ """Add a bar plot showing the top-N from each taxa rank"""
+
+ pd = []
+ cats = list()
+ # Keeping track of encountered codes to display only tabs with available data
+ found_rank_codes = set()
+
+ for rank_code in self.t_ranks:
+ rank_cats = dict()
+ rank_data = dict()
+
+ # Loop through the summed tax percentages to get the top-N across all samples
+ try:
+ sorted_pct = sorted(
+ self.sylph_total_pct[rank_code].items(),
+ key=lambda x: x[1],
+ reverse=True,
+ )
+ except KeyError:
+ # Taxa rank not found in this sample
+ continue
+ i = 0
+ abundances_shown = {}
+ for taxonomy, pct_sum in sorted_pct:
+ # Add top n taxa for each rank
+ i += 1
+ if i > self.top_n:
+ # After top N, keep looping to sum up unclassified
+ continue
+ rank_cats[taxonomy] = {"name": taxonomy}
+ # Pull out abundances for this rank + classif from each sample
+ for s_name, d in self.sylph_raw_data.items():
+ if s_name not in rank_data:
+ rank_data[s_name] = dict()
+ if s_name not in abundances_shown:
+ abundances_shown[s_name] = 0
+
+ for row in d:
+ if row["tax_rank"] == rank_code:
+ found_rank_codes.add(rank_code)
+ if row["taxonomy"] == taxonomy:
+ if taxonomy not in rank_data[s_name]:
+ rank_data[s_name][taxonomy] = 0
+ rank_data[s_name][taxonomy] += row["rel_abundance"]
+ abundances_shown[s_name] += row["rel_abundance"]
+ rank_data[s_name]["other"] = 100 - abundances_shown[s_name]
+ # Add in other - we presume from other species etc.
+ for s_name, d in self.sylph_raw_data.items():
+ # In case none of the top_n were in some sample:
+ if s_name not in rank_data:
+ rank_data[s_name] = dict()
+ if s_name not in abundances_shown:
+ abundances_shown[s_name] = 0
+ rank_data[s_name]["other"] = 100 - abundances_shown[s_name]
+
+ # This should never happen... But it does in Metaphlan at least if the total abundance is a bit off
+ if rank_data[s_name]["other"] < 0:
+ log.debug(
+ "Found negative 'other' abundance for {} ({}): {}".format(
+ s_name, self.t_ranks[rank_code], rank_data[s_name]["other"]
+ )
+ )
+ rank_data[s_name]["other"] = 0
+ # Quick fix to ensure that the "other" category is on end of bar plot
+ rank_data[s_name]["zzz_other"] = rank_data[s_name].pop("other")
+
+ rank_cats["zzz_other"] = {"name": "Other", "color": "#cccccc"}
+
+ cats.append(rank_cats)
+ pd.append(rank_data)
+
+ pconfig = {
+ "id": f"{self.anchor}-top-n-plot",
+ "title": f"{self.name}: Top taxa",
+ "ylab": "Relative Abundance",
+ "data_labels": [v for k, v in self.t_ranks.items() if k in found_rank_codes],
+ "cpswitch": False,
+ }
+
+ self.add_section(
+ name="Top taxa",
+ anchor=f"{self.anchor}-top-n",
+ description=f"The relative abundance of reads falling into the top {self.top_n} taxa across different ranks.",
+ helptext=f"""
+ To make this plot, the percentage of each sample assigned to a given taxa is summed across all samples.
+ The relative abundance for these top {self.top_n} taxa are then plotted for each of the different taxa ranks.
+
+ The category _"Other"_ shows the difference between 100% and the sum of the percent
+ in the top {self.top_n} taxa shown. This should cover all taxa _not_ in the top {self.top_n}, +/- any rounding errors.
+ Note that Sylph does not estimate the percent of unclassified reads, see [here](https://github.com/bluenote-1577/sylph/issues/49).
+ """,
+ plot=bargraph.plot(pd, cats, pconfig),
+ )
diff --git a/multiqc/modules/umicollapse/umicollapse.py b/multiqc/modules/umicollapse/umicollapse.py
index 9e9c4a7f78..f9c3e7bd47 100644
--- a/multiqc/modules/umicollapse/umicollapse.py
+++ b/multiqc/modules/umicollapse/umicollapse.py
@@ -155,7 +155,7 @@ def bar_plot(self, data_by_sample):
keys,
{
"id": "umicollapse_deduplication_barplot",
- "title": "UMI-tools: Deduplication Counts",
+ "title": "UMICollapse: Deduplication Counts",
"ylab": "# Reads",
"cpswitch_counts_label": "Number of Reads",
},
diff --git a/multiqc/modules/xenium/xenium.py b/multiqc/modules/xenium/xenium.py
index c31504ffd6..64fb10eb2e 100644
--- a/multiqc/modules/xenium/xenium.py
+++ b/multiqc/modules/xenium/xenium.py
@@ -1,81 +1,39 @@
import json
import logging
-import re
from pathlib import Path
-from typing import Any, Dict, Optional, Tuple
-
-import numpy as np
-import polars as pl
+from typing import Dict
from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound
-from multiqc.plots import bargraph, box, linegraph, scatter, table
-from multiqc.plots.table_object import ColumnDict, TableConfig
-from multiqc.utils import mqc_colour
-
-# Try importing scipy, fallback gracefully if not available
-try:
- import scipy
- import scipy.stats
-
- SCIPY_AVAILABLE = True
-except ImportError:
- SCIPY_AVAILABLE = False
-
-# Try importing scanpy for H5 file reading, fallback gracefully if not available
-try:
- import scanpy as sc
-
- SCANPY_AVAILABLE = True
-except ImportError:
- SCANPY_AVAILABLE = False
+from multiqc.core import plugin_hooks
+from multiqc.plots.table_object import ColumnDict
log = logging.getLogger(__name__)
-# Define gene categories for coloring based on Xenium naming conventions
-GENE_CATS = {
- "Pre-designed": {"color": "rgba(31, 119, 180, 0.8)"}, # Standard gene names - blue with transparency
- "Custom": {"color": "rgba(255, 127, 14, 0.8)"}, # Orange with transparency
- "Negative Control Probe": {"color": "rgba(214, 39, 40, 0.8)"}, # Red with transparency
- "Negative Control Codeword": {"color": "rgba(255, 153, 0, 0.8)"}, # Yellow/Orange with transparency
- "Genomic Control Probe": {"color": "rgba(227, 119, 194, 0.8)"}, # Pink with transparency
- "Unassigned Codeword": {"color": "rgba(127, 127, 127, 0.8)"}, # Gray with transparency
- "Deprecated Codeword": {"color": "rgba(188, 189, 34, 0.8)"}, # Olive with transparency
-}
-
-
-def categorize_feature(feature_name) -> Tuple[str, str]:
- """Categorize a feature based on its name
- Splits the feature name into category and feature id"""
- # Check prefixes directly instead of using regex for better performance
- category = ""
- feature_id = feature_name.split("_")[1] if "_" in feature_name else feature_name
- if feature_name.startswith("Custom_"):
- category = "Custom"
- elif feature_name.startswith("NegControlProbe_"):
- category = "Negative Control Probe"
- elif feature_name.startswith("NegControlCodeword_"):
- category = "Negative Control Codeword"
- elif feature_name.startswith("GenomicControlProbe_"):
- category = "Genomic Control Probe"
- elif feature_name.startswith("UnassignedCodeword_"):
- category = "Unassigned Codeword"
- else:
- category = "Pre-designed" # Default category for standard gene names
- return category, feature_id
-
-
class MultiqcModule(BaseMultiqcModule):
"""
Xenium is a spatial transcriptomics platform from 10x Genomics that provides subcellular resolution.
:::note
- Parsing huge files is not an intended MultiQC usage. By default, MultiQC will ignore the `*.parquet` files
- as they are gigabyte-sized. To enable parsing those, make sure to have this line in your config:
+ This module provides basic quality metrics from the Xenium pipeline (total transcripts, cells detected,
+ transcript assignment rates, and median genes per cell).
+ For advanced visualizations including:
+
+ - Transcript quality distributions by gene category
+ - Cell and nucleus area distributions
+ - Field-of-view quality plots
+ - Segmentation method breakdown
+ - Transcripts per gene distributions
+
+ Install the [multiqc-xenium-extra](https://pypi.org/project/multiqc-xenium-extra/) plugin:
+
+ ```bash
+ pip install multiqc multiqc-xenium-extra
```
- log_filesize_limit: 5000000000 # 5GB
- ```
+
+ The plugin automatically adjusts the log filesize limit to parse large Xenium files (`.parquet` and `.h5`),
+ so you don't need to manually configure `log_filesize_limit` in your MultiQC config when using the plugin.
:::
The MultiQC module is tested with outputs from xenium-3.x, older versions of xenium output are
@@ -114,667 +72,38 @@ def __init__(self):
data_by_sample[parent_dir] = parsed_experiment_data
self.add_data_source(f, parent_dir)
- # Parse transcript quality data
- transcript_data_by_sample = {}
- transcript_files = list(self.find_log_files("xenium/transcripts", filecontents=False, filehandles=False))
-
- for transcript_f in transcript_files:
- parsed_transcript_data = self.parse_transcripts_parquet(transcript_f)
- if parsed_transcript_data:
- # Use parent directory name as sample name
- parent_dir = Path(transcript_f["root"]).name if transcript_f["root"] else transcript_f["s_name"]
- transcript_data_by_sample[parent_dir] = parsed_transcript_data
- self.add_data_source(transcript_f, parent_dir)
-
- # Parse cells.parquet files for cell-level metrics
- cells_data_by_sample = {}
- for cells_f in self.find_log_files("xenium/cells", filecontents=False, filehandles=False):
- parsed_cells_data = self.parse_cells_parquet(cells_f)
- if parsed_cells_data:
- # Use parent directory name as sample name
- parent_dir = Path(cells_f["root"]).name if cells_f["root"] else cells_f["s_name"]
- cells_data_by_sample[parent_dir] = parsed_cells_data
- self.add_data_source(cells_f, parent_dir)
-
- # Parse cell_feature_matrix.h5 files for detected genes per cell calculation
- for h5_f in self.find_log_files("xenium/cell_feature_matrix", filecontents=False, filehandles=False):
- detected_genes_data = self.parse_cell_feature_matrix_h5(h5_f)
- if detected_genes_data:
- # Use parent directory name as sample name
- parent_dir = Path(h5_f["root"]).name if h5_f["root"] else h5_f["s_name"]
- if parent_dir in cells_data_by_sample:
- # Merge detected genes data with existing cells data
- cells_data_by_sample[parent_dir].update(detected_genes_data)
- else:
- # Create new entry if cells.parquet wasn't found
- cells_data_by_sample[parent_dir] = detected_genes_data
- self.add_data_source(h5_f, parent_dir)
-
- data_by_sample = self.ignore_samples(data_by_sample)
- transcript_data_by_sample = self.ignore_samples(transcript_data_by_sample)
- cells_data_by_sample = self.ignore_samples(cells_data_by_sample)
+ self.data_by_sample = self.ignore_samples(data_by_sample)
- if len(data_by_sample) == 0 and len(transcript_data_by_sample) == 0 and len(cells_data_by_sample) == 0:
+ if len(self.data_by_sample) == 0:
raise ModuleNoSamplesFound
- log.info(f"Found {len(data_by_sample)} Xenium reports")
+ log.info(f"Found {len(self.data_by_sample)} Xenium reports")
# Check for QC issues and add warnings
- self.check_qc_warnings(data_by_sample)
-
- # Add software version info (Xenium files don't contain version info)
- for s_name in data_by_sample.keys():
- self.add_software_version(None, s_name)
-
- # Merge cell area metrics into main data for general stats
- for sample_name, cell_data in cells_data_by_sample.items():
- if sample_name in data_by_sample:
- # Add cell area metrics to existing sample data
- data_by_sample[sample_name]["cell_area_median"] = cell_data["cell_area_median"]
- data_by_sample[sample_name]["nucleus_area_median"] = cell_data["nucleus_area_median"]
- data_by_sample[sample_name]["nucleus_to_cell_area_ratio_median"] = cell_data[
- "nucleus_to_cell_area_ratio_median"
- ]
- elif cell_data:
- # Create new sample entry if only cell data exists
- data_by_sample[sample_name] = {}
- data_by_sample[sample_name]["cell_area_median"] = cell_data["cell_area_median"]
- data_by_sample[sample_name]["nucleus_area_median"] = cell_data["nucleus_area_median"]
- data_by_sample[sample_name]["nucleus_to_cell_area_ratio_median"] = cell_data[
- "nucleus_to_cell_area_ratio_median"
- ]
-
- # Use transcript count from parquet file if missing from JSON
- for sample_name, transcript_data in transcript_data_by_sample.items():
- if sample_name in data_by_sample:
- # Add transcript count if missing from JSON data
- if (
- "num_transcripts" not in data_by_sample[sample_name]
- or data_by_sample[sample_name]["num_transcripts"] is None
- ):
- if "total_transcripts" in transcript_data:
- data_by_sample[sample_name]["num_transcripts"] = transcript_data["total_transcripts"]
- elif "total_transcripts" in transcript_data:
- # Create new sample entry if only transcript data exists
- if sample_name not in data_by_sample:
- data_by_sample[sample_name] = {}
- data_by_sample[sample_name]["num_transcripts"] = transcript_data["total_transcripts"]
+ self.check_qc_warnings()
+
+ # Add software version info from experiment.xenium if available
+ for s_name, data in self.data_by_sample.items():
+ version = data.get("analysis_sw_version")
+ self.add_software_version(version, s_name)
+
+ # Configure initial headers for general stats table
+ self.setup_general_stats_headers()
+
+ # Call plugin hook to allow extensions to add data from parquet/H5 files
+ # This must happen BEFORE writing data files so plugins can augment data_by_sample
+ if "xenium_extra" in plugin_hooks.hook_functions:
+ log.debug("Calling xenium_extra plugin hooks")
+ for hook_fn in plugin_hooks.hook_functions["xenium_extra"]:
+ hook_fn(self)
+ else:
+ log.info("Run 'pip install multiqc-xenium-extra' for additional visualizations")
# Write parsed data to a file
- self.write_data_file(data_by_sample, "multiqc_xenium")
+ self.write_data_file(self.data_by_sample, "multiqc_xenium")
# Add key metrics to general stats
- self.xenium_general_stats_table(data_by_sample)
-
- # Create plots - Cell detection metrics are already in general stats table
-
- self.add_section(
- name="Segmentation Method",
- anchor="xenium-segmentation",
- description="Distribution of cell segmentation methods used",
- helptext="""
- This stacked bar chart shows the fraction of cells segmented by each method:
-
- * **Boundary**: Cells segmented using boundary staining (e.g., ATP1A1/E-cadherin/CD45)
- * **Interior**: Cells segmented using interior staining (e.g., 18S RNA)
- * **Nuclear Expansion**: Cells segmented by expanding from nucleus boundaries
-
- **What to look for:**
- * **Boundary segmentation** typically provides the most accurate cell boundaries
- * **High nuclear expansion fraction** may indicate poor membrane staining
- * Consistent ratios across samples of the same tissue type
-
- **Interpretation:**
- * >80% boundary segmentation: Excellent membrane staining and segmentation
- * >50% nuclear expansion: Consider optimizing membrane staining protocols
- * Large sample-to-sample variation: Check staining consistency
- """,
- plot=self.xenium_segmentation_plot(data_by_sample),
- )
-
- # Add transcript quality section if transcript data is available
- if transcript_data_by_sample:
- if len(transcript_data_by_sample) == 1:
- self.add_section(
- name="Transcript Quality",
- anchor="xenium-transcript-quality",
- description="Transcript quality statistics by gene category",
- helptext="""
- This scatter plot shows transcript quality statistics broken down by gene category:
-
- **Gene Categories:**
- * **Pre-designed**: Standard genes from Xenium panels
- * **Custom**: User-added custom targets
- * **Deprecated**: Genes no longer recommended for use
- * **Control**: Control probe sequences (e.g., negative controls)
-
- **Quality Metrics:**
- * **X-axis**: Transcript count per gene category
- * **Y-axis**: Quality score distribution for each category
-
- **Expected patterns:**
- * Pre-designed genes typically show the highest counts and quality
- * Custom genes may show variable performance depending on probe design
- * Control probes should show expected low signal
- """,
- plot=self.xenium_transcript_quality_scatter_plot(transcript_data_by_sample),
- )
- else:
- self.add_section(
- name="Transcript Quality Summary",
- anchor="xenium-transcript-quality",
- description="Per-sample mean transcript quality statistics by gene category",
- helptext="""
- This table shows mean transcript quality statistics for each sample, with separate columns for each gene category:
-
- **Gene Categories:**
- * **Pre-designed**: Standard genes from Xenium panels
- * **Custom**: User-added custom targets
- * **Negative Control Probe/Codeword**: Control probes for background estimation
- * **Genomic Control Probe**: Genomic DNA controls
- * **Unassigned/Deprecated Codeword**: Other transcript types
-
- **Quality Score (QV) Interpretation:**
- * QV ≥20: High-quality transcripts (≥99% accuracy)
- * QV 10-20: Medium quality (90-99% accuracy)
- * QV <10: Low-quality transcripts (<90% accuracy)
-
- **Table Layout:**
- * **Rows**: Individual samples
- * **Columns**: Mean QV and Standard Deviation for each category
- * Values show quality statistics computed from all transcripts in that category for each sample
-
- **What to look for:**
- * Pre-designed genes should have high mean QV (>20) across all samples
- * Consistent quality patterns across samples indicate good data quality
- * High standard deviations may indicate heterogeneous quality within a category
- * Missing values (empty cells) indicate no transcripts found for that category in that sample
- """,
- plot=self.xenium_transcript_quality_table(transcript_data_by_sample),
- )
-
- # Add transcripts per gene distribution if available
- transcripts_per_gene_plot = self.xenium_transcripts_per_gene_plot(transcript_data_by_sample)
- if transcripts_per_gene_plot is not None:
- self.add_section(
- name="Distribution of Transcripts",
- anchor="xenium-transcripts-per-gene",
- description="Distribution of transcript counts per gene",
- helptext="""
- This histogram shows the distribution of transcript counts per gene across all samples:
-
- **What it shows:**
- * **X-axis**: Number of transcripts per gene (log scale)
- * **Y-axis**: Number of genes with that transcript count
- * **Two categories**: Genes vs. non-genes (controls, unassigned, etc.)
-
- **Interpretation:**
- * **Most genes** should have moderate transcript counts (hundreds to thousands)
- * **Controls and non-genes** typically have lower counts
- * **Very high counts** may indicate highly expressed genes or technical artifacts
- * **Very low counts** may indicate poorly detected genes
-
- **What to look for:**
- * **Smooth distribution** for genes with a peak in the hundreds-thousands range
- * **Lower counts** for non-gene features (controls)
- * **No extreme outliers** unless biologically expected
- * **Consistent patterns** across similar tissue types
-
- **Quality indicators:**
- * Peak gene expression around 100-10,000 transcripts per gene is typical
- * Clear separation between gene and non-gene distributions
- * Absence of unusual spikes or gaps in the distribution
- """,
- plot=transcripts_per_gene_plot,
- )
-
- # Add cell area distribution section if cells data is available
- if cells_data_by_sample and SCIPY_AVAILABLE:
- area_plot = self.xenium_cell_area_distribution_plot(cells_data_by_sample)
- if area_plot:
- self.add_section(
- name="Cell Area Distribution",
- anchor="xenium-cell-area-distribution",
- description="Distribution of cell areas across samples",
- helptext="""
- This plot shows the distribution of cell areas in the sample(s):
-
- **Single sample**: Density plot with vertical lines showing mean and median cell area
- **Multiple samples**: Violin plots showing the distribution for each sample
-
- **Typical cell area ranges (tissue-dependent):**
- * **Most tissues**: 50-200 μm²
- * **Large cells** (e.g., neurons): 200-500 μm²
- * **Small cells** (e.g., lymphocytes): 20-80 μm²
-
- **What to look for:**
- * **Consistent distributions** across samples of the same tissue type
- * **Biologically reasonable values** for your tissue
- * **Outliers**: Very large or small cells may indicate segmentation issues
-
- **Troubleshooting:**
- * Bimodal distributions: May indicate mixed cell types or segmentation artifacts
- * Very large cells: Over-segmentation, cell doublets, or debris
- * Very small cells: Under-segmentation, nuclear fragments
- """,
- plot=area_plot,
- )
-
- # Add nucleus RNA fraction distribution plot (scipy required)
- nucleus_plot = self.xenium_nucleus_rna_fraction_plot(cells_data_by_sample)
- if nucleus_plot:
- self.add_section(
- name="Fraction of Transcripts in Nucleus",
- anchor="xenium-nucleus-rna-fraction",
- description="Distribution of the fraction of transcripts found in the nucleus across cells",
- helptext="""
- This plot shows the distribution of the fraction of RNA molecules located in the nucleus versus cytoplasm for each cell:
-
- **Single sample**: Density plot showing the distribution of nucleus RNA fractions
- **Multiple samples**: Box plots comparing distributions across samples
-
- **Biological interpretation:**
- * **Low values (0.0-0.2)**: Most RNA is cytoplasmic (expected for mature mRNAs)
- * **High values (>0.5)**: High nuclear retention (may indicate processing issues)
- * **Peak around 0.0-0.1**: Normal for most cell types with efficient RNA export
-
- **What to look for:**
- * **Consistent distributions** across samples of the same tissue type
- * **Biologically reasonable values** for your cell types
- * **Sample differences**: May reflect cell type composition or processing efficiency
-
- **Troubleshooting:**
- * Very high nuclear fractions: Check for nuclear segmentation issues
- * Bimodal distributions: May indicate different cell types or states
- * Outliers: Individual cells with unusual RNA localization patterns
- """,
- plot=nucleus_plot,
- )
-
- # Add nucleus-to-cell area ratio distribution plot
- ratio_plot = self.xenium_nucleus_cell_area_ratio_plot(cells_data_by_sample)
- if ratio_plot:
- self.add_section(
- name="Nucleus to Cell Area",
- anchor="xenium-nucleus-cell-area-ratio",
- description="Distribution of nucleus-to-cell area ratios across cells",
- helptext="""
- This plot shows the distribution of the ratio between nucleus area and total cell area for each cell:
-
- **Single sample**: Density plot showing the distribution of nucleus-to-cell area ratios
- **Multiple samples**: Box plots comparing distributions across samples
-
- **Biological interpretation:**
- * **Typical range**: 0.2-0.6 for most cell types
- * **Low values (<0.2)**: Small nucleus relative to cell (may indicate active/mature cells)
- * **High values (>0.6)**: Large nucleus relative to cell (may indicate dividing or stressed cells)
- * **Peak around 0.3-0.5**: Normal for most healthy cell types
-
- **What to look for:**
- * **Consistent distributions** across samples of the same tissue type
- * **Biologically reasonable values** for your cell types
- * **Sample differences**: May reflect different cell states or tissue composition
-
- **Quality assessment:**
- * Very low ratios: May indicate over-segmented cells or debris
- * Very high ratios: May indicate under-segmented cells or nuclear fragments
- * Bimodal distributions: May indicate different cell types or segmentation artifacts
-
- **Troubleshooting:**
- * Unusual distributions may suggest issues with nuclear or cell segmentation parameters
- * Consider tissue-specific expected ranges when evaluating results
- """,
- plot=ratio_plot,
- )
-
- # Add combined cell distribution plot (transcripts and genes per cell)
- combined_plot = self.xenium_cell_distributions_combined_plot(cells_data_by_sample)
- if combined_plot:
- self.add_section(
- name="Distribution of Transcripts/Genes per Cell",
- anchor="xenium-cell-distributions",
- description="Distribution of transcripts and detected genes per cell",
- helptext="""
- This plot shows two key cell-level distributions with separate tabs/datasets:
-
- **Tab 1: Transcripts per cell** - Shows the distribution of total transcript counts per cell
- **Tab 2: Detected genes per cell** - Shows the distribution of unique genes detected per cell
-
- **Plot types:**
- * **Single sample**: Density plots showing the distribution shapes
- * **Multiple samples**: Box plots comparing distributions across samples
-
- **Transcripts per cell interpretation:**
- * **Typical range**: 100-5000 transcripts per cell for most tissues
- * **High transcript counts**: Metabolically active cells or large cell types
- * **Low transcript counts**: Less active cells, technical dropouts, or small cell fragments
- * **Quality thresholds**: <50 may indicate poor segmentation, >10,000 may indicate doublets
-
- **Detected genes per cell interpretation:**
- * **Typical range**: 50-2000 genes per cell depending on cell type and panel size
- * **High gene counts**: Metabolically active cells or cells with high expression diversity
- * **Low gene counts**: Specialized cells, inactive cells, or technical dropouts
-
- **What to look for:**
- * **Unimodal distributions**: Expected for homogeneous cell populations
- * **Multimodal distributions**: May indicate different cell types or technical artifacts
- * **Sample consistency**: Similar distributions expected for replicate samples
- * **Positive correlation**: Generally expect transcripts and detected genes per cell to correlate
-
- **Panel considerations:**
- * **Pre-designed panels**: Gene counts limited by panel design (typically 100-1000 genes)
- * **Custom panels**: Consider gene selection bias when interpreting results
- * **Detection efficiency**: Some genes may be harder to detect than others
-
- **Quality assessment:**
- * **Counts**: Very low (<50) or very high (>10,000) may indicate segmentation issues
- * **Shoulder distributions**: May indicate presence of different cell types
-
- **Troubleshooting:**
- * Unusual distributions may suggest issues with transcript detection or cell segmentation
- * Consider cell type and tissue context when evaluating expected ranges
- * Low gene detection may suggest transcript assignment issues
- """,
- plot=combined_plot,
- )
-
- # Add Field of View quality section at the end if FoV data is available
- if transcript_data_by_sample:
- fov_plot = self.xenium_fov_quality_plot(transcript_data_by_sample)
- if fov_plot is not None:
- self.add_section(
- name="Field of View Quality",
- anchor="xenium-fov-quality",
- description="Field of View quality distribution across QV ranges",
- helptext="""
- This plot shows the distribution of Field of View (FoV) quality across different quality ranges:
-
- **What is a Field of View?**
- * Each FoV represents one microscope imaging area/tile
- * Large tissue sections are imaged as multiple overlapping FoVs
- * FoVs are systematically captured in a grid pattern across the tissue
-
- **Plot interpretation:**
- * **X-axis**: Quality ranges (Low to Excellent QV ranges)
- * **Y-axis**: Fields of View in each quality range
- * **Colors**: Color-coded by quality level (grey=poor, green=excellent)
- * **Bars**: Each sample shown as separate colored bars for comparison
-
- **Quality ranges:**
- * **Low (QV < 20)**: Poor imaging quality - investigate issues (dark grey)
- * **Poor (QV 20-25)**: Below optimal quality - may need attention (light grey)
- * **Fair (QV 25-30)**: Acceptable quality (lighter grey)
- * **Good (QV 30-35)**: Good imaging quality (light green)
- * **Excellent (QV ≥ 35)**: Optimal imaging quality (bright green)
-
- **What to look for:**
- * **Good distribution**: Most FoVs should be in "Good" or "Excellent" ranges
- * **Few poor FoVs**: Minimal counts in "Low" and "Poor" ranges
- * **Sample consistency**: Similar distributions across samples
-
- **Troubleshooting:**
- * Many low-quality FoVs: Focus/illumination issues, debris, tissue damage
- * Sample inconsistency: Processing or storage differences
- * Edge effects: FoVs at tissue edges often have lower quality
- """,
- plot=fov_plot,
- )
-
- def _create_non_overlapping_labels(
- self,
- mean_value,
- median_value,
- mean_color="red",
- median_color="green",
- precision=0,
- suffix="",
- prefix="",
- threshold_percent=5,
- data_min=None,
- data_max=None,
- ):
- """
- Create vertical line configurations with non-overlapping labels when mean and median are close.
-
- Args:
- mean_value: Mean value for vertical line
- median_value: Median value for vertical line
- mean_color: Color for mean line (default: "red")
- median_color: Color for median line (default: "green")
- precision: Decimal places for value display
- suffix: Unit suffix to add to labels (e.g., " μm²")
- prefix: Prefix for labels (e.g., "Transcripts ", "Genes ")
- threshold_percent: If values are within this percentage of plot range, offset labels
- data_min: Minimum value of the underlying data range (optional)
- data_max: Maximum value of the underlying data range (optional)
-
- Returns:
- List of line configurations with appropriate label positioning
- """
- # Calculate plot range for scale-aware overlap detection
- if data_min is not None and data_max is not None:
- plot_range = data_max - data_min
-
- # If data range is too small, use mean/median range
- if plot_range == 0:
- plot_range = max(abs(mean_value - median_value), max(abs(mean_value), abs(median_value), 1))
- else:
- # Fall back to using mean/median values to estimate scale
- plot_range = max(abs(mean_value), abs(median_value), 1)
-
- # Calculate percentage difference relative to plot scale
- value_diff = abs(mean_value - median_value)
- range_percent_diff = (value_diff / plot_range) * 100
-
- # Format values according to precision
- if precision == 0:
- mean_str = f"{mean_value:.0f}"
- median_str = f"{median_value:.0f}"
- else:
- mean_str = f"{mean_value:.{precision}f}"
- median_str = f"{median_value:.{precision}f}"
-
- # Create base line configurations
- lines = [
- {
- "value": float(mean_value),
- "color": mean_color,
- "dash": "dash",
- "width": 2,
- "label": f"{prefix}Mean ({mean_str}{suffix})",
- },
- {
- "value": float(median_value),
- "color": median_color,
- "dash": "dash",
- "width": 2,
- "label": f"{prefix}Median ({median_str}{suffix})",
- },
- ]
-
- # If values are too close on the plot scale, create labels with non-breaking spaces to offset them horizontally
- if range_percent_diff < threshold_percent:
- # Use non-breaking spaces to create horizontal offset
- space = " " * 30
- lines[0]["label"] = f"{prefix}Mean ({mean_str}{suffix}){space}" # Add trailing spaces
- lines[1]["label"] = f"{space}{prefix}Median ({median_str}{suffix})" # Add leading spaces
-
- return lines
-
- def _create_non_overlapping_combined_lines(
- self, transcript_values=None, gene_values=None, plot_data=None, threshold_percent=5
- ):
- """
- Create all vertical lines for combined plots with intelligent label positioning to avoid any overlaps.
-
- Args:
- transcript_values: Array of transcript values (optional)
- gene_values: Array of gene values (optional)
- plot_data: Dictionary of plot data to calculate X-axis range (optional)
- threshold_percent: Minimum percentage difference relative to plot range
-
- Returns:
- List of all line configurations with non-overlapping labels
- """
- import numpy as np
-
- lines = []
- all_values = [] # Track all line values for overlap detection
-
- # Collect transcript lines if provided
- if transcript_values is not None:
- mean_transcripts = np.nanmean(transcript_values)
- median_transcripts = np.nanmedian(transcript_values)
-
- transcript_lines = [
- {
- "value": float(mean_transcripts),
- "color": "#7cb5ec",
- "dash": "dash",
- "width": 2,
- "label": f"Transcripts Mean ({mean_transcripts:.0f})",
- "type": "mean",
- "dataset": "transcripts",
- },
- {
- "value": float(median_transcripts),
- "color": "#99c2e8",
- "dash": "dash",
- "width": 2,
- "label": f"Transcripts Median ({median_transcripts:.0f})",
- "type": "median",
- "dataset": "transcripts",
- },
- ]
- lines.extend(transcript_lines)
- all_values.extend([mean_transcripts, median_transcripts])
-
- # Collect gene lines if provided
- if gene_values is not None:
- mean_genes = np.nanmean(gene_values)
- median_genes = np.nanmedian(gene_values)
-
- gene_lines = [
- {
- "value": float(mean_genes),
- "color": "#434348",
- "dash": "dash",
- "width": 2,
- "label": f"Genes Mean ({mean_genes:.0f})",
- "type": "mean",
- "dataset": "genes",
- },
- {
- "value": float(median_genes),
- "color": "#888888",
- "dash": "dash",
- "width": 2,
- "label": f"Genes Median ({median_genes:.0f})",
- "type": "median",
- "dataset": "genes",
- },
- ]
- lines.extend(gene_lines)
- all_values.extend([mean_genes, median_genes])
-
- if not lines:
- return []
-
- # Sort lines by value for easier overlap detection
- lines.sort(key=lambda x: x["value"])
-
- # Calculate plot range from actual plot data X values
- if plot_data:
- all_x_values = []
- for dataset in plot_data.values():
- all_x_values.extend(dataset.keys())
-
- if all_x_values:
- min_value = min(all_x_values)
- max_value = max(all_x_values)
- plot_range = max_value - min_value
- else:
- # Fallback to line values if no plot data
- all_line_values = [line["value"] for line in lines]
- min_value = min(all_line_values)
- max_value = max(all_line_values)
- plot_range = max_value - min_value
- else:
- # Fallback to line values if no plot data provided
- all_line_values = [line["value"] for line in lines]
- min_value = min(all_line_values)
- max_value = max(all_line_values)
- plot_range = max_value - min_value
-
- # If plot range is too small, fall back to absolute threshold
- if plot_range == 0:
- plot_range = max(abs(max_value), 1) # Avoid division by zero
-
- # Group overlapping lines and apply spacing once per group
- processed = set()
-
- for i in range(len(lines)):
- if i in processed:
- continue
-
- line = lines[i]
- overlap_group = [i]
-
- # Find all lines that overlap with this one
- for j in range(i + 1, len(lines)):
- if j in processed:
- continue
-
- other_line = lines[j]
- value_diff = abs(line["value"] - other_line["value"])
-
- # Calculate percentage relative to the plot range, not individual values
- range_percent_diff = (value_diff / plot_range) * 100
-
- if range_percent_diff < threshold_percent:
- overlap_group.append(j)
-
- # Apply spacing to the entire overlap group
- if len(overlap_group) > 1:
- space = " " * 15
- group_size = len(overlap_group)
-
- for idx, line_idx in enumerate(overlap_group):
- target_line = lines[line_idx]
-
- if group_size == 2:
- # Two lines: one gets trailing space, other gets leading space
- if idx == 0:
- target_line["label"] = target_line["label"] + space
- else:
- target_line["label"] = space + target_line["label"]
- elif group_size == 3:
- # Three lines: spread out with different amounts of spacing
- if idx == 0:
- target_line["label"] = target_line["label"] + space + space
- elif idx == 1:
- target_line["label"] = space + target_line["label"] + space
- else:
- target_line["label"] = space + space + target_line["label"]
- elif group_size >= 4:
- # Four or more lines: maximum spreading
- if idx == 0:
- target_line["label"] = target_line["label"] + space + space + space
- elif idx == 1:
- target_line["label"] = target_line["label"] + space
- elif idx == group_size - 2:
- target_line["label"] = space + target_line["label"]
- else:
- target_line["label"] = space + space + space + target_line["label"]
-
- processed.add(line_idx)
-
- # Clean up temporary fields
- for line in lines:
- line.pop("type", None)
- line.pop("dataset", None)
-
- return lines
+ self.xenium_general_stats_table()
def parse_xenium_metrics(self, f) -> Dict:
"""Parse Xenium metrics_summary.csv file"""
@@ -897,1955 +226,85 @@ def parse_experiment_json(self, f) -> Dict:
log.warning(f"Could not parse experiment.xenium file {f['fn']}: {e}")
return {}
- def parse_transcripts_parquet(self, f) -> Optional[Dict]:
- """Parse Xenium transcripts.parquet file with optimized lazy dataframe processing
-
- Only computes aggregated statistics needed for reporting, avoiding per-transcript dictionaries.
-
- Args:
- f: File info dict
- """
- file_path = Path(f["root"]) / f["fn"]
-
- # Use lazy loading to avoid reading entire file into memory
- df_lazy = pl.scan_parquet(file_path)
-
- # Check if required columns exist by scanning schema (avoid performance warning)
- schema = df_lazy.collect_schema()
- required_cols = ["qv", "feature_name"]
- if not all(col in schema for col in required_cols):
- log.warning(f"Missing required columns in {f['fn']}: {required_cols}")
- return None
-
- # Get total row count efficiently without loading full data
- total_transcripts = df_lazy.select(pl.len()).collect().item()
-
- # Compute category statistics directly in lazy dataframe for optimal performance
- # This replaces per-transcript dictionaries with aggregated category stats
- category_stats = (
- df_lazy.with_columns(
- pl.col("feature_name")
- .map_elements(lambda x: categorize_feature(str(x))[0], return_dtype=pl.Utf8)
- .alias("category")
- )
- .group_by("category")
- .agg(
- [
- pl.col("qv").mean().alias("mean_quality"),
- pl.col("qv").std().alias("std_quality"),
- pl.col("qv").count().alias("transcript_count"),
- pl.col("feature_name").n_unique().alias("feature_count"),
- ]
- )
- .collect()
- )
-
- # Create optimized result structure - only store aggregated category statistics
- category_summary = {}
- for row in category_stats.iter_rows(named=True):
- category = str(row["category"])
- category_summary[category] = {
- "mean_quality": row["mean_quality"],
- "std_quality": row["std_quality"] or 0.0, # Handle null std for single values
- "transcript_count": row["transcript_count"],
- "feature_count": row["feature_count"],
- }
-
- result = {
- "category_summary": category_summary,
- "total_transcripts": total_transcripts,
- }
-
- # Add feature-level transcript counts for scatter plot (single sample case)
- # This is needed for the transcript quality scatter plot
- feature_stats = (
- df_lazy.group_by("feature_name")
- .agg(
- [
- pl.col("qv").mean().alias("mean_quality"),
- pl.col("qv").count().alias("count"),
- ]
- )
- .collect()
- )
-
- # Create transcript_counts dictionary for scatter plot
- transcript_counts = {}
- for row in feature_stats.iter_rows(named=True):
- feature_name = str(row["feature_name"])
- transcript_counts[feature_name] = {
- "count": row["count"],
- "mean_quality": row["mean_quality"],
- }
-
- result["transcript_counts"] = transcript_counts
-
- # Add transcripts per gene analysis if is_gene column is present
- if "is_gene" in schema:
- transcript_stats = (
- df_lazy.group_by("feature_name")
- .agg([pl.len().alias("transcript_count"), pl.col("is_gene").first().alias("is_gene")])
- .collect()
- )
-
- if not transcript_stats.is_empty():
- molecules_per_gene = {}
- for row in transcript_stats.iter_rows(named=True):
- feature_name = str(row["feature_name"])
- molecules_per_gene[feature_name] = {
- "count": row["transcript_count"], # This is transcript count per gene
- "is_gene": row["is_gene"],
- }
- result["molecules_per_gene"] = molecules_per_gene
-
- # Calculate noise threshold directly from transcript_stats DataFrame
- result["noise_threshold"] = self.calculate_noise_threshold_from_df(transcript_stats)
-
- # Add FoV quality analysis if fov_name column is present
- if "fov_name" in schema:
- fov_stats = (
- df_lazy.group_by("fov_name")
- .agg(
- [
- pl.col("qv").mean().alias("mean_qv"),
- pl.col("qv").median().alias("median_qv"),
- pl.col("qv").std().alias("std_qv"),
- pl.len().alias("transcript_count"),
- ]
- )
- .collect()
- )
-
- fov_quality_stats = {}
- fov_medians = []
- for row in fov_stats.iter_rows(named=True):
- fov_name = str(row["fov_name"])
- median_qv = row["median_qv"]
- fov_quality_stats[fov_name] = {
- "mean_quality": row["mean_qv"],
- "median_quality": median_qv,
- "std_quality": row["std_qv"] or 0.0,
- "transcript_count": row["transcript_count"],
- }
- if median_qv is not None:
- fov_medians.append(median_qv)
-
- result["fov_quality_stats"] = fov_quality_stats
- result["fov_median_qualities"] = fov_medians # For heatmap generation
-
- return result
-
- def parse_cells_parquet(self, f) -> Optional[Dict]:
- """Parse Xenium cells.parquet file to extract cell-level metrics"""
- file_path = Path(f["root"]) / f["fn"]
-
- # Use lazy reading to avoid loading entire file into memory
- log.info(f"Processing cells parquet file with memory-efficient lazy read: {file_path}")
- # Start with lazy frame to check schema without loading data
- lazy_df = pl.scan_parquet(file_path, parallel="none") # parallel execution causing panics
-
- # Check for required columns using schema
- schema = lazy_df.collect_schema()
- required_cols = ["cell_area", "nucleus_area", "total_counts", "transcript_counts"]
- missing_cols = [col for col in required_cols if col not in schema]
- if missing_cols:
- log.warning(f"Missing columns in {f['fn']}: {missing_cols}")
- return None
-
- # Get row count efficiently without loading data
- total_cells = lazy_df.select(pl.len()).collect().item()
- cell_stats = {"total_cells": total_cells}
-
- # Cell area distribution stats using lazy operations
- cell_area_stats = (
- lazy_df.filter(pl.col("cell_area").is_not_null())
- .select(
- [
- pl.col("cell_area").mean().alias("mean"),
- pl.col("cell_area").median().alias("median"),
- pl.col("cell_area").std().alias("std"),
- pl.col("cell_area").min().alias("min"),
- pl.col("cell_area").max().alias("max"),
- pl.col("cell_area").quantile(0.25).alias("q1"),
- pl.col("cell_area").quantile(0.75).alias("q3"),
- pl.col("cell_area").count().alias("count"),
- ]
- )
- .collect()
- )
-
- if cell_area_stats["count"].item() > 0:
- cell_stats.update(
- {
- "cell_area_mean": cell_area_stats["mean"].item(),
- "cell_area_median": cell_area_stats["median"].item(),
- "cell_area_std": cell_area_stats["std"].item(),
- "cell_area_min": cell_area_stats["min"].item(),
- "cell_area_max": cell_area_stats["max"].item(),
- "cell_area_q1": cell_area_stats["q1"].item(),
- "cell_area_q3": cell_area_stats["q3"].item(),
- }
- )
-
- # Store box plot statistics instead of raw values
- cell_stats["cell_area_box_stats"] = {
- "min": cell_area_stats["min"].item(),
- "q1": cell_area_stats["q1"].item(),
- "median": cell_area_stats["median"].item(),
- "q3": cell_area_stats["q3"].item(),
- "max": cell_area_stats["max"].item(),
- "mean": cell_area_stats["mean"].item(),
- "count": cell_area_stats["count"].item(),
- }
-
- # Nucleus area distribution stats using lazy operations
- nucleus_area_stats = (
- lazy_df.filter(pl.col("nucleus_area").is_not_null())
- .select(
- [
- pl.col("nucleus_area").mean().alias("mean"),
- pl.col("nucleus_area").median().alias("median"),
- pl.col("nucleus_area").std().alias("std"),
- pl.col("nucleus_area").count().alias("count"),
- ]
- )
- .collect()
- )
-
- if nucleus_area_stats["count"].item() > 0:
- cell_stats.update(
- {
- "nucleus_area_mean": nucleus_area_stats["mean"].item(),
- "nucleus_area_median": nucleus_area_stats["median"].item(),
- "nucleus_area_std": nucleus_area_stats["std"].item(),
- }
- )
-
- # Nucleus to cell area ratio (only for non-null values)
- ratio_stats = (
- lazy_df.filter(
- (pl.col("cell_area").is_not_null())
- & (pl.col("nucleus_area").is_not_null())
- & (pl.col("cell_area") > 0)
- )
- .with_columns((pl.col("nucleus_area") / pl.col("cell_area")).alias("ratio"))
- .select(
- [
- pl.col("ratio").mean().alias("mean"),
- pl.col("ratio").median().alias("median"),
- pl.col("ratio").count().alias("count"),
- ]
- )
- .collect()
- )
-
- if ratio_stats["count"].item() > 0:
- cell_stats.update(
- {
- "nucleus_to_cell_area_ratio_mean": ratio_stats["mean"].item(),
- "nucleus_to_cell_area_ratio_median": ratio_stats["median"].item(),
- }
- )
-
- # Calculate ratio distribution statistics for box plots
- ratio_dist_stats = (
- lazy_df.filter(
- (pl.col("cell_area").is_not_null())
- & (pl.col("nucleus_area").is_not_null())
- & (pl.col("cell_area") > 0)
- )
- .with_columns((pl.col("nucleus_area") / pl.col("cell_area")).alias("ratio"))
- .select(
- [
- pl.col("ratio").min().alias("min"),
- pl.col("ratio").quantile(0.25).alias("q1"),
- pl.col("ratio").median().alias("median"),
- pl.col("ratio").quantile(0.75).alias("q3"),
- pl.col("ratio").max().alias("max"),
- pl.col("ratio").mean().alias("mean"),
- pl.col("ratio").count().alias("count"),
- ]
- )
- .collect()
- )
-
- if ratio_dist_stats["count"].item() > 0:
- cell_stats["nucleus_to_cell_area_ratio_box_stats"] = {
- "min": ratio_dist_stats["min"].item(),
- "q1": ratio_dist_stats["q1"].item(),
- "median": ratio_dist_stats["median"].item(),
- "q3": ratio_dist_stats["q3"].item(),
- "max": ratio_dist_stats["max"].item(),
- "mean": ratio_dist_stats["mean"].item(),
- "count": ratio_dist_stats["count"].item(),
- }
-
- # Store total transcript counts per cell (total_counts) for distribution plots
- total_count_check = (
- lazy_df.filter(pl.col("total_counts").is_not_null())
- .select(pl.col("total_counts").count().alias("count"))
- .collect()
- )
-
- if total_count_check["count"].item() > 0:
- # Calculate total counts distribution statistics for box plots
- total_counts_stats = (
- lazy_df.filter(pl.col("total_counts").is_not_null())
- .select(
- [
- pl.col("total_counts").min().alias("min"),
- pl.col("total_counts").quantile(0.25).alias("q1"),
- pl.col("total_counts").median().alias("median"),
- pl.col("total_counts").quantile(0.75).alias("q3"),
- pl.col("total_counts").max().alias("max"),
- pl.col("total_counts").mean().alias("mean"),
- pl.col("total_counts").count().alias("count"),
- ]
- )
- .collect()
- )
- cell_stats["total_counts_box_stats"] = {
- "min": total_counts_stats["min"].item(),
- "q1": total_counts_stats["q1"].item(),
- "median": total_counts_stats["median"].item(),
- "q3": total_counts_stats["q3"].item(),
- "max": total_counts_stats["max"].item(),
- "mean": total_counts_stats["mean"].item(),
- "count": total_counts_stats["count"].item(),
- }
-
- # Store detected genes per cell (transcript_counts) for distribution plots
- # NOTE: This will be overridden by H5-based calculation if cell_feature_matrix.h5 is available
- detected_count_check = (
- lazy_df.filter(pl.col("transcript_counts").is_not_null())
- .select(pl.col("transcript_counts").count().alias("count"))
- .collect()
- )
-
- if detected_count_check["count"].item() > 0:
- # Calculate detected genes per cell distribution statistics for box plots
- gene_counts_stats = (
- lazy_df.filter(pl.col("transcript_counts").is_not_null())
- .select(
- [
- pl.col("transcript_counts").min().alias("min"),
- pl.col("transcript_counts").quantile(0.25).alias("q1"),
- pl.col("transcript_counts").median().alias("median"),
- pl.col("transcript_counts").quantile(0.75).alias("q3"),
- pl.col("transcript_counts").max().alias("max"),
- pl.col("transcript_counts").mean().alias("mean"),
- pl.col("transcript_counts").count().alias("count"),
- ]
+ def check_qc_warnings(self):
+ """Check for common QC issues and add warnings to samples"""
+ for s_name, data in self.data_by_sample.items():
+ # Check for low transcript assignment rate
+ if data.get("fraction_transcripts_assigned", 1.0) < 0.7:
+ log.warning(
+ f"Sample '{s_name}' has low transcript assignment rate: {data['fraction_transcripts_assigned']:.3f} (< 0.7). Cell segmentation likely needs refinement."
)
- .collect()
- )
- cell_stats["gene_transcript_counts_box_stats"] = {
- "min": gene_counts_stats["min"].item(),
- "q1": gene_counts_stats["q1"].item(),
- "median": gene_counts_stats["median"].item(),
- "q3": gene_counts_stats["q3"].item(),
- "max": gene_counts_stats["max"].item(),
- "mean": gene_counts_stats["mean"].item(),
- "count": gene_counts_stats["count"].item(),
- }
-
- # Add nucleus RNA fraction if nucleus_count is available
- if "nucleus_count" in schema:
- nucleus_fraction_stats = (
- lazy_df.filter(pl.col("total_counts") >= 10)
- .with_columns((pl.col("nucleus_count") / pl.col("total_counts")).alias("fraction"))
- .select(
- [
- pl.col("fraction").mean().alias("mean"),
- pl.col("fraction").median().alias("median"),
- pl.col("fraction").count().alias("count"),
- ]
- )
- .collect()
- )
-
- if nucleus_fraction_stats["count"].item() > 0:
- cell_stats.update(
- {
- "nucleus_rna_fraction_mean": nucleus_fraction_stats["mean"].item(),
- "nucleus_rna_fraction_median": nucleus_fraction_stats["median"].item(),
- }
- )
-
- # Calculate nucleus RNA fraction distribution statistics for box plots
- nucleus_fraction_dist_stats = (
- lazy_df.filter(pl.col("total_counts") > 0)
- .with_columns((pl.col("nucleus_count") / pl.col("total_counts")).alias("fraction"))
- .select(
- [
- pl.col("fraction").min().alias("min"),
- pl.col("fraction").quantile(0.25).alias("q1"),
- pl.col("fraction").median().alias("median"),
- pl.col("fraction").quantile(0.75).alias("q3"),
- pl.col("fraction").max().alias("max"),
- pl.col("fraction").mean().alias("mean"),
- pl.col("fraction").count().alias("count"),
- ]
- )
- .collect()
- )
- cell_stats["nucleus_rna_fraction_box_stats"] = {
- "min": nucleus_fraction_dist_stats["min"].item(),
- "q1": nucleus_fraction_dist_stats["q1"].item(),
- "median": nucleus_fraction_dist_stats["median"].item(),
- "q3": nucleus_fraction_dist_stats["q3"].item(),
- "max": nucleus_fraction_dist_stats["max"].item(),
- "mean": nucleus_fraction_dist_stats["mean"].item(),
- "count": nucleus_fraction_dist_stats["count"].item(),
- }
-
- return cell_stats
- def check_qc_warnings(self, data_by_sample):
- """Check for quality control issues and log warnings"""
- low_assignment_threshold = 0.7 # 70% threshold as mentioned in notebooks
+ def setup_general_stats_headers(self):
+ self.genstat_headers = {}
- for s_name, data in data_by_sample.items():
- if "fraction_transcripts_assigned" in data:
- assignment_rate = data["fraction_transcripts_assigned"]
- if assignment_rate < low_assignment_threshold:
- log.warning(
- f"Sample '{s_name}' has low transcript assignment rate: "
- f"{assignment_rate:.3f} (< {low_assignment_threshold}). "
- f"Cell segmentation likely needs refinement."
- )
-
- def xenium_general_stats_table(self, data_by_sample):
- """Add key Xenium metrics to the general statistics table"""
- headers: Dict[str, Dict[str, Any]] = {
- "num_transcripts": {
+ # Add basic metrics from metrics_summary.csv (always available)
+ self.genstat_headers["num_transcripts"] = ColumnDict(
+ {
"title": "Total Transcripts",
"description": "Total number of transcripts detected",
"scale": "YlOrRd",
"format": "{:,.0f}",
- },
- "num_cells_detected": {
+ "hidden": False,
+ }
+ )
+
+ self.genstat_headers["num_cells_detected"] = ColumnDict(
+ {
"title": "Cells",
"description": "Number of cells detected",
"scale": "Blues",
"format": "{:,.0f}",
- },
- "fraction_transcripts_assigned": {
+ "hidden": False,
+ }
+ )
+
+ self.genstat_headers["fraction_transcripts_assigned"] = ColumnDict(
+ {
"title": "Transcripts Assigned",
"description": "Fraction of transcripts assigned to cells",
"suffix": "%",
"scale": "RdYlGn",
"modify": lambda x: x * 100.0,
"max": 100.0,
- },
- "median_genes_per_cell": {
+ "hidden": False,
+ }
+ )
+
+ self.genstat_headers["median_genes_per_cell"] = ColumnDict(
+ {
"title": "Genes/Cell",
"description": "Median number of genes per cell",
"scale": "Purples",
"format": "{:,.0f}",
- },
- "fraction_transcripts_decoded_q20": {
- "title": "Q20+ Transcripts",
- "description": "Fraction of transcripts decoded with Q20+",
- "suffix": "%",
- "scale": "Greens",
- "modify": lambda x: x * 100.0,
- "max": 100.0,
- },
- "cell_area_median": {
- "title": "Median Cell",
- "description": "Median cell area",
- "suffix": " μm²",
- "scale": "Blues",
- "format": "{:,.1f}",
- "shared_key": "xenium_cell_area",
- },
- "nucleus_area_median": {
- "title": "Median Nucleus",
- "description": "Median nucleus area",
- "suffix": " μm²",
- "scale": "Oranges",
- "format": "{:,.1f}",
- "shared_key": "xenium_cell_area",
- },
- "nucleus_to_cell_area_ratio_median": {
- "title": "Nucleus/Cell",
- "description": "Median nucleus to cell area ratio",
- "scale": "Greens",
- "format": "{:.3f}",
- "max": 1.0,
- },
- }
- self.general_stats_addcols(data_by_sample, headers)
-
- def xenium_segmentation_plot(self, data_by_sample):
- """Create stacked bar plot for segmentation methods"""
- keys = {
- "segmented_cell_boundary_frac": {"name": "Boundary", "color": "#c72eba"},
- "segmented_cell_interior_frac": {"name": "Interior", "color": "#bbbf34"},
- "segmented_cell_nuc_expansion_frac": {"name": "Nuclear Expansion", "color": "#426cf5"},
- }
-
- config = {
- "id": "xenium_segmentation",
- "title": "Xenium: Cell Segmentation Method",
- "ylab": "Fraction",
- "stacking": "normal",
- "ymax": 1.0,
- "cpswitch": False,
- }
-
- return bargraph.plot(data_by_sample, keys, config)
-
- def xenium_transcript_quality_scatter_plot(self, transcript_data_by_sample):
- """Create scatter plot - handles both single and multiple samples"""
- # Prepare scatter data - create individual points for each gene from all samples
- plot_data: Dict[str, Any] = {}
-
- for sample_name, sample_data in transcript_data_by_sample.items():
- if "transcript_counts" not in sample_data:
- continue
-
- for feature, counts_data in sample_data["transcript_counts"].items():
- category, feature_id = categorize_feature(feature)
-
- if category not in plot_data:
- plot_data[category] = []
-
- # Each point is a separate data point
- # For multiple samples, include sample name in the hover text
- if len(transcript_data_by_sample) > 1:
- point_name = f"{feature_id} ({sample_name})"
- else:
- point_name = feature_id
-
- plot_data[category].append(
- {
- "x": counts_data["count"],
- "y": counts_data["mean_quality"],
- "name": point_name, # Use gene name (+ sample) for hover text
- "group": category,
- }
- )
-
- # Filter out empty categories and add colors to each point
- final_plot_data = {}
- for category, points in plot_data.items():
- if points: # Only include categories with data
- # Add color to each point in the category
- for point in points:
- point["color"] = GENE_CATS[category]["color"]
- final_plot_data[category] = points
-
- # Adjust title based on number of samples
- if len(transcript_data_by_sample) == 1:
- title = "Xenium: Gene-Specific Transcript Quality"
- else:
- title = f"Xenium: Gene-Specific Transcript Quality ({len(transcript_data_by_sample)} samples)"
-
- # Define desired category order for legend
- category_order = [
- "Pre-designed",
- "Custom",
- "Genomic Control Probe",
- "Negative Control Probe",
- "Negative Control Codeword",
- "Unassigned Codeword",
- "Deprecated Codeword",
- ]
-
- config = {
- "id": "xenium_transcript_quality_combined",
- "title": title,
- "xlab": "Total transcripts per gene",
- "ylab": "Mean calibrated quality of gene transcripts",
- "marker_size": 4,
- "marker_line_width": 0,
- "opacity": 0.75,
- "series_label": "transcripts",
- "xlog": True,
- "showlegend": True,
- "groups": category_order,
- "flat_if_very_large": False,
- }
-
- return scatter.plot(final_plot_data, config)
-
- def _create_multi_sample(self, transcript_data_by_sample):
- """Create multi-dataset line plot with categories as datasets"""
- # Prepare data for each category as a separate dataset
- # First, collect all categories that have data across samples
- all_categories = set()
- for sample_data in transcript_data_by_sample.values():
- if "transcript_counts" in sample_data:
- for feature in sample_data["transcript_counts"].keys():
- category, _ = categorize_feature(feature)
- all_categories.add(category)
-
- # Create a dataset for "all transcripts" first (combining all categories)
- datasets: Dict[str, Dict[str, Dict]] = {"All transcripts": {}}
- for cat in all_categories:
- datasets[cat] = {}
-
- for sample_name, sample_data in transcript_data_by_sample.items():
- if "transcript_counts" not in sample_data:
- continue
-
- datasets["All transcripts"][sample_name] = {}
- for cat in all_categories:
- datasets[cat][sample_name] = {}
-
- for feature, counts_data in sample_data["transcript_counts"].items():
- count = counts_data["count"]
- quality = counts_data["mean_quality"]
- datasets["All transcripts"][sample_name][count] = quality
- for cat in all_categories:
- if categorize_feature(feature)[0] == cat:
- datasets[cat][sample_name][count] = quality
-
- if not datasets:
- return None
-
- config = {
- "id": "xenium_transcript_quality_multi",
- "title": "Xenium: Transcript Quality by Category",
- "xlab": "Total transcripts per gene",
- "ylab": "Mean calibrated quality of gene transcripts",
- "data_labels": [{"name": cat} for cat in datasets.keys()],
- "xlog": True,
- }
-
- return linegraph.plot(list(datasets.values()), config)
-
- def xenium_transcript_quality_table(self, transcript_data_by_sample):
- """Create per-sample table showing mean quality for each category (samples as rows, categories as columns)"""
- if not transcript_data_by_sample:
- return None
-
- # Collect all categories across samples to create consistent columns
- all_categories = set()
- for sample_data in transcript_data_by_sample.values():
- if "category_summary" in sample_data:
- all_categories.update(sample_data["category_summary"].keys())
-
- if not all_categories:
- return None
-
- # Sort categories for consistent ordering
- sorted_categories = sorted(
- all_categories,
- key=lambda x: (
- 0
- if x == "Pre-designed"
- else 1
- if x == "Custom"
- else 2
- if x == "Genomic Control Probe"
- else 3
- if x == "Negative Control Probe"
- else 4
- if x == "Negative Control Codeword"
- else 5
- if x == "Unassigned Codeword"
- else 6
- if x == "Deprecated Codeword"
- else 7
- ),
+ "hidden": False,
+ }
)
- # Create table data: samples as rows, categories as columns
- table_data = {}
- for sample_name, sample_data in transcript_data_by_sample.items():
- if "category_summary" not in sample_data:
- continue
-
- table_data[sample_name] = {}
-
- # Add mean quality for each category
- for category in sorted_categories:
- if category in sample_data["category_summary"]:
- mean_quality = sample_data["category_summary"][category]["mean_quality"]
- table_data[sample_name][f"{category} Mean QV"] = mean_quality
- else:
- table_data[sample_name][f"{category} Mean QV"] = None
-
- # Add standard deviation for each category
- for category in sorted_categories:
- if category in sample_data["category_summary"]:
- std_quality = sample_data["category_summary"][category]["std_quality"]
- table_data[sample_name][f"{category} Std Dev"] = std_quality
- else:
- table_data[sample_name][f"{category} Std Dev"] = None
-
- if not table_data:
- return None
-
- # Create table headers for each category (both mean and std dev)
- headers: Dict[str, ColumnDict] = {}
-
- # Create consistent abbreviations for column titles
- category_abbreviations = {
- "Pre-designed": "Pre-designed",
- "Custom": "Custom",
- "Genomic Сontrol Probe": "Genomic Ctrl",
- "Negative Control Probe": "Negative Ctrl",
- "Negative Control Codeword": "Neg Codeword",
- "Unassigned Codeword": "Unassigned",
- "Deprecated Codeword": "Deprecated",
- }
-
- for category in sorted_categories:
- # Get abbreviated title for consistent column width
- abbrev_title = category_abbreviations[category]
-
- # Mean quality column
- headers[f"{category} Mean QV"] = {
- "title": f"{abbrev_title}",
- "description": f"Mean calibrated quality score (QV) for {category}",
- "scale": "Blues",
- "format": "{:.2f}",
- "suffix": "",
- "shared_key": "xenium_transcript_quality",
+ self.genstat_headers["median_transcripts_per_cell"] = ColumnDict(
+ {
+ "title": "Transcripts/Cell",
+ "description": "Median transcripts per cell",
"min": 0,
- "max": 40,
- }
-
- # Standard deviation column
- headers[f"{category} Std Dev"] = {
- "title": f"{abbrev_title} StdDev",
- "description": f"Standard deviation of quality scores for {category}",
- "scale": "Oranges",
- "format": "{:.2f}",
- "suffix": "",
- "shared_key": "xenium_transcript_quality",
+ "scale": "Greens",
+ "format": "{:,.0f}",
"hidden": True,
}
-
- return table.plot(
- table_data,
- headers,
- pconfig=TableConfig(
- id="xenium_transcript_quality_per_sample_table",
- title="Xenium: Mean Transcript Quality by Sample and Category",
- ),
)
- def xenium_cell_area_distribution_plot(self, cells_data_by_sample):
- """Create cell area distribution plot - line plot for single sample, violin plots for multiple"""
- # Check which samples have cell area data
- samples_with_areas = []
- for s_name, data in cells_data_by_sample.items():
- # Accept either pre-calculated statistics or raw values
- if ("cell_area_box_stats" in data) or ("cell_area_values" in data and data["cell_area_values"]):
- samples_with_areas.append(s_name)
-
- if not samples_with_areas:
- return None
-
- num_samples = len(samples_with_areas)
-
- if num_samples == 1:
- # Single sample: Create line plot (density) with vertical lines for mean/median
- return self._create_single_sample_area_density(cells_data_by_sample[samples_with_areas[0]])
- else:
- # Multiple samples: Create violin plots
- return self._create_multi_sample_area_violins(cells_data_by_sample, samples_with_areas)
-
- def _create_single_sample_area_density(self, cell_data):
- """Create density plot for single sample with mean/median lines"""
- if not SCIPY_AVAILABLE:
- log.warning("scipy not available, skipping density plots. Install scipy for enhanced plotting.")
- return None
-
- from scipy.stats import gaussian_kde
-
- # Skip density plots if only pre-calculated statistics are available
- if "cell_area_values" not in cell_data:
- log.info(
- "Skipping cell area density plot - using pre-calculated statistics. Density plots require raw data."
- )
- return None
-
- cell_areas = cell_data["cell_area_values"]
- if not cell_areas or len(cell_areas) < 10:
- return None
-
- # Create density estimation
- kde = gaussian_kde(cell_areas)
-
- # Create x values for density curve
- min_area = min(cell_areas)
- max_area = max(cell_areas)
- x_range = max_area - min_area
- x_vals = np.linspace(max(0, min_area - 0.1 * x_range), max_area + 0.1 * x_range, 200)
- density_vals = kde(x_vals)
-
- # Prepare data for linegraph
- density_data = {}
- for x, y in zip(x_vals, density_vals):
- density_data[float(x)] = float(y)
-
- config: Dict[str, Any] = {
- "id": "xenium_cell_area_distribution",
- "title": "Xenium: Cell Area Distribution",
- "xlab": "Cell area",
- "ylab": "Density",
- "xsuffix": " μm²",
- }
-
- # Add vertical lines for mean and median
- if "cell_area_mean" in cell_data and "cell_area_median" in cell_data:
- density_keys = [float(k) for k in density_data.keys()]
- config["x_lines"] = self._create_non_overlapping_labels(
- cell_data["cell_area_mean"],
- cell_data["cell_area_median"],
- precision=1,
- suffix=" μm²",
- data_min=min(density_keys),
- data_max=max(density_keys),
- )
-
- return linegraph.plot({"Density": density_data}, config)
-
- def _create_multi_sample_area_violins(self, cells_data_by_sample, samples_with_areas):
- """Create box plots for multiple samples using pre-calculated statistics"""
-
- # For box plots, we now provide pre-calculated statistics instead of raw data
- data = {}
-
- for s_name in samples_with_areas:
- cell_data = cells_data_by_sample[s_name]
- if "cell_area_box_stats" in cell_data:
- # Use pre-calculated box plot statistics
- data[s_name] = cell_data["cell_area_box_stats"]
-
- if not data:
- return None
-
- config = {
- "id": "xenium_cell_area_distribution",
- "title": "Xenium: Cell Area Distribution",
- "xlab": "Cell area (μm²)",
- "boxpoints": False,
- }
-
- return box.plot(data, config)
-
- def xenium_nucleus_rna_fraction_plot(self, cells_data_by_sample):
- """Create nucleus RNA fraction distribution plot - density for single sample, box plots for multiple"""
- # Check which samples have nucleus RNA fraction data
- samples_with_nucleus_data = []
- for s_name, data in cells_data_by_sample.items():
- if "nucleus_rna_fraction_box_stats" in data or (
- "nucleus_rna_fraction_values" in data and data["nucleus_rna_fraction_values"]
- ):
- samples_with_nucleus_data.append(s_name)
-
- if not samples_with_nucleus_data:
- return None
-
- num_samples = len(samples_with_nucleus_data)
-
- if num_samples == 1:
- # Single sample: Create density plot
- return self._create_single_sample_nucleus_density(cells_data_by_sample[samples_with_nucleus_data[0]])
- else:
- # Multiple samples: Create box plots
- return self._create_multi_sample_nucleus_boxes(cells_data_by_sample, samples_with_nucleus_data)
-
- def _create_single_sample_nucleus_density(self, cell_data):
- """Create density plot for single sample nucleus RNA fractions"""
- if not SCIPY_AVAILABLE:
- log.warning("scipy not available, skipping nucleus density plots. Install scipy for enhanced plotting.")
- return None
-
- from scipy import stats
-
- # Skip density plots if only pre-calculated statistics are available
- if "nucleus_rna_fraction_values" not in cell_data:
- log.info(
- "Skipping nucleus RNA fraction density plot - using pre-calculated statistics. Density plots require raw data."
- )
- return None
-
- nucleus_fractions = cell_data["nucleus_rna_fraction_values"]
- if not nucleus_fractions:
- return None
-
- # Use a more appropriate range for nucleus RNA fractions (0 to 1)
- x_range = np.linspace(0, 1, 200)
-
- # Calculate kernel density estimation
- try:
- kde = stats.gaussian_kde(nucleus_fractions)
- density = kde(x_range)
- except Exception:
- # Fallback to histogram if KDE fails
- hist, bin_edges = np.histogram(nucleus_fractions, bins=50, range=(0, 1), density=True)
- x_range = (bin_edges[:-1] + bin_edges[1:]) / 2
- density = hist
-
- # Trim long tail: find cutoff where all values above X are below 1% of max
- max_density = np.max(density)
- threshold = max_density * 0.01 # 1% of max
-
- # Find the last point where density is above threshold
- last_significant_point = len(density) - 1
- for i in range(len(density) - 1, -1, -1):
- if density[i] >= threshold:
- last_significant_point = i
- break
-
- # Trim the data to only include up to the last significant point
- if last_significant_point < len(density) - 1:
- x_range = x_range[: last_significant_point + 1]
- density = density[: last_significant_point + 1]
-
- # Create the density plot data
- data = {}
- data["Nucleus RNA Fraction Density"] = {str(x): y for x, y in zip(x_range, density)}
-
- # Note: Could add statistical lines (mean/median) in future if desired
-
- config = {
- "id": "xenium_nucleus_rna_fraction_single",
- "title": "Xenium: Fraction of Transcripts in Nucleus",
- "xlab": "Distribution of the fraction of transcripts found in the nucleus across cells",
- "ylab": "Density",
- "data_labels": [
- {"name": "Density", "ylab": "Density"},
- ],
- }
-
- # Add vertical lines for mean and median
- mean_fraction = np.nanmean(nucleus_fractions)
- median_fraction = np.nanmedian(nucleus_fractions)
-
- density_keys = [float(k) for k in data["Nucleus RNA Fraction Density"].keys()]
- config["x_lines"] = self._create_non_overlapping_labels(
- mean_fraction,
- median_fraction,
- precision=3,
- data_min=min(density_keys),
- data_max=max(density_keys),
- )
-
- plot = linegraph.plot(data, config)
-
- return plot
-
- def _create_multi_sample_nucleus_boxes(self, cells_data_by_sample, samples_with_nucleus_data):
- """Create box plots for multiple samples using pre-calculated statistics"""
-
- # For box plots, we now provide pre-calculated statistics instead of raw data
- data = {}
-
- for s_name in samples_with_nucleus_data:
- cell_data = cells_data_by_sample[s_name]
- if "nucleus_rna_fraction_box_stats" in cell_data:
- # Use pre-calculated box plot statistics
- data[s_name] = cell_data["nucleus_rna_fraction_box_stats"]
- elif "nucleus_rna_fraction_values" in cell_data:
- # Fallback to raw data if statistics not available (backward compatibility)
- nucleus_fractions = cell_data["nucleus_rna_fraction_values"]
- if nucleus_fractions:
- data[s_name] = [float(fraction) for fraction in nucleus_fractions]
-
- if not data:
- return None
-
- config = {
- "id": "xenium_nucleus_rna_fraction_multi",
- "title": "Xenium: Fraction of Transcripts in Nucleus",
- "xlab": "Distribution of the fraction of transcripts found in the nucleus across cells",
- "boxpoints": False,
- }
-
- return box.plot(data, config)
-
- def xenium_nucleus_cell_area_ratio_plot(self, cells_data_by_sample):
- """Create nucleus-to-cell area ratio distribution plot - density for single sample, box plots for multiple"""
- # Check which samples have nucleus-to-cell area ratio data
- samples_with_ratio_data = []
- for s_name, data in cells_data_by_sample.items():
- if "nucleus_to_cell_area_ratio_box_stats" in data or (
- "nucleus_to_cell_area_ratio_values" in data and data["nucleus_to_cell_area_ratio_values"]
- ):
- samples_with_ratio_data.append(s_name)
-
- if not samples_with_ratio_data:
- return None
-
- num_samples = len(samples_with_ratio_data)
-
- if num_samples == 1:
- # Single sample: Create density plot
- return self._create_single_sample_ratio_density(cells_data_by_sample[samples_with_ratio_data[0]])
- else:
- # Multiple samples: Create box plots
- return self._create_multi_sample_ratio_boxes(cells_data_by_sample, samples_with_ratio_data)
-
- def _create_single_sample_ratio_density(self, cell_data):
- """Create density plot for single sample nucleus-to-cell area ratios"""
- if not SCIPY_AVAILABLE:
- log.warning("scipy not available, skipping plots. Install scipy for enhanced plotting.")
- return None
-
- from scipy import stats
-
- # Skip density plots if only pre-calculated statistics are available
- if "nucleus_to_cell_area_ratio_values" not in cell_data:
- log.info(
- "Skipping nucleus-to-cell area ratio density plot - using pre-calculated statistics. Density plots require raw data."
- )
- return None
-
- ratio_values = cell_data["nucleus_to_cell_area_ratio_values"]
- if not ratio_values:
- return None
-
- # Use a reasonable range for nucleus-to-cell area ratios (0 to 1.0)
- x_range = np.linspace(0, 1.0, 200)
-
- # Calculate kernel density estimation
- try:
- kde = stats.gaussian_kde(ratio_values)
- density = kde(x_range)
- except Exception:
- # Fallback to histogram if KDE fails
- hist, bin_edges = np.histogram(ratio_values, bins=50, range=(0, 1.0), density=True)
- x_range = (bin_edges[:-1] + bin_edges[1:]) / 2
- density = hist
-
- # Create the density plot data
- data = {}
- data["Nucleus-to-Cell Area Ratio Density"] = {str(x): y for x, y in zip(x_range, density)}
-
- config = {
- "id": "xenium_nucleus_cell_area_ratio_single",
- "title": "Xenium: Nucleus to Cell Area Distribution",
- "xlab": "Nucleus-to-cell area ratio",
- "ylab": "Density",
- "data_labels": [
- {"name": "Density", "ylab": "Density"},
- ],
- }
-
- # Add vertical lines for mean and median
- mean_ratio = np.nanmean(ratio_values)
- median_ratio = np.nanmedian(ratio_values)
-
- density_keys = [float(k) for k in data["Nucleus-to-Cell Area Ratio Density"].keys()]
- config["x_lines"] = self._create_non_overlapping_labels(
- mean_ratio, median_ratio, precision=3, data_min=min(density_keys), data_max=max(density_keys)
- )
-
- plot = linegraph.plot(data, config)
-
- return plot
-
- def _create_multi_sample_ratio_boxes(self, cells_data_by_sample, samples_with_ratio_data):
- """Create box plots for multiple samples using pre-calculated statistics"""
-
- # For box plots, we now provide pre-calculated statistics instead of raw data
- data = {}
-
- for s_name in samples_with_ratio_data:
- cell_data = cells_data_by_sample[s_name]
- if "nucleus_to_cell_area_ratio_box_stats" in cell_data:
- # Use pre-calculated box plot statistics
- data[s_name] = cell_data["nucleus_to_cell_area_ratio_box_stats"]
- elif "nucleus_to_cell_area_ratio_values" in cell_data:
- # Fallback to raw data if statistics not available (backward compatibility)
- ratio_values = cell_data["nucleus_to_cell_area_ratio_values"]
- if ratio_values:
- data[s_name] = [float(ratio) for ratio in ratio_values]
-
- if not data:
- return None
-
- config = box.BoxPlotConfig(
- id="xenium_nucleus_cell_area_ratio_multi",
- title="Xenium: Nucleus to Cell Area Distribution",
- xlab="Nucleus-to-cell area ratio",
- boxpoints=False,
- xmin=0,
- xmax=1,
- )
-
- return box.plot(data, config)
-
- def xenium_fov_quality_plot(self, transcript_data_by_sample):
- """Create bar plot showing FoV count distribution across QV ranges"""
- # Collect median quality per FoV per sample
- fov_median_by_sample = {}
-
- for s_name, data in transcript_data_by_sample.items():
- data = transcript_data_by_sample[s_name]
- if "fov_quality_stats" in data:
- fov_median_by_sample[s_name] = {}
- fov_stats = data["fov_quality_stats"]
- for fov_name, stats in fov_stats.items():
- median_quality = stats["median_quality"]
- if median_quality is not None:
- fov_median_by_sample[s_name][fov_name] = median_quality
-
- if not fov_median_by_sample:
- return None
-
- # Define QV ranges (ordered high to low for display)
- qv_ranges = [
- ("Excellent (QV ≥ 35)", 35, float("inf")),
- ("Good (QV 30-35)", 30, 35),
- ("Fair (QV 25-30)", 25, 30),
- ("Poor (QV 20-25)", 20, 25),
- ("Low (QV < 20)", 0, 20),
- ]
-
- # Create bar plot data - count FoVs in each QV range per sample
- bar_data = {}
- for sample_name, fov_qualities in fov_median_by_sample.items():
- bar_data[sample_name] = {}
-
- # Initialize counts for each range
- for range_name, _, _ in qv_ranges:
- bar_data[sample_name][range_name] = 0
-
- # Count FoVs in each range
- for fov_name, quality in fov_qualities.items():
- for range_name, min_qv, max_qv in qv_ranges:
- if min_qv <= quality < max_qv:
- bar_data[sample_name][range_name] += 1
- break
-
- config = {
- "id": "xenium_fov_quality_ranges",
- "title": "Xenium: Field of View Quality Distribution",
- "xlab": "Quality Range",
- "ylab": "Fields of View",
- "cpswitch_c_active": False,
- "use_legend": True,
- }
-
- # Define categories with colors (grey-to-green gradient, ordered high to low)
- cats = {
- "Excellent (QV ≥ 35)": {
- "name": "Excellent (QV ≥ 35)",
- "color": "#32CD32", # Bright green for excellent quality
- },
- "Good (QV 30-35)": {
- "name": "Good (QV 30-35)",
- "color": "#90EE90", # Light green for good quality
- },
- "Fair (QV 25-30)": {
- "name": "Fair (QV 25-30)",
- "color": "#FFB6C1", # Light pink for fair quality
- },
- "Poor (QV 20-25)": {
- "name": "Poor (QV 20-25)",
- "color": "#FF8C94", # Medium pink-red for poor quality
- },
- "Low (QV < 20)": {
- "name": "Low (QV < 20)",
- "color": "#DC143C", # Dark red for low quality
- },
- }
-
- return bargraph.plot(bar_data, cats, config)
-
- def _sort_fov_names(self, fov_names):
- """Sort FoV names naturally, handling numeric components if present"""
-
- def natural_sort_key(fov_name):
- # Split on digits to handle natural sorting (e.g., fov_1, fov_2, fov_10)
- parts = re.split(r"(\d+)", str(fov_name))
- return [int(part) if part.isdigit() else part.lower() for part in parts]
-
- return sorted(fov_names, key=natural_sort_key)
-
- def xenium_transcripts_per_gene_plot(self, transcript_data_by_sample):
- """Create histogram plot showing distribution of transcripts per gene with separate lines per sample"""
- # Check if any sample has molecules per gene data
- samples_with_molecules = []
- for s_name, data in transcript_data_by_sample.items():
- if "molecules_per_gene" in data:
- samples_with_molecules.append(s_name)
-
- if not samples_with_molecules:
- return None
-
- # Determine if single or multi-sample plot
- num_samples = len(samples_with_molecules)
- if num_samples == 1:
- # Single sample: calculate noise threshold for this sample only
- s_name = samples_with_molecules[0]
- sample_data = transcript_data_by_sample[s_name]
- molecules_data = sample_data["molecules_per_gene"]
-
- # Use pre-calculated noise threshold if available
- n_mols_threshold = sample_data.get("noise_threshold")
- else:
- # Multi-sample: use pre-calculated noise thresholds
- sample_thresholds = {}
- for s_name in samples_with_molecules:
- sample_data = transcript_data_by_sample[s_name]
-
- # Use pre-calculated noise threshold if available
- threshold = sample_data.get("noise_threshold")
- sample_thresholds[s_name] = threshold
-
- n_mols_threshold = None # Keep for single-sample compatibility
-
- # Determine global bins based on all samples' data
- all_gene_counts = []
- all_non_gene_counts = []
-
- for s_name in samples_with_molecules:
- data = transcript_data_by_sample[s_name]
- molecules_data = data["molecules_per_gene"]
-
- for _, gene_info in molecules_data.items():
- count = gene_info["count"]
- if count > 0:
- if gene_info["is_gene"]:
- all_gene_counts.append(count)
- else:
- all_non_gene_counts.append(count)
-
- # Create consistent bins for all samples
- all_counts = all_gene_counts + all_non_gene_counts
- if not all_counts:
- return None
-
- min_count = max(1, min(all_counts))
- max_count = max(all_counts)
- bins = np.logspace(np.log10(min_count), np.log10(max_count), 50)
- bin_centers = (bins[:-1] + bins[1:]) / 2
-
- # Choose between single and multi-sample plots
- if num_samples == 1:
- # Single sample with noise threshold
- s_name = samples_with_molecules[0]
- sample_data = transcript_data_by_sample[s_name]
- # Create single-item threshold dict for consistency
- single_sample_thresholds = {s_name: n_mols_threshold}
- return self._create_single_sample_molecules_plot(
- sample_data, bins, bin_centers, single_sample_thresholds, s_name
- )
- else:
- # Multi-sample with per-sample thresholds
- return self._create_multi_sample_molecules_plot(
- transcript_data_by_sample, samples_with_molecules, bins, bin_centers, sample_thresholds
- )
-
- def _create_single_sample_molecules_plot(self, sample_data, bins, bin_centers, sample_thresholds, sample_name):
- """Create single plot with both Gene and Non-gene lines for single sample"""
- molecules_data = sample_data["molecules_per_gene"]
-
- # Separate counts by gene type
- gene_counts = []
- non_gene_counts = []
-
- for _, gene_info in molecules_data.items():
- count = gene_info["count"]
- if count > 0:
- if gene_info["is_gene"]:
- gene_counts.append(count)
- else:
- non_gene_counts.append(count)
-
- # Create plot data with both lines
- plot_data = {}
- all_histograms = []
-
- if gene_counts:
- gene_hist, _ = np.histogram(gene_counts, bins=bins)
- all_histograms.append(gene_hist)
- gene_line_data = {}
- for i, count in enumerate(gene_hist):
- gene_line_data[float(bin_centers[i])] = int(count)
- plot_data["Genes"] = gene_line_data
-
- if non_gene_counts:
- non_gene_hist, _ = np.histogram(non_gene_counts, bins=bins)
- all_histograms.append(non_gene_hist)
- non_gene_line_data = {}
- for i, count in enumerate(non_gene_hist):
- non_gene_line_data[float(bin_centers[i])] = int(count)
- plot_data["Non-genes"] = non_gene_line_data
-
- if not plot_data:
- return None
-
- # Trim long tail: find cutoff where all values above X are below 1% of max
- if all_histograms:
- # Get maximum value across all histograms
- max_value = max(np.max(hist) for hist in all_histograms)
- threshold = max_value * 0.01 # 1% of max
-
- # Find the last bin where any histogram has values above threshold
- last_significant_bin = len(bin_centers) - 1
- for i in range(len(bin_centers) - 1, -1, -1):
- if any(hist[i] >= threshold for hist in all_histograms):
- last_significant_bin = i
- break
-
- # Trim the data to only include up to the last significant bin
- if last_significant_bin < len(bin_centers) - 1:
- trimmed_plot_data = {}
- for dataset_name, data in plot_data.items():
- trimmed_data = {}
- for i, (x_val, y_val) in enumerate(data.items()):
- if i <= last_significant_bin:
- trimmed_data[x_val] = y_val
- trimmed_plot_data[dataset_name] = trimmed_data
- plot_data = trimmed_plot_data
-
- config: Dict[str, Any] = {
- "id": "xenium_transcripts_per_gene",
- "title": "Xenium: Distribution of Transcripts per Gene",
- "xlab": "Number of transcripts per gene",
- "ylab": "Number of features",
- "xlog": True,
- "series_label": False,
- }
-
- # Use same color for genes and controls from same sample (distinguished by line style)
- scale = mqc_colour.mqc_colour_scale("plot_defaults")
- sample_color = scale.get_colour(0, lighten=1) # Use first color for single sample
-
- n_mols_threshold = sample_thresholds.get(sample_name) if sample_thresholds else None
- threshold_text = f" (noise threshold: {n_mols_threshold:.0f})" if n_mols_threshold is not None else ""
-
- colors = {
- "Genes": sample_color,
- }
- config["colors"] = colors
-
- # Use dash_styles and hovertemplates for series styling
- if "Non-genes" in plot_data:
- colors["Non-genes"] = sample_color # Same color as genes
- config["dash_styles"] = {
- "Genes": "solid",
- "Non-genes": "dash", # Dashed line for controls
- }
- config["hovertemplates"] = {
- "Genes": f"%{{text}} %{{x}}: %{{y}}{threshold_text} ",
- "Non-genes": f"%{{text}} %{{x}}: %{{y}}{threshold_text} ",
+ self.genstat_headers["adjusted_negative_control_probe_rate"] = ColumnDict(
+ {
+ "title": "Neg Ctrl Rate",
+ "description": "Adjusted negative control probe rate",
+ "max": 0.1,
+ "min": 0,
+ "scale": "OrRd",
+ "format": "{:,.3f}",
+ "hidden": True,
}
- config["legend_groups"] = {"Genes": sample_name, "Non-genes": sample_name}
- else:
- config["hovertemplates"] = {"Genes": f"%{{text}} %{{x}}: %{{y}}{threshold_text} "}
- config["legend_groups"] = {"Genes": sample_name}
-
- # Add vertical line for noise threshold if calculated
- if n_mols_threshold is not None and n_mols_threshold > 0:
- config["x_lines"] = [
- {
- "value": n_mols_threshold,
- "color": "grey",
- "dash": "dash",
- "width": 1,
- "label": f"Noise threshold ({n_mols_threshold:.0f})",
- }
- ]
-
- return linegraph.plot(plot_data, config)
-
- def _create_multi_sample_molecules_plot(
- self, transcript_data_by_sample, samples_with_molecules, bins, bin_centers, sample_thresholds
- ):
- """Create single plot with all samples shown as separate lines, color-coded by gene type"""
- plot_data = {}
- all_histograms = []
-
- # Process each sample and separate by gene type
- for s_name in samples_with_molecules:
- data = transcript_data_by_sample[s_name]
- molecules_data = data["molecules_per_gene"]
-
- # Separate this sample's counts by gene type
- sample_gene_counts = []
- sample_non_gene_counts = []
-
- for _, gene_info in molecules_data.items():
- count = gene_info["count"]
- if count > 0:
- if gene_info["is_gene"]:
- sample_gene_counts.append(count)
- else:
- sample_non_gene_counts.append(count)
-
- # Create histograms for genes (blue lines)
- if sample_gene_counts:
- gene_hist, _ = np.histogram(sample_gene_counts, bins=bins)
- all_histograms.append(gene_hist)
- gene_line_data = {}
- for i, count in enumerate(gene_hist):
- gene_line_data[float(bin_centers[i])] = int(count)
- plot_data[f"{s_name} (genes)"] = gene_line_data
-
- # Create histograms for non-genes (black lines)
- if sample_non_gene_counts:
- non_gene_hist, _ = np.histogram(sample_non_gene_counts, bins=bins)
- all_histograms.append(non_gene_hist)
- non_gene_line_data = {}
- for i, count in enumerate(non_gene_hist):
- non_gene_line_data[float(bin_centers[i])] = int(count)
- plot_data[f"{s_name} (non-genes)"] = non_gene_line_data
-
- if not plot_data:
- return None
-
- # Trim long tail: find cutoff where all values above X are below 1% of max
- if all_histograms:
- # Get maximum value across all histograms
- max_value = max(np.max(hist) for hist in all_histograms)
- threshold = max_value * 0.01 # 1% of max
-
- # Find the last bin where any histogram has values above threshold
- last_significant_bin = len(bin_centers) - 1
- for i in range(len(bin_centers) - 1, -1, -1):
- if any(hist[i] >= threshold for hist in all_histograms):
- last_significant_bin = i
- break
-
- # Trim the data to only include up to the last significant bin
- if last_significant_bin < len(bin_centers) - 1:
- trimmed_plot_data = {}
- for dataset_name, data in plot_data.items():
- trimmed_data = {}
- for i, (x_val, y_val) in enumerate(data.items()):
- if i <= last_significant_bin:
- trimmed_data[x_val] = y_val
- trimmed_plot_data[dataset_name] = trimmed_data
- plot_data = trimmed_plot_data
-
- config: Dict[str, Any] = {
- "id": "xenium_transcripts_per_gene",
- "title": "Xenium: Distribution of Transcripts per Gene",
- "xlab": "Number of transcripts per gene",
- "ylab": "Number of features",
- "series_label": False,
- "xlog": True,
- "x_decimals": 0,
- }
-
- # Use per-sample coloring with mqc_colour plot_defaults scheme
- scale = mqc_colour.mqc_colour_scale("plot_defaults")
-
- # Group paired lines by sample name and assign colors
- sample_names = set()
- for dataset_name in plot_data.keys():
- if "(genes)" in dataset_name:
- sample_name = dataset_name.replace(" (genes)", "")
- sample_names.add(sample_name)
- elif "(non-genes)" in dataset_name:
- sample_name = dataset_name.replace(" (non-genes)", "")
- sample_names.add(sample_name)
-
- # Create color mapping for each sample
- sample_colors = {}
- for idx, sample_name in enumerate(sorted(sample_names)):
- sample_colors[sample_name] = scale.get_colour(idx, lighten=1)
-
- # Use the new parameters to style series instead of extra_series
- colors = {}
- dash_styles = {}
- hovertemplates = {}
- legend_groups = {}
-
- # Set up styling for all series using the new parameters
- for dataset_name in plot_data.keys():
- if "(genes)" in dataset_name:
- sample_name = dataset_name.replace(" (genes)", "")
- threshold = sample_thresholds.get(sample_name)
- threshold_text = f" (noise threshold: {threshold:.0f})" if threshold is not None else ""
-
- colors[dataset_name] = sample_colors[sample_name]
- dash_styles[dataset_name] = "solid" # Solid lines for genes
- hovertemplates[dataset_name] = f"%{{text}} %{{x}}: %{{y}}{threshold_text} "
- legend_groups[dataset_name] = sample_name
-
- elif "(non-genes)" in dataset_name:
- sample_name = dataset_name.replace(" (non-genes)", "")
- threshold = sample_thresholds.get(sample_name)
- threshold_text = f" (noise threshold: {threshold:.0f})" if threshold is not None else ""
-
- colors[dataset_name] = sample_colors[sample_name]
- dash_styles[dataset_name] = "dash" # Dashed lines for controls
- hovertemplates[dataset_name] = f"%{{text}} %{{x}}: %{{y}}{threshold_text} "
- legend_groups[dataset_name] = sample_name
-
- config["colors"] = colors
- config["dash_styles"] = dash_styles
- config["hovertemplates"] = hovertemplates
- config["legend_groups"] = legend_groups
-
- return linegraph.plot(plot_data, config)
-
- def calculate_noise_threshold_from_df(self, transcript_stats_df, quantile=0.99):
- """
- Calculate noise threshold directly from transcript_stats DataFrame.
- This is the most efficient version as it works on the already-processed DataFrame.
-
- Args:
- transcript_stats_df: Polars DataFrame with columns ['feature_name', 'transcript_count', 'is_gene']
- quantile: Quantile for threshold calculation (default 0.99)
-
- Returns:
- Float threshold value or None if insufficient data
- """
- # Filter for negative control features using polars
- neg_controls = transcript_stats_df.filter(
- (~pl.col("is_gene")) & pl.col("feature_name").str.starts_with("NegControl")
- )
-
- # Get counts > 0 for negative controls
- neg_control_counts = neg_controls.filter(pl.col("transcript_count") > 0)["transcript_count"].to_list()
-
- if len(neg_control_counts) < 3: # Need at least 3 data points for meaningful statistics
- return None
-
- if not SCIPY_AVAILABLE:
- # Fallback to simple percentile if scipy not available
- log.warning("scipy not available, falling back to simple percentile for noise threshold")
- return np.percentile(neg_control_counts, quantile * 100)
-
- # Calculate upper bound using quantile
- from scipy.stats import norm
-
- # Calculate threshold using log-space statistics (similar to notebook)
- log_counts = np.log10(neg_control_counts)
-
- # Use median absolute deviation as robust estimate of standard deviation
- median_log = np.median(log_counts)
- mad = np.median(np.abs(log_counts - median_log))
- # Convert MAD to standard deviation equivalent (normal distribution scaling factor)
- std_log = mad * 1.4826
-
- z_score = norm.ppf(quantile)
- threshold_log = median_log + z_score * std_log
-
- threshold = 10**threshold_log
- return threshold
-
- def xenium_cell_distributions_combined_plot(self, cells_data_by_sample):
- """Create combined plot for transcripts and detected genes per cell distributions"""
- # Check if we have data for either transcripts or genes
- samples_with_transcript_counts = {}
- samples_with_gene_counts = {}
-
- for s_name, data in cells_data_by_sample.items():
- # Check for pre-calculated statistics first, fall back to raw values
- if data and "total_counts_box_stats" in data:
- samples_with_transcript_counts[s_name] = data["total_counts_box_stats"]
- elif data and "total_counts_values" in data and data["total_counts_values"]:
- samples_with_transcript_counts[s_name] = data["total_counts_values"]
-
- if data and "detected_genes_stats" in data:
- samples_with_gene_counts[s_name] = data["detected_genes_stats"]
- elif data and "detected_genes_values" in data and data["detected_genes_values"]:
- samples_with_gene_counts[s_name] = data["detected_genes_values"]
-
- # If neither dataset is available, return None
- if not samples_with_transcript_counts and not samples_with_gene_counts:
- return None
-
- num_samples = max(len(samples_with_transcript_counts), len(samples_with_gene_counts))
-
- if num_samples == 1:
- # Single sample: Create combined density plots
- return self._create_single_sample_combined_density(samples_with_transcript_counts, samples_with_gene_counts)
- else:
- # Multiple samples: Create combined box plots
- return self._create_multi_sample_combined_boxes(samples_with_transcript_counts, samples_with_gene_counts)
-
- def _create_single_sample_combined_density(self, samples_with_transcript_counts, samples_with_gene_counts):
- """Create single sample combined density plot with transcripts (blue) and genes (grey) on the same plot"""
- plot_data = {}
-
- # Store raw values for intelligent line positioning
- raw_transcript_values = None
- raw_gene_values = None
-
- # Handle transcripts per cell data
- if samples_with_transcript_counts:
- _, transcript_values = next(iter(samples_with_transcript_counts.items()))
- # Skip density plots for pre-calculated statistics (use box plots instead)
- if isinstance(transcript_values, dict) and "min" in transcript_values:
- log.info(
- "Skipping density plot for transcripts per cell - using pre-calculated statistics. Density plots require raw data."
- )
- return None
- raw_transcript_values = transcript_values
- transcript_values = np.array(transcript_values)
-
- if SCIPY_AVAILABLE:
- from scipy.stats import gaussian_kde
-
- kde = gaussian_kde(transcript_values)
- x_min, x_max = transcript_values.min(), transcript_values.max()
- x_range = np.linspace(x_min, x_max, 1000)
- density = kde(x_range)
-
- # Add to plot data
- transcripts_data = {}
- for x, y in zip(x_range, density):
- transcripts_data[float(x)] = float(y)
- plot_data["Transcripts per cell"] = transcripts_data
-
- else:
- log.warning("scipy not available, falling back to histogram")
- # Fallback to histogram if scipy not available
- bins = min(50, len(transcript_values) // 20)
- hist, bin_edges = np.histogram(transcript_values, bins=bins)
- bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
-
- transcripts_data = {}
- for x, y in zip(bin_centers, hist):
- transcripts_data[float(x)] = float(y)
- plot_data["Transcripts per cell"] = transcripts_data
-
- # Handle detected genes per cell data
- if samples_with_gene_counts:
- _, gene_counts = next(iter(samples_with_gene_counts.items()))
- # Skip density plots for pre-calculated statistics
- if isinstance(gene_counts, dict) and "min" in gene_counts:
- log.info(
- "Skipping density plot for detected genes per cell - using pre-calculated statistics. Density plots require raw data."
- )
- # For mixed cases, only show available density plots
- if not raw_transcript_values:
- return None
- else:
- raw_gene_values = gene_counts
-
- gene_counts = np.array(gene_counts)
-
- if SCIPY_AVAILABLE:
- from scipy.stats import gaussian_kde
-
- kde = gaussian_kde(gene_counts)
- x_min, x_max = gene_counts.min(), gene_counts.max()
- x_range = np.linspace(x_min, x_max, 1000)
- density = kde(x_range)
-
- # Add to plot data with dataset identifier
- genes_data = {}
- for x, y in zip(x_range, density):
- genes_data[float(x)] = float(y)
- plot_data["Detected genes per cell"] = genes_data
-
- else:
- log.warning("scipy not available, falling back to histogram")
- # Fallback to histogram if scipy not available
- bins = min(50, len(gene_counts) // 20)
- hist, bin_edges = np.histogram(gene_counts, bins=bins)
- bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
-
- genes_data = {}
- for x, y in zip(bin_centers, hist):
- genes_data[float(x)] = float(y)
- plot_data["Detected genes per cell"] = genes_data
-
- if not plot_data:
- return None
-
- config = {
- "id": "xenium_cell_distributions_combined",
- "title": "Xenium: Distribution of Transcripts per Cell",
- "xlab": "Number per cell",
- "ylab": "Density",
- "smooth_points": 100,
- }
-
- # Add color configuration
- colors = {"Transcripts per cell": "#7cb5ec", "Detected genes per cell": "#434348"}
- config["colors"] = colors
-
- # Add all mean/median lines with intelligent overlap prevention
- combined_lines = self._create_non_overlapping_combined_lines(
- transcript_values=raw_transcript_values, gene_values=raw_gene_values, plot_data=plot_data
)
- if combined_lines:
- config["x_lines"] = combined_lines
-
- return linegraph.plot(plot_data, config)
-
- def _create_multi_sample_combined_boxes(self, samples_with_transcript_counts, samples_with_genes_counts):
- """Create multi-sample combined box plots for transcripts and genes per cell using pre-calculated statistics"""
-
- plot_data = []
- data_labels = []
-
- # Add transcripts per cell data (prefer statistics over raw values)
- if samples_with_transcript_counts:
- transcripts_data = {}
- for s_name, transcript_counts_stats in samples_with_transcript_counts.items():
- transcripts_data[s_name] = transcript_counts_stats
- plot_data.append(transcripts_data)
- data_labels.append({"name": "Transcripts per Cell", "ylab": "Transcripts per cell"})
-
- # Add detected genes per cell data (prefer statistics over raw values)
- if samples_with_genes_counts:
- genes_data = {}
- for s_name, gene_count_stats in samples_with_genes_counts.items():
- genes_data[s_name] = gene_count_stats
- plot_data.append(genes_data)
- data_labels.append({"name": "Detected Genes per Cell", "ylab": "Detected genes per cell"})
-
- config = {
- "id": "xenium_cell_distributions_combined",
- "title": "Xenium: Distribution of Transcripts per Cell",
- "boxpoints": False,
- "xlab": "Transcripts per cell",
- "data_labels": data_labels,
- }
- return box.plot(plot_data, config)
-
- def xenium_transcripts_per_cell_plot(self, cells_data_by_sample):
- """Create transcripts per cell distribution plot"""
- # Filter samples with transcript count data
- samples_with_transcripts = {}
- for s_name, data in cells_data_by_sample.items():
- if data and "total_counts_values" in data and data["total_counts_values"]:
- samples_with_transcripts[s_name] = data["total_counts_values"]
-
- if not samples_with_transcripts:
- return None
-
- num_samples = len(samples_with_transcripts)
-
- if num_samples == 1:
- # Single sample: Create density plot
- return self._create_single_sample_transcripts_density(samples_with_transcripts)
- else:
- # Multiple samples: Create box plots
- return self._create_multi_sample_transcripts_boxes(samples_with_transcripts)
-
- def _create_single_sample_transcripts_density(self, samples_with_transcripts):
- """Create single sample transcripts per cell density plot"""
- s_name, transcript_values = next(iter(samples_with_transcripts.items()))
-
- # Create kernel density estimation
- if SCIPY_AVAILABLE:
- from scipy.stats import gaussian_kde
-
- transcript_values = np.array(transcript_values)
- kde = gaussian_kde(transcript_values)
-
- # Create x range for density plot
- x_min, x_max = transcript_values.min(), transcript_values.max()
- x_range = np.linspace(x_min, x_max, 1000)
- density = kde(x_range)
-
- # Create plot data
- plot_data = {s_name: {}}
- for x, y in zip(x_range, density):
- plot_data[s_name][float(x)] = float(y)
-
- config = {
- "id": "xenium_transcripts_per_cell_single",
- "title": "Xenium: Distribution of Transcripts per Cell",
- "xlab": "Number of transcripts per cell",
- "ylab": "Density",
- "smooth_points": 100,
- }
-
- # Add vertical lines for mean and median
- mean_transcripts = np.mean(transcript_values)
- median_transcripts = np.median(transcript_values)
-
- config["x_lines"] = self._create_non_overlapping_labels(
- mean_transcripts, median_transcripts, data_min=x_min, data_max=x_max
- )
-
- return linegraph.plot(plot_data, config)
-
- else:
- log.warning("scipy not available, falling back to histogram")
- # Fallback to histogram if scipy not available
- bins = min(50, len(transcript_values) // 20)
- hist, bin_edges = np.histogram(transcript_values, bins=bins)
- bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
-
- plot_data = {s_name: {}}
- for x, y in zip(bin_centers, hist):
- plot_data[s_name][float(x)] = float(y)
-
- config = {
- "id": "xenium_transcripts_per_cell_single",
- "title": "Xenium: Distribution of Transcripts per Cell",
- "xlab": "Number of transcripts per cell",
- "ylab": "Number of cells",
- }
-
- # Add vertical lines for mean and median
- mean_transcripts = np.mean(transcript_values)
- median_transcripts = np.median(transcript_values)
-
- config["x_lines"] = self._create_non_overlapping_labels( # type: ignore
- mean_transcripts,
- median_transcripts,
- data_min=np.min(transcript_values),
- data_max=np.max(transcript_values),
- )
-
- return linegraph.plot(plot_data, config)
-
- def _create_multi_sample_transcripts_boxes(self, samples_with_transcripts):
- """Create multi-sample transcripts per cell box plots"""
-
- # Prepare data for box plot
- plot_data = {}
- for s_name, transcript_values in samples_with_transcripts.items():
- plot_data[s_name] = transcript_values
-
- config = {
- "id": "xenium_transcripts_per_cell_multi",
- "title": "Xenium: Distribution of Transcripts per Cell",
- "ylab": "Number of transcripts per cell",
- "boxpoints": False,
- }
-
- return box.plot(plot_data, config)
-
- def xenium_detected_genes_per_cell_plot(self, cells_data_by_sample):
- """Create detected genes per cell distribution plot"""
- # Filter samples with detected genes data
- samples_with_transcript_counts = {}
- for s_name, data in cells_data_by_sample.items():
- if data and "gene_transcript_counts_values" in data and data["gene_transcript_counts_values"]:
- samples_with_transcript_counts[s_name] = data["gene_transcript_counts_values"]
-
- if not samples_with_transcript_counts:
- return None
-
- num_samples = len(samples_with_transcript_counts)
-
- if num_samples == 1:
- # Single sample: Create density plot
- return self._create_single_sample_transcript_counts_density(samples_with_transcript_counts)
- else:
- # Multiple samples: Create box plots
- return self._create_multi_sample_transcript_counts_boxes(samples_with_transcript_counts)
-
- def _create_single_sample_transcript_counts_density(self, samples_with_transcript_counts):
- """Create single sample detected genes per cell density plot"""
- s_name, gene_values = next(iter(samples_with_transcript_counts.items()))
-
- # Create kernel density estimation
- if SCIPY_AVAILABLE:
- from scipy.stats import gaussian_kde
-
- gene_values = np.array(gene_values)
- kde = gaussian_kde(gene_values)
-
- # Create x range for density plot
- x_min, x_max = gene_values.min(), gene_values.max()
- x_range = np.linspace(x_min, x_max, 1000)
- density = kde(x_range)
-
- # Create plot data
- plot_data = {s_name: {}}
- for x, y in zip(x_range, density):
- plot_data[s_name][float(x)] = float(y)
-
- config = {
- "id": "xenium_detected_genes_per_cell_single",
- "title": "Xenium: Distribution of Detected Genes per Cell",
- "xlab": "Detected genes per cell",
- "ylab": "Density",
- "smooth_points": 100,
- }
-
- return linegraph.plot(plot_data, config)
-
- else:
- log.warning("scipy not available, falling back to histogram")
- # Fallback to histogram if scipy not available
- bins = min(50, len(gene_values) // 20)
- hist, bin_edges = np.histogram(gene_values, bins=bins)
- bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
-
- plot_data = {s_name: {}}
- for x, y in zip(bin_centers, hist):
- plot_data[s_name][float(x)] = float(y)
-
- config = {
- "id": "xenium_detected_genes_per_cell_single",
- "title": "Xenium: Distribution of Detected Genes per Cell",
- "xlab": "Detected genes per cell",
- "ylab": "Number of cells",
- }
-
- return linegraph.plot(plot_data, config)
-
- def _create_multi_sample_transcript_counts_boxes(self, samples_with_transcript_counts):
- """Create multi-sample detected genes per cell box plots"""
-
- # Prepare data for box plot
- plot_data = {}
- for s_name, gene_values in samples_with_transcript_counts.items():
- plot_data[s_name] = gene_values
-
- config = {
- "id": "xenium_detected_genes_per_cell_multi",
- "title": "Xenium: Distribution of Detected Genes per Cell",
- "ylab": "Detected genes per cell",
- "boxpoints": False,
- }
-
- return box.plot(plot_data, config)
-
- def parse_cell_feature_matrix_h5(self, f):
- """Parse cell_feature_matrix.h5 file to calculate detected genes per cell"""
- if not SCANPY_AVAILABLE:
- log.warning(
- "scanpy is not available. Cannot process cell_feature_matrix.h5 files. Install scanpy to enable detected genes per cell calculation."
- )
- return None
-
- try:
- # Construct full file path
- file_path = Path(f["root"]) / f["fn"]
-
- # Read H5 file using scanpy
- adata = sc.read_10x_h5(str(file_path))
-
- # Calculate detected genes per cell (number of non-zero genes per cell)
- # This matches the notebook's approach: (ad.X != 0).sum(axis=1).A1
- n_genes_per_cell = (adata.X != 0).sum(axis=1).A1
-
- result = {}
-
- # Calculate statistics for detected genes per cell (similar to transcript_counts processing)
- if len(n_genes_per_cell) > 0:
- detected_genes_stats = {
- "min": float(np.min(n_genes_per_cell)),
- "q1": float(np.percentile(n_genes_per_cell, 25)),
- "median": float(np.median(n_genes_per_cell)),
- "q3": float(np.percentile(n_genes_per_cell, 75)),
- "max": float(np.max(n_genes_per_cell)),
- "mean": float(np.mean(n_genes_per_cell)),
- "count": len(n_genes_per_cell),
- }
-
- # Store as gene_transcript_counts_box_stats to replace the current implementation
- result["detected_genes_stats"] = detected_genes_stats
-
- # Also store raw values if needed for single-sample density plots
- result["detected_genes_values"] = n_genes_per_cell.tolist()
-
- log.info(f"Processed {file_path}: {len(n_genes_per_cell)} cells, {adata.n_vars} genes")
- log.info(
- f"Detected genes per cell - mean: {detected_genes_stats['mean']:.1f}, median: {detected_genes_stats['median']:.1f}"
- )
-
- return result
+ def xenium_general_stats_table(self):
+ """Add key Xenium metrics to the general statistics table"""
- except Exception as e:
- log.warning(f"Failed to process {f.get('fn', 'cell_feature_matrix.h5')}: {str(e)}")
- return None
+ self.general_stats_addcols(self.data_by_sample, self.genstat_headers)
diff --git a/multiqc/multiqc.py b/multiqc/multiqc.py
index ca36498e88..340365cf53 100644
--- a/multiqc/multiqc.py
+++ b/multiqc/multiqc.py
@@ -371,7 +371,7 @@
"development",
is_flag=True,
default=None,
- help="Development mode. Do not compress and minimise JS, export uncompressed plot data",
+ help="Development mode. Do not inline JS and CSS, export uncompressed plot data",
)
@click.option(
"--pdf",
@@ -612,6 +612,13 @@ def run(
"give warnings if anything is not optimally configured in a module or a template."
)
+ # Load template early to apply config overrides before modules run
+ template_mod = config.avail_templates[config.template].load()
+ if hasattr(template_mod, "template_dark_mode"):
+ config.template_dark_mode = template_mod.template_dark_mode
+ if hasattr(template_mod, "plot_font_family"):
+ config.plot_font_family = template_mod.plot_font_family
+
report.multiqc_command = " ".join(sys.argv)
logger.debug(f"Command used: {report.multiqc_command}")
diff --git a/multiqc/plots/bargraph.py b/multiqc/plots/bargraph.py
index 63a8314908..7ffe8934b8 100644
--- a/multiqc/plots/bargraph.py
+++ b/multiqc/plots/bargraph.py
@@ -5,7 +5,7 @@
import logging
import math
from collections import OrderedDict, defaultdict
-from typing import Any, Dict, List, Literal, Mapping, NewType, Optional, Sequence, Tuple, TypedDict, Union, cast
+from typing import Any, Dict, List, Literal, Mapping, NewType, Optional, Sequence, Set, Tuple, TypedDict, Union, cast
import numpy as np
import plotly.graph_objects as go # type: ignore
@@ -36,6 +36,7 @@
CatName = NewType("CatName", str)
CatNameT = Union[CatName, str]
InputDatasetT = Union[Mapping[SampleName, Mapping[CatName, Any]], Mapping[str, Mapping[str, Any]]]
+SampleGroupEntry = List[str] # [sample_name, offset_group]
class CatConf(ValidatedConfig):
@@ -67,6 +68,7 @@ class BarPlotConfig(PConfig):
use_legend: Optional[bool] = None
suffix: Optional[str] = None
lab_format: Optional[str] = None
+ sample_groups: Optional[Dict[str, List[SampleGroupEntry]]] = None
def __init__(self, path_in_cfg: Optional[Tuple[str, ...]] = None, **data):
if "suffix" in data:
@@ -76,6 +78,16 @@ def __init__(self, path_in_cfg: Optional[Tuple[str, ...]] = None, **data):
data["ylab_format"] = data["lab_format"]
del data["lab_format"]
+ # Validate sample_groups structure
+ if "sample_groups" in data and data["sample_groups"] is not None:
+ for group_name, entries in data["sample_groups"].items():
+ for entry in entries:
+ if not isinstance(entry, list) or len(entry) != 2:
+ raise ValueError(
+ f"sample_groups['{group_name}'] entries must be [sample_name, offset_group] lists, "
+ f"got: {entry!r}"
+ )
+
super().__init__(path_in_cfg=path_in_cfg or ("barplot",), **data)
@@ -147,9 +159,56 @@ def _cluster_samples(data: DatasetT, cats: Dict[CatName, Any], method: str = "co
return sample_names
+def _reorder_by_groups(
+ datasets: List[DatasetT],
+ sample_groups: Dict[str, List[List[str]]],
+) -> Tuple[List[DatasetT], List[List[str]], List[Dict[str, str]]]:
+ """
+ Reorder samples according to groups and generate group labels for multicategory axis.
+
+ Returns:
+ Tuple of (reordered datasets, group labels per dataset, offset groups per dataset)
+ """
+ new_datasets: List[DatasetT] = []
+ group_labels_per_ds: List[List[str]] = []
+ offset_groups_per_ds: List[Dict[str, str]] = []
+
+ for dataset in datasets:
+ new_dataset: DatasetT = {}
+ group_labels: List[str] = []
+ offset_groups: Dict[str, str] = {}
+ grouped_samples: Set[SampleName] = set()
+
+ for group_label, group_samples in sample_groups.items():
+ for sample_name_str, offset_group in group_samples:
+ sample_name = SampleName(sample_name_str)
+ if sample_name in dataset:
+ new_dataset[sample_name] = dataset[sample_name]
+ group_labels.append(group_label)
+ offset_groups[sample_name_str] = offset_group
+ grouped_samples.add(sample_name)
+ else:
+ logger.debug(f"Sample '{sample_name_str}' in sample_groups not found in dataset")
+
+ ungrouped = [s for s in dataset.keys() if s not in grouped_samples]
+ if ungrouped:
+ for s in ungrouped:
+ new_dataset[s] = dataset[s]
+ group_labels.append("Other")
+ offset_groups[str(s)] = str(s)
+
+ new_datasets.append(new_dataset)
+ group_labels_per_ds.append(group_labels)
+ offset_groups_per_ds.append(offset_groups)
+
+ return new_datasets, group_labels_per_ds, offset_groups_per_ds
+
+
class BarPlotInputData(NormalizedPlotInputData[BarPlotConfig]):
data: List[DatasetT]
cats: List[Dict[CatName, CatConf]]
+ group_labels: Optional[List[List[str]]] = None
+ offset_groups: Optional[List[Dict[str, str]]] = None
def is_empty(self) -> bool:
return len(self.data) == 0 or all(len(ds) == 0 for ds in self.data)
@@ -167,6 +226,15 @@ def create(
"""
pconf = cast(BarPlotConfig, BarPlotConfig.from_pconfig_dict(pconfig))
+ # If sample_groups is provided, disable sort_samples and cluster_samples to preserve group order
+ if pconf.sample_groups is not None:
+ if pconf.sort_samples:
+ logger.debug("Disabling sort_samples because sample_groups is set")
+ pconf.sort_samples = False
+ if pconf.cluster_samples:
+ logger.debug("Disabling cluster_samples because sample_groups is set")
+ pconf.cluster_samples = False
+
# Given one dataset - turn it into a list
raw_datasets: List[DatasetT]
if isinstance(data, Sequence):
@@ -264,12 +332,22 @@ def create(
continue
filtered_datasets[ds_idx][sample_name] = filtered_val_by_cat
+ # Reorder samples by groups and generate group labels for multicategory axis
+ group_labels_per_ds: Optional[List[List[str]]] = None
+ offset_groups_per_ds: Optional[List[Dict[str, str]]] = None
+ if pconf.sample_groups:
+ filtered_datasets, group_labels_per_ds, offset_groups_per_ds = _reorder_by_groups(
+ filtered_datasets, pconf.sample_groups
+ )
+
return BarPlotInputData(
anchor=plot_anchor(pconf),
plot_type=PlotType.BAR,
pconfig=pconf,
data=filtered_datasets,
cats=categories_per_ds,
+ group_labels=group_labels_per_ds,
+ offset_groups=offset_groups_per_ds,
creation_date=report.creation_date,
)
@@ -489,6 +567,8 @@ class Dataset(BaseDataset):
samples: List[str]
cats_clustered: Optional[List[Category]] = None
samples_clustered: Optional[List[str]] = None
+ group_labels: Optional[List[str]] = None
+ offset_groups: Optional[Dict[str, str]] = None
def sample_names(self) -> List[SampleName]:
return [SampleName(sample) for sample in self.samples]
@@ -502,9 +582,14 @@ def create(
cluster_method: str = "complete",
original_data: Optional[DatasetT] = None,
original_cats: Optional[Dict[CatName, Any]] = None,
+ group_labels: Optional[List[str]] = None,
+ offset_groups: Optional[Dict[str, str]] = None,
) -> "Dataset":
# Need to reverse samples as the bar plot will show them reversed
samples = list(reversed(samples))
+ # Also reverse group_labels to match
+ if group_labels is not None:
+ group_labels = list(reversed(group_labels))
fixed_cats: List[Category] = []
for input_cat in cats:
if "name" not in input_cat:
@@ -602,6 +687,8 @@ def create(
samples=samples,
cats_clustered=cats_clustered,
samples_clustered=samples_clustered,
+ group_labels=group_labels,
+ offset_groups=offset_groups,
)
return dataset
@@ -742,6 +829,8 @@ def from_inputs(inputs: BarPlotInputData) -> Union["BarPlot", str, None]:
anchor=inputs.anchor,
original_data=inputs.data,
original_cats=inputs.cats,
+ group_labels=inputs.group_labels,
+ offset_groups=inputs.offset_groups,
)
@staticmethod
@@ -752,6 +841,8 @@ def create(
anchor: Anchor,
original_data: Optional[List[DatasetT]] = None,
original_cats: Optional[List[Dict[CatName, Any]]] = None,
+ group_labels: Optional[List[List[str]]] = None,
+ offset_groups: Optional[List[Dict[str, str]]] = None,
) -> "BarPlot":
"""
:param cats_lists: each dataset is a list of dicts with the keys: {name, color, data},
@@ -761,6 +852,7 @@ def create(
:param samples_lists: list of lists of bar names (that is, sample names). Similarly,
each outer list will correspond to a separate tab.
:param pconfig: Plot configuration dictionary
+ :param group_labels: Optional list of group labels per dataset for multicategory axis
"""
if len(cats_lists) != len(samples_lists):
raise ValueError("Number of datasets and samples lists do not match")
@@ -784,6 +876,8 @@ def create(
cluster_method=pconfig.cluster_method,
original_data=original_data[idx] if original_data and idx < len(original_data) else None,
original_cats=original_cats[idx] if original_cats and idx < len(original_cats) else None,
+ group_labels=group_labels[idx] if group_labels and idx < len(group_labels) else None,
+ offset_groups=offset_groups[idx] if offset_groups and idx < len(offset_groups) else None,
)
for idx, (d, cats, samples) in enumerate(zip(model.datasets, cats_lists, samples_lists))
]
@@ -809,12 +903,21 @@ def create(
legend_height=legend_height,
)
- model.layout.update(
- height=height,
- barmode=barmode,
- bargroupgap=0,
- bargap=0.2,
- yaxis=dict(
+ # Check if any dataset uses group_labels (multicategory axis)
+ uses_multicategory = any(ds.group_labels for ds in model.datasets)
+
+ # Configure yaxis based on whether we're using multicategory
+ if uses_multicategory:
+ yaxis_config = dict(
+ showgrid=False,
+ automargin=True, # to make sure there is enough space for ticks labels
+ title=None,
+ hoverformat=model.layout.xaxis.hoverformat,
+ ticksuffix=model.layout.xaxis.ticksuffix,
+ # For multicategory, don't set type or categoryorder - let Plotly auto-detect
+ )
+ else:
+ yaxis_config = dict(
showgrid=False,
categoryorder="trace", # keep sample order
automargin=True, # to make sure there is enough space for ticks labels
@@ -823,7 +926,14 @@ def create(
ticksuffix=model.layout.xaxis.ticksuffix,
# Prevent JavaScript from automatically parsing categorical values as numbers:
type="category",
- ),
+ )
+
+ model.layout.update(
+ height=height,
+ barmode=barmode,
+ bargroupgap=0,
+ bargap=0.2,
+ yaxis=yaxis_config,
xaxis=dict(
title=dict(text=model.layout.yaxis.title.text),
hoverformat=model.layout.yaxis.hoverformat,
@@ -839,10 +949,11 @@ def create(
# the legend, so reversing the legend to match it:
traceorder="normal" if barmode != "group" else "reversed",
),
- hovermode="y unified",
+ # Use "closest" for multicategory to show only the hovered bar, otherwise "y unified"
+ hovermode="closest" if uses_multicategory else "y unified",
hoverlabel=dict(
- bgcolor="rgba(255, 255, 255, 0.8)",
- font=dict(color="black"),
+ bgcolor="white",
+ font=dict(color="rgba(60,60,60,1)"),
),
showlegend=pconfig.use_legend if pconfig.use_legend is not None else True,
)
@@ -879,17 +990,33 @@ def create(
if maxallowed is None:
maxallowed = xmax_cnt
- dataset.layout.update(
- yaxis=dict(
+ # For multicategory, use minimal yaxis config without numeric settings
+ if dataset.group_labels:
+ yaxis_update = dict(
title=None,
hoverformat=dataset.layout["xaxis"]["hoverformat"],
ticksuffix=dataset.layout["xaxis"]["ticksuffix"],
- ),
+ # Skip autorangeoptions for multicategory - they don't apply to categorical axes
+ )
+ else:
+ yaxis_update = dict(
+ title=None,
+ hoverformat=dataset.layout["xaxis"]["hoverformat"],
+ ticksuffix=dataset.layout["xaxis"]["ticksuffix"],
+ autorangeoptions=dataset.layout["xaxis"].get(
+ "autorangeoptions",
+ dict(clipmin=None, clipmax=None, minallowed=None, maxallowed=None),
+ ),
+ )
+ dataset.layout.update(
+ yaxis=yaxis_update,
xaxis=dict(
title=dict(text=dataset.layout["yaxis"]["title"]["text"]),
hoverformat=dataset.layout["yaxis"]["hoverformat"],
ticksuffix=dataset.layout["yaxis"]["ticksuffix"],
autorangeoptions=dict(
+ clipmin=dataset.layout["yaxis"].get("autorangeoptions", {}).get("clipmin"),
+ clipmax=dataset.layout["yaxis"].get("autorangeoptions", {}).get("clipmax"),
minallowed=minallowed,
maxallowed=maxallowed,
),
diff --git a/multiqc/plots/box.py b/multiqc/plots/box.py
index 6d533af72c..cbb54390b9 100644
--- a/multiqc/plots/box.py
+++ b/multiqc/plots/box.py
@@ -488,8 +488,8 @@ def create(
),
hovermode="y",
hoverlabel=dict(
- bgcolor="rgba(255, 255, 255, 0.8)",
- font=dict(color="black"),
+ bgcolor="white",
+ font=dict(color="rgba(60,60,60,1)"),
),
)
return BoxPlot(**model.__dict__, sort_switch_sorted_active=pconfig.sort_switch_sorted_active)
diff --git a/multiqc/plots/heatmap.py b/multiqc/plots/heatmap.py
index a60c1782b9..564d9cd666 100644
--- a/multiqc/plots/heatmap.py
+++ b/multiqc/plots/heatmap.py
@@ -1,6 +1,7 @@
"""MultiQC functions to plot a heatmap"""
import logging
+import re
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union, cast
import numpy as np
@@ -25,6 +26,21 @@
logger = logging.getLogger(__name__)
+def _convert_hex8_to_rgba(color: str) -> str:
+ """
+ Convert 8-digit hex color (with alpha) to rgba format for Plotly compatibility.
+ Plotly colorscales don't accept #RRGGBBAA format, but do accept rgba().
+ """
+ # Match 8-digit hex colors like #ffffff00 or #FFFFFF00
+ match = re.match(r"^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$", color)
+ if match:
+ r, g, b, a = [int(x, 16) for x in match.groups()]
+ # Convert alpha from 0-255 to 0-1
+ alpha = round(a / 255, 3)
+ return f"rgba({r}, {g}, {b}, {alpha})"
+ return color
+
+
# Define element types for the heatmap
ElemT = Union[str, float, int, None]
@@ -658,7 +674,8 @@ def n_elements_to_size(n: int):
# normalized color level value (starting at 0 and ending at 1),
# and the second item is a valid color string.
try:
- colorscale = [(float(x), color) for x, color in pconfig.colstops]
+ # Convert 8-digit hex colors to rgba for Plotly compatibility
+ colorscale = [(float(x), _convert_hex8_to_rgba(color)) for x, color in pconfig.colstops]
except ValueError:
pass
else:
@@ -727,7 +744,7 @@ def buttons(self, flat: bool, module_anchor: Anchor, section_anchor: Anchor) ->
@@ -735,7 +752,7 @@ def buttons(self, flat: bool, module_anchor: Anchor, section_anchor: Anchor) ->
diff --git a/multiqc/plots/linegraph.py b/multiqc/plots/linegraph.py
index b3b72f7984..d5fb553008 100644
--- a/multiqc/plots/linegraph.py
+++ b/multiqc/plots/linegraph.py
@@ -107,6 +107,9 @@ def get_y_range(self) -> Tuple[Optional[Any], Optional[Any]]:
SeriesT = Union[Series, Dict[str, Any]]
+AxisStr = Literal["xaxis", "yaxis"]
+
+
class LinePlotConfig(PConfig):
xlab: Optional[str] = None
ylab: Optional[str] = None
@@ -120,6 +123,7 @@ class LinePlotConfig(PConfig):
dash_styles: Dict[str, str] = {}
hovertemplates: Dict[str, str] = {}
legend_groups: Dict[str, str] = {}
+ axis_controlled_by_switches: Optional[List[AxisStr]] = None
@classmethod
def parse_extra_series(
@@ -342,7 +346,11 @@ def to_df(self) -> pl.DataFrame:
records = []
# Create a record for each data point in each series
for ds_idx, dataset in enumerate(self.data):
+ data_label = json.dumps(self.pconfig.data_labels[ds_idx]) if self.pconfig.data_labels else ""
for series in dataset:
+ # Extract series properties once per series, not per data point
+ series_props = {k: v for k, v in series.model_dump().items() if k not in ["pairs", "name"]}
+ sample_name = series.name
for x, y in series.pairs:
# Convert NaN values to string marker for safe serialization
x_val = "__NAN__MARKER__" if isinstance(x, float) and math.isnan(x) else str(x)
@@ -350,15 +358,15 @@ def to_df(self) -> pl.DataFrame:
record = {
"dataset_idx": ds_idx,
- "data_label": json.dumps(self.pconfig.data_labels[ds_idx]) if self.pconfig.data_labels else "",
- "sample": series.name,
+ "data_label": data_label,
+ "sample": sample_name,
# values can be be different types (int, float, str...), especially across
# plots. parquet requires values of the same type. so we cast them to str
"x_val": x_val,
"y_val": y_val,
"x_val_type": type(x).__name__,
"y_val_type": type(y).__name__,
- "series": {k: v for k, v in series.model_dump().items() if k not in ["pairs", "name"]},
+ "series": series_props,
}
records.append(record)
@@ -397,10 +405,11 @@ def from_df(
)
pconf = cast(LinePlotConfig, LinePlotConfig.from_df(df))
- # Reconstruct data structure
- datasets = []
- data_labels = []
- sample_names = []
+ # Reconstruct data structure using efficient grouping
+ datasets: List[List[Series[KeyT, ValT]]] = []
+ data_labels: List[Union[str, Dict[str, Any]]] = []
+ sample_names: List[SampleName] = []
+ sample_names_set: set = set()
dataset_indices = sorted(df.select("dataset_idx").unique().to_series()) if not df.is_empty() else []
@@ -410,40 +419,61 @@ def from_df(
data_label = ds_group.select("data_label").item(0, 0) if not ds_group.is_empty() else None
data_labels.append(json.loads(data_label) if data_label else {})
- dataset = []
+ dataset: List[Series[KeyT, ValT]] = []
- # Get list of unique sample names in this dataset to preserve order
- unique_samples: pl.Series = (
- ds_group.select("sample").unique().to_series() if not ds_group.is_empty() else pl.Series([])
- )
- # Group by sample_name within each dataset
- for sample_name in natsorted(unique_samples):
- sample_group = ds_group.filter(pl.col("sample") == sample_name)
-
- # Extract series properties
- if not sample_group.is_empty():
- first_row = sample_group.row(0, named=True)
- series_dict = first_row.get("series", {})
-
- # Extract x,y pairs and sort by x value for proper display
- pairs = []
- for row in sample_group.iter_rows(named=True):
- x_val = parse_value(row["x_val"], row["x_val_type"])
- y_val = parse_value(row["y_val"], row["y_val_type"])
- pairs.append((x_val, y_val))
-
- # Create Series object
- series = Series(
- name=str(sample_name),
- pairs=pairs,
- path_in_cfg=("lineplot", "data"),
- **series_dict,
- )
- dataset.append(series)
-
- # Add sample name if not already in the list
- if sample_name not in sample_names:
- sample_names.append(SampleName(str(sample_name)))
+ if ds_group.is_empty():
+ datasets.append(dataset)
+ continue
+
+ # Get unique sample names and sort them using natsort
+ unique_samples_list = ds_group.select("sample").unique().to_series().to_list()
+ sorted_samples = natsorted(unique_samples_list)
+
+ # Build a lookup of sample -> rows using partition_by for efficiency
+ # First, get all relevant columns as lists for faster access
+ all_samples = ds_group.get_column("sample").to_list()
+ all_x_vals = ds_group.get_column("x_val").to_list()
+ all_y_vals = ds_group.get_column("y_val").to_list()
+ all_x_types = ds_group.get_column("x_val_type").to_list()
+ all_y_types = ds_group.get_column("y_val_type").to_list()
+ all_series = ds_group.get_column("series").to_list()
+
+ # Group data by sample name using a dictionary
+ sample_data: Dict[str, List[int]] = {}
+ for i, sample in enumerate(all_samples):
+ if sample not in sample_data:
+ sample_data[sample] = []
+ sample_data[sample].append(i)
+
+ for sample_name in sorted_samples:
+ row_indices = sample_data.get(sample_name, [])
+ if not row_indices:
+ continue
+
+ # Get series properties from first row
+ first_idx = row_indices[0]
+ series_dict = all_series[first_idx]
+
+ # Extract x,y pairs
+ pairs: List[Tuple[KeyT, ValT]] = []
+ for idx in row_indices:
+ x_val = parse_value(all_x_vals[idx], all_x_types[idx])
+ y_val = parse_value(all_y_vals[idx], all_y_types[idx])
+ pairs.append((x_val, y_val))
+
+ # Create Series object
+ series: Series[KeyT, ValT] = Series(
+ name=str(sample_name),
+ pairs=pairs,
+ path_in_cfg=("lineplot", "data"),
+ **series_dict,
+ )
+ dataset.append(series)
+
+ # Add sample name if not already in the set
+ if sample_name not in sample_names_set:
+ sample_names_set.add(sample_name)
+ sample_names.append(SampleName(str(sample_name)))
datasets.append(dataset)
@@ -590,12 +620,14 @@ def create(
lists_of_lines = [x for x in lists_of_lines if x]
n_samples_per_dataset = [len(x) for x in lists_of_lines]
+ axis_controlled_by_switches = pconfig.axis_controlled_by_switches or ["yaxis"]
+
model: Plot[Dataset[KeyT, ValT], LinePlotConfig] = Plot.initialize(
plot_type=PlotType.LINE,
pconfig=pconfig,
anchor=anchor,
n_series_per_dataset=n_samples_per_dataset,
- axis_controlled_by_switches=["yaxis"],
+ axis_controlled_by_switches=list(axis_controlled_by_switches),
default_tt_label=" %{x}: %{y}",
)
diff --git a/multiqc/plots/plot.py b/multiqc/plots/plot.py
index c91fab1010..27ebdb411b 100644
--- a/multiqc/plots/plot.py
+++ b/multiqc/plots/plot.py
@@ -37,6 +37,7 @@
from multiqc.plots.utils import check_plotly_version
from multiqc.types import Anchor, ColumnKey, PlotType, SampleName
from multiqc.utils import mqc_colour
+from multiqc.utils.material_icons import get_material_icon
from multiqc.validation import ValidatedConfig, add_validation_warning
logger = logging.getLogger(__name__)
@@ -67,32 +68,45 @@ def _get_series_label(plot_type: PlotType, series_label: Union[str, bool]) -> st
# Create and register MultiQC default Plotly template
-multiqc_plotly_template = dict(
- layout=go.Layout(
- paper_bgcolor="white",
- plot_bgcolor="white",
- font=dict(family="'Lucida Grande', 'Open Sans', verdana, arial, sans-serif"),
- colorway=mqc_colour.mqc_colour_scale.COLORBREWER_SCALES["plot_defaults"],
- xaxis=dict(
- gridcolor="rgba(0,0,0,0.05)",
- zerolinecolor="rgba(0,0,0,0.05)",
- color="rgba(0,0,0,0.3)", # axis labels
- tickfont=dict(size=10, color="rgba(0,0,0,1)"),
- ),
- yaxis=dict(
- gridcolor="rgba(0,0,0,0.05)",
- zerolinecolor="rgba(0,0,0,0.05)",
- color="rgba(0,0,0,0.3)", # axis labels
- tickfont=dict(size=10, color="rgba(0,0,0,1)"),
- ),
- title=dict(font=dict(size=20)),
- modebar=dict(
- bgcolor="rgba(0, 0, 0, 0)",
- color="rgba(0, 0, 0, 0.5)",
- activecolor="rgba(0, 0, 0, 1)",
- ),
+# Uses transparent backgrounds so plots adapt to the page theme in the HTML report
+# JavaScript in plotting.js will override colors for dark mode
+def get_multiqc_plotly_template():
+ """Get the MultiQC Plotly template with runtime config values."""
+ return dict(
+ layout=go.Layout(
+ paper_bgcolor="rgba(0,0,0,0)", # transparent for HTML report
+ plot_bgcolor="rgba(0,0,0,0)", # transparent for HTML report
+ font=dict(
+ family=config.plot_font_family
+ or "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'",
+ color="rgba(60,60,60,1)",
+ ),
+ colorway=mqc_colour.mqc_colour_scale.COLORBREWER_SCALES["plot_defaults"],
+ xaxis=dict(
+ gridcolor="rgba(128,128,128,0.15)",
+ zerolinecolor="rgba(128,128,128,0.2)",
+ color="rgba(100,100,100,1)",
+ tickfont=dict(size=10, color="rgba(80,80,80,1)"),
+ spikecolor="rgba(60,60,60,1)", # Darker spike line for light mode
+ spikethickness=-3, # Negative value removes white border, absolute value is thickness
+ ),
+ yaxis=dict(
+ gridcolor="rgba(128,128,128,0.15)",
+ zerolinecolor="rgba(128,128,128,0.2)",
+ color="rgba(100,100,100,1)",
+ tickfont=dict(size=10, color="rgba(80,80,80,1)"),
+ spikecolor="rgba(60,60,60,1)", # Darker spike line for light mode
+ spikethickness=-3, # Negative value removes white border, absolute value is thickness
+ ),
+ title=dict(font=dict(size=20, color="rgba(60,60,60,1)")),
+ legend=dict(font=dict(color="rgba(60,60,60,1)")),
+ modebar=dict(
+ bgcolor="rgba(0, 0, 0, 0)",
+ color="rgba(100, 100, 100, 0.5)",
+ activecolor="rgba(80, 80, 80, 1)",
+ ),
+ )
)
-)
class FlatLine(ValidatedConfig):
@@ -251,7 +265,8 @@ def __init__(self, path_in_cfg: Optional[Tuple[str, ...]] = None, **data: Any):
# Allow user to overwrite any given config for this plot
if self.id in config.custom_plot_config:
for k, v in config.custom_plot_config[self.id].items():
- setattr(self, k, v)
+ if k in self.model_fields:
+ setattr(self, k, v)
# Normalize data labels to ensure they are unique and consistent.
if self.data_labels and len(self.data_labels) > 1:
@@ -645,8 +660,8 @@ def initialize(
if showlegend is None:
showlegend = True if flat else False
- # Use the specified template or default to multiqc
- template = config.plot_theme if config.plot_theme else go.layout.Template(multiqc_plotly_template)
+ # Use the MultiQC template with runtime config values
+ template = go.layout.Template(get_multiqc_plotly_template())
layout: go.Layout = go.Layout(
template=template,
@@ -1155,7 +1170,7 @@ def flat_plot(
html = "".join(
[
'',
- ' ',
+ f"{get_material_icon('mdi:image', 16)} ",
"Flat image plot. Toolbox functions such as highlighting / hiding samples will not work ",
'(see the docs ).',
" ",
@@ -1246,7 +1261,7 @@ def _btn(
style_str = f'style="{style}"' if style else ""
- return f'{label} \n'
+ return f'{label} \n'
def buttons(self, flat: bool, module_anchor: Anchor, section_anchor: Anchor) -> List[str]:
"""
@@ -1292,7 +1307,7 @@ def buttons(self, flat: bool, module_anchor: Anchor, section_anchor: Anchor) ->
export_btn = self._btn(
cls="export-plot",
style="float: right; margin-left: 5px;",
- label=' Export...',
+ label=' Export...',
attrs={"title": "Show export options"},
data_attrs={
"plot-anchor": str(self.anchor),
@@ -1303,29 +1318,27 @@ def buttons(self, flat: bool, module_anchor: Anchor, section_anchor: Anchor) ->
ai_btn = ""
if not config.no_ai:
+ seqera_ai_icon = (
+ Path(__file__).parent.parent / "templates/default/assets/img/Seqera_AI_icon.svg"
+ ).read_text()
ai_btn = f"""
@@ -1359,7 +1367,7 @@ def __control_panel(self, flat: bool, module_anchor: Anchor, section_anchor: Anc
Add buttons: percentage on/off, log scale on/off, datasets switch panel
"""
buttons = "\n".join(self.buttons(flat=flat, module_anchor=module_anchor, section_anchor=section_anchor))
- html = f"\n\n"
+ html = f"\n\n"
return html
@@ -1423,7 +1431,7 @@ def _batch_export_plots(export_tasks, timeout=None):
"""
# Default timeout from config
if timeout is None:
- timeout = config.export_plots_timeout if hasattr(config, "export_plots_timeout") else 30
+ timeout = config.export_plots_timeout
# Start the export in a separate process
export_process = BatchExportProcess(export_tasks)
@@ -1461,11 +1469,42 @@ def _batch_export_plots(export_tasks, timeout=None):
return completed_tasks
+def _prepare_figure_for_export(fig):
+ """
+ Prepare a figure for export by ensuring it has solid backgrounds.
+ Only modifies transparent backgrounds - preserves custom theme backgrounds.
+
+ Plots use transparent backgrounds by default to adapt to page themes in HTML,
+ but exports need solid backgrounds for readability.
+ """
+ # Create a copy to avoid modifying the original figure used in HTML
+ fig_copy = go.Figure(fig)
+
+ # Helper function to check if a color is transparent
+ def is_transparent(color):
+ if color is None:
+ return True
+ color_str = str(color).lower()
+ # Check for transparent rgba values
+ return color_str.startswith("rgba(") and ",0)" in color_str.replace(" ", "")
+
+ # Only change background if it's transparent
+ if is_transparent(fig_copy.layout.paper_bgcolor):
+ fig_copy.update_layout(paper_bgcolor="white")
+
+ if is_transparent(fig_copy.layout.plot_bgcolor):
+ fig_copy.update_layout(plot_bgcolor="white")
+
+ return fig_copy
+
+
def _export_plot(fig, plot_path, write_kwargs):
"""Export a plotly figure to a file."""
- # Default timeout of 30 seconds for image export
- timeout = config.export_plots_timeout if hasattr(config, "export_plots_timeout") else 30
+ # Prepare figure with solid backgrounds for export (only if currently transparent)
+ fig = _prepare_figure_for_export(fig)
+
+ timeout = config.export_plots_timeout
# Start the export in a separate process
export_process = ExportProcess(fig, plot_path, write_kwargs)
@@ -1492,6 +1531,9 @@ def _export_plot(fig, plot_path, write_kwargs):
def _export_plot_to_buffer(fig, write_kwargs) -> Optional[str]:
try:
+ # Prepare figure with solid backgrounds for export (only if currently transparent)
+ fig = _prepare_figure_for_export(fig)
+
img_buffer = io.BytesIO()
fig.write_image(img_buffer, **write_kwargs)
img_buffer = add_logo(img_buffer, format="PNG")
@@ -1579,8 +1621,10 @@ def fig_to_static_html(
# Add to batch if using batch processing
if batch_processing:
+ # Prepare figure with solid backgrounds for export (only if currently transparent)
+ fig_for_export = _prepare_figure_for_export(fig)
task_idx = len(_plot_export_batch)
- _plot_export_batch.append((fig, plot_path, write_kwargs))
+ _plot_export_batch.append((fig_for_export, plot_path, write_kwargs))
tasks_added.append((task_idx, plot_path, file_ext))
# If we're using batch processing, we'll assume the PNG will be written by the batch process
diff --git a/multiqc/plots/table_object.py b/multiqc/plots/table_object.py
index b5efa4c789..261fc497fe 100644
--- a/multiqc/plots/table_object.py
+++ b/multiqc/plots/table_object.py
@@ -8,6 +8,7 @@
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Mapping, NewType, Optional, Sequence, Set, Tuple, TypedDict, Union, cast
+from pathlib import Path
from natsort import natsorted
from pydantic import BaseModel, Field
@@ -16,6 +17,7 @@
from multiqc.plots.plot import PConfig
from multiqc.types import Anchor, ColumnKey, SampleGroup, SampleName, SectionKey
from multiqc.utils import mqc_colour
+from multiqc.utils.material_icons import get_material_icon
from multiqc.validation import ValidatedConfig
logger = logging.getLogger(__name__)
@@ -379,7 +381,7 @@ def create(
# the table anchor because that's the ID that is shown in the Configure Columns modal
if table_anchor in config.custom_plot_config:
for k, v in config.custom_plot_config[table_anchor].items():
- if isinstance(k, str) and k in pconfig.__dict__:
+ if isinstance(k, str) and k in pconfig.model_fields:
setattr(pconfig, k, v)
# Each section to have a list of groups (even if there is just one element in a group)
@@ -1035,8 +1037,14 @@ def escape(s: str) -> str:
buttons.append(
f"""
-
- Copy table
+
+ {get_material_icon("mdi:content-copy", 16)} Copy table
"""
)
@@ -1053,9 +1061,9 @@ def escape(s: str) -> str:
buttons.append(
f"""
-
- Configure columns
+
+ {get_material_icon("mdi:view-column", 16)} Configure columns
"""
)
@@ -1063,10 +1071,10 @@ def escape(s: str) -> str:
# Sort By Highlight button
buttons.append(
f"""
-
- Sort by highlight
-
+
+ {get_material_icon("mdi:sort", 16)} Sort by highlight
+
"""
)
@@ -1074,27 +1082,27 @@ def escape(s: str) -> str:
if len(col_to_th) > 1:
buttons.append(
f"""
-
- Scatter plot
-
- """
+
+ {get_material_icon("mdi:chart-scatter-plot", 16)} Scatter plot
+
+ """
)
if violin_anchor is not None:
buttons.append(
f"""
-
- Violin plot
-
- """
+
+ {get_material_icon("mdi:violin", 16)} Violin plot
+
+ """
)
buttons.append(
f"""
- Export as CSV...
"""
)
@@ -1122,29 +1130,27 @@ def escape(s: str) -> str:
)
if not config.no_ai:
+ seqera_ai_icon = (
+ Path(__file__).parent.parent / "templates/default/assets/img/Seqera_AI_icon.svg"
+ ).read_text()
buttons.append(
f"""
@@ -1176,7 +1177,7 @@ def escape(s: str) -> str:
panel = "\n".join(buttons)
html += f"""
-
+
"""
# Build the table itself
@@ -1185,8 +1186,8 @@ def escape(s: str) -> str:
)
html += f"""
-
-
+
+
"""
# Build the header row
@@ -1239,7 +1240,9 @@ def escape(s: str) -> str:
html += ""
html += "
"
if len(group_to_sample_to_anchor_to_td) > 10 and config.collapse_tables:
- html += '
'
+ html += (
+ f'Expand table {get_material_icon("mdi:chevron-down", 20)}
'
+ )
html += ""
# Save the raw values to a file if requested
@@ -1274,17 +1277,17 @@ def _configuration_modal(table_anchor: str, title: str, trows: str, violin_ancho
return f"""
-
+
Uncheck the tick box to hide columns. Click and drag the handle on the left to change order. Table ID: {table_anchor}
- Show All
- Show None
+ Show All
+ Show None
@@ -1303,7 +1306,7 @@ def _configuration_modal(table_anchor: str, title: str, trows: str, violin_ancho
-
+
"""
diff --git a/multiqc/plots/violin.py b/multiqc/plots/violin.py
index 7b947a0784..2a7472b122 100644
--- a/multiqc/plots/violin.py
+++ b/multiqc/plots/violin.py
@@ -32,6 +32,7 @@
render_html,
)
from multiqc.types import Anchor, ColumnKey, SampleName, SectionKey
+from multiqc.utils.material_icons import get_material_icon
logger = logging.getLogger(__name__)
@@ -215,58 +216,66 @@ def from_df(
data_dict: Dict[SectionKey, Dict[SampleName, Dict[ColumnKeyT, Optional[ExtValueT]]]] = {}
headers_dict: Dict[SectionKey, Dict[ColumnKey, ColumnDict]] = {}
- # Track metrics and their order
- ordered_metrics = {}
-
- # Group by section
- for section_key in df.select("section_key").unique().to_series():
- section_group = df.filter(pl.col("section_key") == section_key)
+ # Extract all columns as lists for efficient access (avoiding repeated iter_rows)
+ all_section_keys = df.get_column("section_key").to_list()
+ all_samples = df.get_column("sample").to_list()
+ all_metrics = df.get_column("metric").to_list()
+ all_val_raw = df.get_column("val_raw").to_list()
+ all_val_raw_type = df.get_column("val_raw_type").to_list()
+ all_val_mod = df.get_column("val_mod").to_list()
+ all_val_mod_type = df.get_column("val_mod_type").to_list()
+ all_val_fmt = df.get_column("val_fmt").to_list()
+ all_column_meta = df.get_column("column_meta").to_list()
+ has_section_order = "section_order" in df.columns
+ all_section_order = df.get_column("section_order").to_list() if has_section_order else None
+
+ # Build index for grouping: section_key -> sample -> list of row indices
+ section_sample_indices: Dict[str, Dict[str, List[int]]] = {}
+ for i, (section_key, sample) in enumerate(zip(all_section_keys, all_samples)):
+ if section_key not in section_sample_indices:
+ section_sample_indices[section_key] = {}
+ if sample not in section_sample_indices[section_key]:
+ section_sample_indices[section_key][sample] = []
+ section_sample_indices[section_key][sample].append(i)
+
+ # Process each section
+ for section_key, sample_indices in section_sample_indices.items():
val_by_sample_by_metric: Dict[SampleName, Dict[ColumnKeyT, Optional[ExtValueT]]] = {}
section_headers: Dict[ColumnKey, ColumnDict] = {}
- # Sort metrics by their original index if available
- if "section_order" in section_group.columns:
- # Create ordered dict of metrics for this section
- metrics_info = section_group.select(["metric", "section_order"]).unique()
- for metric_row in metrics_info.iter_rows(named=True):
- ordered_metrics[metric_row["metric"]] = metric_row["section_order"]
+ for sample_name, row_indices in sample_indices.items():
+ # Sort indices by section_order if available
+ if has_section_order and all_section_order is not None:
+ row_indices = sorted(row_indices, key=lambda idx: all_section_order[idx])
- # Process samples
- for sample_name in section_group.select("sample").unique().to_series():
- sample_group = section_group.filter(pl.col("sample") == sample_name)
val_by_metric: Dict[ColumnKeyT, Optional[ExtValueT]] = {}
- # If we have section_order, sort by that first
- if "section_order" in sample_group.columns:
- sample_group = sample_group.sort("section_order")
-
- # Process metrics/columns
- for row in sample_group.iter_rows(named=True):
- metric_name = row["metric"]
+ for idx in row_indices:
+ metric_name = all_metrics[idx]
# Convert string values back to their original types
- val_raw: float = row["val_raw"]
+ val_raw: Any = all_val_raw[idx]
if not math.isnan(val_raw):
- if row["val_raw_type"] == "int":
+ if all_val_raw_type[idx] == "int":
val_raw = int(val_raw)
- elif row["val_raw_type"] == "bool":
+ elif all_val_raw_type[idx] == "bool":
val_raw = bool(val_raw)
- val_mod: float = row["val_mod"]
+ val_mod: Any = all_val_mod[idx]
if not math.isnan(val_mod):
- if row["val_mod_type"] == "int":
+ if all_val_mod_type[idx] == "int":
val_mod = int(val_mod)
- elif row["val_mod_type"] == "bool":
+ elif all_val_mod_type[idx] == "bool":
val_mod = bool(val_mod)
val_by_metric[ColumnKey(str(metric_name))] = Cell(
raw=val_raw,
mod=val_mod,
- fmt=row["val_fmt"],
+ fmt=all_val_fmt[idx],
)
# Create header if it doesn't exist
if metric_name not in section_headers:
# Parse column metadata from JSON
- section_headers[metric_name] = json.loads(row["column_meta"])
+ section_headers[metric_name] = json.loads(all_column_meta[idx])
# Add sample data to section
if val_by_metric:
@@ -378,58 +387,29 @@ def merge(cls, old_data: "ViolinPlotInputData", new_data: "ViolinPlotInputData")
# Combine the dataframes, keeping all rows
merged_df = pl.concat([old_df, new_df], how="vertical")
- # Get original order of metrics
- # We need to preserve metric_name order, which is important for visualization
- metric_order = {}
- for idx, metric in enumerate(merged_df.select("metric").to_series()):
+ # Get original order of metrics using a more efficient approach
+ # Create a mapping from metric to its first occurrence index
+ metric_list = merged_df.get_column("metric").to_list()
+ metric_order: Dict[str, int] = {}
+ for idx, metric in enumerate(metric_list):
if metric not in metric_order:
metric_order[metric] = idx
- merged_df = merged_df.with_columns(
- pl.col("metric")
- .map_elements(lambda x: metric_order.get(x, 99999), return_dtype=pl.Int64)
- .alias("__metric_order")
- )
-
- # First ensure the DataFrame is sorted by creation_date (newest last)
- merged_df = merged_df.sort("creation_date")
+ # Create a mapping series for efficient lookup
+ metric_order_map = pl.Series("__metric_order", [metric_order.get(m, 99999) for m in metric_list])
+ merged_df = merged_df.with_columns(metric_order_map)
- # Get all unique combinations of the key columns
+ # Sort by creation_date (newest last), then use unique with keep="last" to deduplicate
+ # This is O(n log n) instead of O(n²) from the previous implementation
key_columns = ["dt_anchor", "section_key", "sample", "metric"]
- unique_keys = merged_df.select(key_columns).unique()
-
- # For each unique key combination, find the newest entry
- latest_records = []
- for key_row in unique_keys.iter_rows(named=True):
- # Build filter expression for this key combination
- filter_expr = pl.lit(True)
- for col, val in key_row.items():
- filter_expr = filter_expr & (pl.col(col) == val)
-
- # Get matching rows (should be sorted by creation_date already)
- matches = merged_df.filter(filter_expr)
- if not matches.is_empty():
- # Get the last/newest record for this key combination
- row: Tuple[Any, ...] = matches.row(-1)
- entry = {k: v for k, v in zip(merged_df.columns, row)}
- latest_records.append(entry)
-
- # Create a new DataFrame from the latest records
- if latest_records:
- # Create DataFrame with same schema as merged_df
- deduped_df = pl.DataFrame(latest_records, schema=merged_df.schema)
-
- # Add a temporary column to sort by the original metric order
- deduped_df = deduped_df.with_columns(
- pl.col("metric")
- .map_elements(lambda x: metric_order.get(x, 99999), return_dtype=pl.Int64)
- .alias("__metric_order")
- )
+ merged_df = merged_df.sort("creation_date").unique(
+ subset=key_columns,
+ keep="last",
+ maintain_order=True,
+ )
- # Sort by the order column to preserve metric ordering
- merged_df = deduped_df.sort("__metric_order").drop("__metric_order")
- else:
- merged_df = pl.DataFrame(schema=merged_df.schema)
+ # Sort by the order column to preserve metric ordering, then drop it
+ merged_df = merged_df.sort("__metric_order").drop("__metric_order")
else:
merged_df = new_df
@@ -680,9 +660,9 @@ def create(
orientation="h",
box={"visible": True},
meanline={"visible": True},
- fillcolor="#b5b5b5",
- line={"width": 2, "color": "#b5b5b5"},
- opacity=0.5,
+ fillcolor="#999999",
+ line={"width": 0},
+ opacity=1,
points=False, # Don't show points, we'll add them manually
# The hover information is useful, but the formatting is ugly and not
# configurable as far as I can see. Also, it's not possible to disable it,
@@ -693,16 +673,13 @@ def create(
# If all violins are grey, make the dots blue to make it more clear that it's interactive
# if some violins are color-coded, make the dots black to make them less distracting
- marker_color = "black" if any(h.color is not None for h in ds.header_by_metric.values()) else "#0b79e6"
+ marker_color = "#000000" if any(h.color is not None for h in ds.header_by_metric.values()) else "#0b79e6"
ds.scatter_trace_params = {
"mode": "markers",
- "marker": {
- "size": 4,
- "color": marker_color,
- },
+ "marker": {"size": 4, "color": marker_color, "opacity": 1},
"showlegend": False,
"hovertemplate": ds.trace_params["hovertemplate"],
- "hoverlabel": {"bgcolor": "white"},
+ "hoverlabel": {"bgcolor": "white", "font": {"color": "rgba(60,60,60,1)"}},
}
return ds
@@ -873,7 +850,7 @@ def format_dataset_for_ai_prompt(self, pconfig: TableConfig, keep_hidden: bool =
try:
value = fmt.format(value)
except (ValueError, KeyError):
- logger.info(f"Value {value} failed to format with {fmt=}")
+ pass
row.append(str(value))
result += f"|{pseudonym}|" + "|".join(row) + "|\n"
@@ -979,7 +956,7 @@ def buttons(self, flat: bool, module_anchor: Anchor, section_anchor: Anchor) ->
buttons.append(
self._btn(
cls="mqc-violin-to-table",
- label="
Table",
+ label=f"{get_material_icon('mdi:table', 16)} Table",
data_attrs={"table-anchor": self.datasets[0].dt.anchor, "violin-anchor": self.anchor},
)
)
@@ -1097,20 +1074,23 @@ def add_to_report(self, module_anchor: Anchor, section_anchor: Anchor, plots_dir
if self.show_table_by_default and not self.show_table:
warning = (
f'
'
- + ' Showing violin plots for {self.n_samples} data points.
'
+ + 'well as hoverable points for outlier samples in each metric."'
+ + ' data-bs-toggle="tooltip">'
+ + get_material_icon("mdi:alert", 16, class_name="text-warning")
+ + f" Showing {self.n_samples} samples."
)
elif not self.show_table:
warning = (
f'
'
- + ' Showing violin plots for {self.n_samples} data points.
'
+ + ' data-bs-toggle="tooltip">'
+ + get_material_icon("mdi:alert", 16, class_name="text-warning")
+ + f" Showing {self.n_samples} samples."
)
assert self.datasets[0].dt is not None
diff --git a/multiqc/report.py b/multiqc/report.py
index 83bf283b5a..832949e6aa 100644
--- a/multiqc/report.py
+++ b/multiqc/report.py
@@ -832,8 +832,10 @@ def data_sources_tofile(data_dir: Path):
json.dump(data_sources, f, indent=4, ensure_ascii=False)
elif config.data_format == "yaml":
# Unlike JSON, YAML represents defaultdicts as objects, so need to convert
- # them to normal dicts
- yaml.dump(replace_defaultdicts(data_sources), f, default_flow_style=False)
+ # them to normal dicts.
+ # Use CSafeDumper for 3-4x faster serialization when libyaml is available
+ Dumper = getattr(yaml, "CSafeDumper", yaml.SafeDumper)
+ yaml.dump(replace_defaultdicts(data_sources), f, default_flow_style=False, Dumper=Dumper)
else:
lines = [["Module", "Section", "Sample Name", "Source"]]
for mod in data_sources:
@@ -858,8 +860,10 @@ def dois_tofile(data_dir: Path, module_list: List["BaseMultiqcModule"]):
json.dump(dois, f, indent=4, ensure_ascii=False)
elif config.data_format == "yaml":
# Unlike JSON, YAML represents defaultdicts as objects, so need to convert
- # them to normal dicts
- yaml.dump(replace_defaultdicts(dois), f, default_flow_style=False)
+ # them to normal dicts.
+ # Use CSafeDumper for 3-4x faster serialization when libyaml is available
+ Dumper = getattr(yaml, "CSafeDumper", yaml.SafeDumper)
+ yaml.dump(replace_defaultdicts(dois), f, default_flow_style=False, Dumper=Dumper)
else:
body = ""
for mod_name, dois_strings in dois.items():
@@ -1035,7 +1039,9 @@ def write_data_file(
if data_format == "json":
dump_json(data, f, indent=4, ensure_ascii=False)
elif data_format == "yaml":
- yaml.dump(replace_defaultdicts(data), f, default_flow_style=False)
+ # Use CSafeDumper for 3-4x faster serialization when libyaml is available
+ Dumper = getattr(yaml, "CSafeDumper", yaml.SafeDumper)
+ yaml.dump(replace_defaultdicts(data), f, default_flow_style=False, Dumper=Dumper)
elif body:
# Default - tab separated output
print(body.encode("utf-8", "ignore").decode("utf-8"), file=f)
diff --git a/multiqc/search_patterns.yaml b/multiqc/search_patterns.yaml
index 17a734ee54..2b9ce7ccbe 100644
--- a/multiqc/search_patterns.yaml
+++ b/multiqc/search_patterns.yaml
@@ -12,15 +12,9 @@ xenium/metrics:
fn: "metrics_summary.csv"
contents: "num_cells_detected"
num_lines: 5
-xenium/transcripts:
- fn: "transcripts.parquet"
xenium/experiment:
fn: "experiment.xenium"
num_lines: 50
-xenium/cells:
- fn: "cells.parquet"
-xenium/cell_feature_matrix:
- fn: "cell_feature_matrix.h5"
afterqc:
fn: "*.json"
contents: "allow_mismatch_in_poly"
@@ -55,6 +49,9 @@ bbduk:
bbmap/stats:
contents: ["#File", "#Total", "#Matched", "#Name Reads ReadsPct"]
num_lines: 10
+bbmap/bbsplit:
+ contents: "#name %unambiguousReads unambiguousMB %ambiguousReads"
+ num_lines: 5
bbmap/aqhist:
contents: "#Quality count1 fraction1 count2 fraction2"
num_lines: 10
@@ -452,6 +449,8 @@ hicexplorer:
num_lines: 26
hicup:
fn: "HiCUP_summary_report*"
+hicup/html:
+ fn: "*HiCUP_summary_report*.html"
hicpro/mmapstat:
fn: "*mapstat"
contents: "total_R"
@@ -822,6 +821,10 @@ qc3C:
qorts:
contents: "BENCHMARK_MinutesOnSamIteration"
num_lines: 100
+qorts/log:
+ fn: "QC.*.log"
+ contents: "Starting QoRTs"
+ num_lines: 2
qualimap/bamqc/genome_results:
fn: "genome_results.txt"
qualimap/bamqc/coverage:
@@ -832,10 +835,18 @@ qualimap/bamqc/genome_fraction:
fn: "genome_fraction_coverage.txt"
qualimap/bamqc/gc_dist:
fn: "mapped_reads_gc-content_distribution.txt"
+qualimap/bamqc/html:
+ fn: "qualimapReport.html"
+ contents: "Qualimap report: BAM QC"
+ num_lines: 10
qualimap/rnaseq/rnaseq_results:
fn: "rnaseq_qc_results.txt"
qualimap/rnaseq/coverage:
fn: "coverage_profile_along_genes_(total).txt"
+qualimap/rnaseq/html:
+ fn: "qualimapReport.html"
+ contents: "Qualimap report: RNA Seq QC"
+ num_lines: 10
quast:
fn: "report.tsv"
contents: "Assembly "
@@ -850,6 +861,13 @@ rna_seqc/coverage:
fn_re: 'meanCoverageNorm_(high|medium|low)\.txt'
rna_seqc/correlation:
fn_re: 'corrMatrix(Pearson|Spearman)\.txt'
+rna_seqc/html:
+ fn: "index.html"
+ contents: "RNA-SeQC v"
+ num_lines: 200
+ribotish/qual:
+ fn: "*_qual.txt"
+ num_lines: 10
rockhopper:
fn: "summary.txt"
contents: "Number of gene-pairs predicted to be part of the same operon"
@@ -928,6 +946,9 @@ sargasso:
seqfu/stats:
contents: "File #Seq Total bp Avg N50 N75 N90 auN Min Max"
num_lines: 1
+seqkit/stats:
+ contents_re: "^file\\s+format\\s+type\\s+num_seqs\\s+sum_len"
+ num_lines: 1
seqwho:
contents: ' "Per Base Seq": ['
num_lines: 10
@@ -1017,6 +1038,8 @@ supernova/kmers:
fn: "histogram_kmer_count.json"
num_lines: 10
contents: '"description": "kmer_count",'
+sylphtax:
+ fn: "*.sylphmpa"
telseq:
num_lines: 3
contents: "ReadGroup Library Sample Total Mapped Duplicates LENGTH_ESTIMATE"
diff --git a/multiqc/templates/default/CLAUDE.md b/multiqc/templates/default/CLAUDE.md
new file mode 100644
index 0000000000..9d7b00495c
--- /dev/null
+++ b/multiqc/templates/default/CLAUDE.md
@@ -0,0 +1,166 @@
+# CLAUDE.md - MultiQC Default Template
+
+This file provides guidance for working with the MultiQC default template frontend assets.
+
+## Overview
+
+The default template is the main HTML report template for MultiQC. It uses Bootstrap 5.3.7 for styling and layout, with custom JavaScript for interactive features like plot rendering, sample filtering, and toolbox controls.
+
+## Directory Structure
+
+```
+multiqc/templates/default/
+├── assets/ # Third-party libraries (jQuery, Plotly.js, Bootstrap icons)
+├── compiled/ # ⚠️ AUTO-GENERATED - Do not edit directly!
+│ ├── css/ # Bundled CSS output from Vite
+│ └── js/ # Bundled JS output from Vite
+├── src/
+│ ├── js/ # Source JavaScript files
+│ └── scss/ # Source SCSS files
+├── *.html # Jinja2 template files
+├── package.json # Node dependencies and build scripts
+├── vite.config.js # Vite bundler configuration
+└── tsconfig.json # TypeScript configuration
+```
+
+## Build System
+
+The template uses **Vite** to bundle JavaScript and SCSS files into the `compiled/` directory.
+
+### Setup
+
+```bash
+cd multiqc/templates/default
+npm install
+```
+
+### Build Commands
+
+```bash
+# One-time build (for production or testing)
+npm run build
+
+# Watch mode (auto-rebuild on file changes during development)
+npm run watch
+```
+
+### Important Rules
+
+- **NEVER edit files in `compiled/`** - they are auto-generated and will be overwritten
+- **Always edit source files** in `src/js/` and `src/scss/`
+- **NEVER add inline styles** like `style="--bs-btn-padding-y: .25rem;`, use `custom.scss` if you must.
+- Run `npm run build` after making changes before committing
+- The build is also run automatically by pre-commit hooks
+
+## Source Files
+
+### JavaScript (`src/js/`)
+
+Main files:
+
+- **`multiqc.js`**: Entry point, imports all other modules
+- **`toolbox.js`**: Toolbar controls (export buttons, highlighting, filtering)
+- **`plots.js`**: Plotly.js plot decompression and rendering
+- **`table.js`**: DataTables initialization and configuration
+- **`highlight.js`**: Sample name highlighting/filtering across plots and tables
+
+### SCSS (`src/scss/`)
+
+- **`multiqc.scss`**: Main entry point
+- **`variables.scss`**: Bootstrap variable overrides (colors, spacing, etc.)
+- **`custom.scss`**: Custom styles for MultiQC-specific components
+
+The SCSS imports Bootstrap 5.3.7 from npm and applies custom variables and styles.
+
+## Assets
+
+Third-party libraries in `assets/` are kept separate and base64-encoded directly into the final HTML report:
+
+- jQuery 3.7.1
+- Plotly.js (basic bundle)
+- Bootstrap Icons
+- DataTables
+
+These are NOT bundled by Vite to maintain version control and reduce build complexity.
+
+## Testing Changes
+
+After making changes to JavaScript or SCSS:
+
+1. Run `npm run build` to generate new compiled assets
+2. Run MultiQC on test data: `multiqc test_data/`
+3. Open the generated `multiqc_report.html` in a browser
+4. Test your changes across different browsers if possible
+
+## Common Tasks
+
+### Changing Bootstrap Variables
+
+Edit `src/scss/variables.scss`:
+
+```scss
+// Example: Change primary color
+$primary: #3498db;
+```
+
+Then rebuild: `npm run build`
+
+### Adding New JavaScript Functionality
+
+1. Edit or create file in `src/js/`
+2. Import in `src/js/multiqc.js` if it's a new file
+3. Rebuild: `npm run build`
+4. Test the generated report
+
+### Modifying Template HTML
+
+Edit the `.html` Jinja2 template files directly:
+
+- `head.html`: `` content, script/style tags
+- `nav.html`: Navigation sidebar
+- `base.html`: Main layout wrapper
+- `footer.html`: Report footer
+
+No build step needed for HTML changes (they use Jinja2 templating).
+
+## Development Workflow
+
+Best workflow for active development:
+
+```bash
+# Terminal 1: Watch for changes to JS/CSS
+cd multiqc/templates/default
+npm run watch
+
+# Terminal 2: Run MultiQC with --development flag
+multiqc /path/to/test/data --development --force
+```
+
+The watch mode will automatically rebuild assets when you save changes to source files.
+
+### Auto-regenerate on HTML Changes
+
+For HTML template changes (which aren't watched by `npm run watch`), use watchmedo:
+
+```bash
+# Install watchdog
+pip install watchdog
+
+# Watch for HTML changes and regenerate report
+watchmedo shell-command \
+ --patterns="*.html" \
+ --recursive \
+ --command='multiqc /path/to/test/data --development --force' \
+ multiqc/templates/default/
+```
+
+## Browser Compatibility
+
+The template targets modern browsers (ES6+). Key features used:
+
+- Arrow functions
+- Template literals
+- `const`/`let`
+- `fetch` API
+
+Legacy browser support (IE11) was dropped in MultiQC v1.15.
diff --git a/multiqc/templates/default/README.md b/multiqc/templates/default/README.md
new file mode 100644
index 0000000000..6154f6dd0f
--- /dev/null
+++ b/multiqc/templates/default/README.md
@@ -0,0 +1,90 @@
+# MultiQC Default Template
+
+This directory contains the default HTML template for MultiQC reports. The template uses Bootstrap 5.3.7 and custom styles/scripts bundled with Vite.
+
+## Directory Structure
+
+```
+default/
+├── src/
+│ ├── js/
+│ │ ├── main.js # Entry point that imports all JS modules
+│ │ └── ... # MultiQC JavaScript modules
+│ └── scss/
+│ ├── main.scss # Main file that imports Bootstrap and custom styles
+│ └── custom.scss # Custom MultiQC styles
+├── compiled/ # Built assets (generated by Vite)
+│ ├── css/
+│ │ └── multiqc.min.css # Bundled CSS (do not edit)
+│ └── js/
+│ └── multiqc.min.js # Bundled JS (do not edit)
+├── assets/ # Third-party libraries
+│ └── js/packages/ # jQuery, Plotly, etc. (kept separate)
+├── node_modules/ # npm dependencies (not in git)
+├── package.json # npm configuration
+└── vite.config.js # Vite configuration
+```
+
+## Development Setup
+
+1. Install dependencies:
+
+ ```bash
+ cd multiqc/templates/default
+ npm install
+ ```
+
+2. Build assets with Vite:
+
+ ```bash
+ npm run build
+ ```
+
+3. Watch for changes during development:
+ ```bash
+ npm run watch
+ ```
+
+## Making Changes
+
+### Styles
+
+- Edit SCSS files in `src/scss/`
+- `custom.scss` contains all custom MultiQC styles
+- `main.scss` imports Bootstrap and custom styles
+
+### JavaScript
+
+- Edit JavaScript files in `src/js/`
+- `main.js` is the entry point that imports all modules
+- Bootstrap 5 is bundled with MultiQC custom JavaScript
+
+### Build Process
+
+Assets are automatically built when:
+
+- You run `npm run build` manually
+- You have `npm run watch` running
+- You commit changes (via pre-commit hook)
+
+### Plotly bundle
+
+The Plotly bundle is created by cloning the Plotly repository and running the following:
+
+```bash
+npm run custom-bundle -- --traces bar,scatter,table,violin,heatmap,box
+```
+
+This generates a minified JavaScript file using [Custom Bundling](https://github.com/plotly/plotly.js/blob/master/CUSTOM_BUNDLE.md).
+The resulting file is much bigger than importing all of Plotly, as we don't use very many different plot types.
+
+For now it seems that tree-shaking does not work well for Plotly. See https://github.com/MultiQC/MultiQC/pull/3364 for implementation and discussion.
+
+## Important Notes
+
+- **Never edit files in `compiled/`** - they are automatically generated
+- The bundled CSS includes Bootstrap 5.3.7 and all custom styles
+- The bundled JS includes Bootstrap 5 and all MultiQC custom JavaScript
+- Third-party libraries (jQuery, Plotly, etc.) are kept separate in `assets/js/packages/`
+- The pre-commit hook ensures built assets are always up-to-date
+- All assets are base64-encoded into the HTML report to make it standalone
diff --git a/multiqc/templates/default/assets/img/Anthropic_logo.svg b/multiqc/templates/default/assets/img/Anthropic_logo.svg
new file mode 100644
index 0000000000..0169b8cb2d
--- /dev/null
+++ b/multiqc/templates/default/assets/img/Anthropic_logo.svg
@@ -0,0 +1 @@
+
diff --git a/multiqc/templates/default/assets/img/MultiQC_icon.svg b/multiqc/templates/default/assets/img/MultiQC_icon.svg
new file mode 100644
index 0000000000..bbabebb9e0
--- /dev/null
+++ b/multiqc/templates/default/assets/img/MultiQC_icon.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/multiqc/templates/default/assets/img/MultiQC_logo.svg b/multiqc/templates/default/assets/img/MultiQC_logo.svg
new file mode 100644
index 0000000000..3b9d9a74bc
--- /dev/null
+++ b/multiqc/templates/default/assets/img/MultiQC_logo.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/multiqc/templates/default/assets/img/OpenAI_logo.svg b/multiqc/templates/default/assets/img/OpenAI_logo.svg
new file mode 100644
index 0000000000..cc2fce8371
--- /dev/null
+++ b/multiqc/templates/default/assets/img/OpenAI_logo.svg
@@ -0,0 +1 @@
+
diff --git a/multiqc/templates/default/assets/img/Seqera_AI_icon.svg b/multiqc/templates/default/assets/img/Seqera_AI_icon.svg
new file mode 100644
index 0000000000..376e7869c4
--- /dev/null
+++ b/multiqc/templates/default/assets/img/Seqera_AI_icon.svg
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/multiqc/templates/default/assets/img/Seqera_logo.svg b/multiqc/templates/default/assets/img/Seqera_logo.svg
new file mode 100644
index 0000000000..d6b70fcbb7
--- /dev/null
+++ b/multiqc/templates/default/assets/img/Seqera_logo.svg
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/multiqc/templates/default/assets/js/packages/plotly-3.1.2.custom.min.js b/multiqc/templates/default/assets/js/packages/plotly-3.1.2.custom.min.js
new file mode 100644
index 0000000000..892971a97e
--- /dev/null
+++ b/multiqc/templates/default/assets/js/packages/plotly-3.1.2.custom.min.js
@@ -0,0 +1,48 @@
+/**
+* plotly.js (custom - minified) v3.1.2
+* Copyright 2012-2025, Plotly, Inc.
+* All rights reserved.
+* Licensed under the MIT license
+*/
+(
+ function(root, factory) {
+ if (typeof module === "object" && module.exports) {
+ module.exports = factory();
+ } else {
+ root.moduleName = factory();
+ }
+} (typeof self !== "undefined" ? self : this, () => {
+"use strict";var Plotly=(()=>{var G=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports);var Iv=G(Gx=>{"use strict";Gx.version="3.1.2"});var Wx=G((Vx,Hv)=>{(function(r,t,a){t[r]=t[r]||a(),typeof Hv!="undefined"&&Hv.exports&&(Hv.exports=t[r])})("Promise",typeof window!="undefined"?window:Vx,function(){"use strict";var r,t,a,n=Object.prototype.toString,i=typeof setImmediate!="undefined"?function(_){return setImmediate(_)}:setTimeout;try{Object.defineProperty({},"x",{}),r=function(_,M,b,w){return Object.defineProperty(_,M,{value:b,writable:!0,configurable:w!==!1})}}catch(x){r=function(M,b,w){return M[b]=w,M}}a=function(){var _,M,b;function w(k,A){this.fn=k,this.self=A,this.next=void 0}return{add:function(A,q){b=new w(A,q),M?M.next=b:_=b,M=b,b=void 0},drain:function(){var A=_;for(_=M=t=void 0;A;)A.fn.call(A.self),A=A.next}}}();function l(x,_){a.add(x,_),t||(t=i(a.drain))}function o(x){var _,M=typeof x;return x!=null&&(M=="object"||M=="function")&&(_=x.then),typeof _=="function"?_:!1}function s(){for(var x=0;x
0&&l(s,M))}catch(b){c.call(new d(M),b)}}}function c(x){var _=this;_.triggered||(_.triggered=!0,_.def&&(_=_.def),_.msg=x,_.state=2,_.chain.length>0&&l(s,_))}function h(x,_,M,b){for(var w=0;w<_.length;w++)(function(A){x.resolve(_[A]).then(function(D){M(A,D)},b)})(w)}function d(x){this.def=x,this.triggered=!1}function p(x){this.promise=x,this.state=0,this.triggered=!1,this.chain=[],this.msg=void 0}function y(x){if(typeof x!="function")throw TypeError("Not a function");if(this.__NPO__!==0)throw TypeError("Not a promise");this.__NPO__=1;var _=new p(this);this.then=function(b,w){var k={success:typeof b=="function"?b:!0,failure:typeof w=="function"?w:!1};return k.promise=new this.constructor(function(q,D){if(typeof q!="function"||typeof D!="function")throw TypeError("Not a function");k.resolve=q,k.reject=D}),_.chain.push(k),_.state!==0&&l(s,_),k.promise},this.catch=function(b){return this.then(void 0,b)};try{x.call(void 0,function(b){f.call(_,b)},function(b){c.call(_,b)})}catch(M){c.call(_,M)}}var g=r({},"constructor",y,!1);return y.prototype=g,r(g,"__NPO__",0,!1),r(y,"resolve",function(_){var M=this;return _&&typeof _=="object"&&_.__NPO__===1?_:new M(function(w,k){if(typeof w!="function"||typeof k!="function")throw TypeError("Not a function");w(_)})}),r(y,"reject",function(_){return new this(function(b,w){if(typeof b!="function"||typeof w!="function")throw TypeError("Not a function");w(_)})}),r(y,"all",function(_){var M=this;return n.call(_)!="[object Array]"?M.reject(TypeError("Not an array")):_.length===0?M.resolve([]):new M(function(w,k){if(typeof w!="function"||typeof k!="function")throw TypeError("Not a function");var A=_.length,q=Array(A),D=0;h(M,_,function(R,z){q[R]=z,++D===A&&w(q)},k)})}),r(y,"race",function(_){var M=this;return n.call(_)!="[object Array]"?M.reject(TypeError("Not an array")):new M(function(w,k){if(typeof w!="function"||typeof k!="function")throw TypeError("Not a function");h(M,_,function(q,D){w(D)},k)})}),y})});var Rr=G((yce,Bv)=>{(function(){var e={version:"3.8.2"},r=[].slice,t=function(v){return r.call(v)},a=self.document;function n(v){return v&&(v.ownerDocument||v.document||v).documentElement}function i(v){return v&&(v.ownerDocument&&v.ownerDocument.defaultView||v.document&&v||v.defaultView)}if(a)try{t(a.documentElement.childNodes)[0].nodeType}catch(v){t=function(m){for(var T=m.length,C=new Array(T);T--;)C[T]=m[T];return C}}if(Date.now||(Date.now=function(){return+new Date}),a)try{a.createElement("DIV").style.setProperty("opacity",0,"")}catch(v){var l=this.Element.prototype,o=l.setAttribute,s=l.setAttributeNS,u=this.CSSStyleDeclaration.prototype,f=u.setProperty;l.setAttribute=function(m,T){o.call(this,m,T+"")},l.setAttributeNS=function(m,T,C){s.call(this,m,T,C+"")},u.setProperty=function(m,T,C){f.call(this,m,T+"",C)}}e.ascending=c;function c(v,m){return vm?1:v>=m?0:NaN}e.descending=function(v,m){return mv?1:m>=v?0:NaN},e.min=function(v,m){var T=-1,C=v.length,S,L;if(arguments.length===1){for(;++T=L){S=L;break}for(;++TL&&(S=L)}else{for(;++T=L){S=L;break}for(;++TL&&(S=L)}return S},e.max=function(v,m){var T=-1,C=v.length,S,L;if(arguments.length===1){for(;++T=L){S=L;break}for(;++TS&&(S=L)}else{for(;++T=L){S=L;break}for(;++TS&&(S=L)}return S},e.extent=function(v,m){var T=-1,C=v.length,S,L,N;if(arguments.length===1){for(;++T=L){S=N=L;break}for(;++TL&&(S=L),N=L){S=N=L;break}for(;++TL&&(S=L),N1)return N/(I-1)},e.deviation=function(){var v=e.variance.apply(this,arguments);return v&&Math.sqrt(v)};function p(v){return{left:function(m,T,C,S){for(arguments.length<3&&(C=0),arguments.length<4&&(S=m.length);C>>1;v(m[L],T)<0?C=L+1:S=L}return C},right:function(m,T,C,S){for(arguments.length<3&&(C=0),arguments.length<4&&(S=m.length);C>>1;v(m[L],T)>0?S=L:C=L+1}return C}}}var y=p(c);e.bisectLeft=y.left,e.bisect=e.bisectRight=y.right,e.bisector=function(v){return p(v.length===1?function(m,T){return c(v(m),T)}:v)},e.shuffle=function(v,m,T){(C=arguments.length)<3&&(T=v.length,C<2&&(m=0));for(var C=T-m,S,L;C;)L=Math.random()*C--|0,S=v[C+m],v[C+m]=v[L+m],v[L+m]=S;return v},e.permute=function(v,m){for(var T=m.length,C=new Array(T);T--;)C[T]=v[m[T]];return C},e.pairs=function(v){for(var m=0,T=v.length-1,C,S=v[0],L=new Array(T<0?0:T);m=0;)for(N=v[m],T=N.length;--T>=0;)L[--S]=N[T];return L};var x=Math.abs;e.range=function(v,m,T){if(arguments.length<3&&(T=1,arguments.length<2&&(m=v,v=0)),(m-v)/T===1/0)throw new Error("infinite range");var C=[],S=_(x(T)),L=-1,N;if(v*=S,m*=S,T*=S,T<0)for(;(N=v+T*++L)>m;)C.push(N/S);else for(;(N=v+T*++L)=m.length)return S?S.call(v,I):C?I.sort(C):I;for(var J=-1,ee=I.length,re=m[X++],_e,ke,te,ve=new b,ye;++J=m.length)return P;var X=[],J=T[I++];return P.forEach(function(ee,re){X.push({key:ee,values:N(re,I)})}),J?X.sort(function(ee,re){return J(ee.key,re.key)}):X}return v.map=function(P,I){return L(I,P,0)},v.entries=function(P){return N(L(e.map,P,0),0)},v.key=function(P){return m.push(P),v},v.sortKeys=function(P){return T[m.length-1]=P,v},v.sortValues=function(P){return C=P,v},v.rollup=function(P){return S=P,v},v},e.set=function(v){var m=new H;if(v)for(var T=0,C=v.length;T=0&&(C=v.slice(T+1),v=v.slice(0,T)),v)return arguments.length<2?this[v].on(C):this[v].on(C,m);if(arguments.length===2){if(m==null)for(v in this)this.hasOwnProperty(v)&&this[v].on(C,null);return this}};function Q(v){var m=[],T=new b;function C(){for(var S=m,L=-1,N=S.length,P;++L=0&&(T=v.slice(0,m))!=="xmlns"&&(v=v.slice(m+1)),pe.hasOwnProperty(T)?{space:pe[T],local:v}:v}},de.attr=function(v,m){if(arguments.length<2){if(typeof v=="string"){var T=this.node();return v=e.ns.qualify(v),v.local?T.getAttributeNS(v.space,v.local):T.getAttribute(v)}for(m in v)this.each(we(m,v[m]));return this}return this.each(we(v,m))};function we(v,m){v=e.ns.qualify(v);function T(){this.removeAttribute(v)}function C(){this.removeAttributeNS(v.space,v.local)}function S(){this.setAttribute(v,m)}function L(){this.setAttributeNS(v.space,v.local,m)}function N(){var I=m.apply(this,arguments);I==null?this.removeAttribute(v):this.setAttribute(v,I)}function P(){var I=m.apply(this,arguments);I==null?this.removeAttributeNS(v.space,v.local):this.setAttributeNS(v.space,v.local,I)}return m==null?v.local?C:T:typeof m=="function"?v.local?P:N:v.local?L:S}function ge(v){return v.trim().replace(/\s+/g," ")}de.classed=function(v,m){if(arguments.length<2){if(typeof v=="string"){var T=this.node(),C=(v=Ne(v)).length,S=-1;if(m=T.classList){for(;++S=0;)(L=T[C])&&(S&&S!==L.nextSibling&&S.parentNode.insertBefore(L,S),S=L);return this},de.sort=function(v){v=Me.apply(this,arguments);for(var m=-1,T=this.length;++m=m&&(m=S+1);!(I=N[m])&&++m0&&(v=v.slice(0,S));var N=He.get(v);N&&(v=N,L=Ye);function P(){var J=this[C];J&&(this.removeEventListener(v,J,J.$),delete this[C])}function I(){var J=L(m,t(arguments));P.call(this),this.addEventListener(v,this[C]=J,J.$=T),J._=m}function X(){var J=new RegExp("^__on([^.]+)"+e.requote(v)+"$"),ee;for(var re in this)if(ee=re.match(J)){var _e=this[re];this.removeEventListener(ee[1],_e,_e.$),delete this[re]}}return S?m?I:P:m?U:X}var He=e.map({mouseenter:"mouseover",mouseleave:"mouseout"});a&&He.forEach(function(v){"on"+v in a&&He.remove(v)});function Ze(v,m){return function(T){var C=e.event;e.event=T,m[0]=this.__data__;try{v.apply(this,m)}finally{e.event=C}}}function Ye(v,m){var T=Ze(v,m);return function(C){var S=this,L=C.relatedTarget;(!L||L!==S&&!(L.compareDocumentPosition(S)&8))&&T.call(S,C)}}var Xe,Qe=0;function hr(v){var m=".dragsuppress-"+ ++Qe,T="click"+m,C=e.select(i(v)).on("touchmove"+m,ae).on("dragstart"+m,ae).on("selectstart"+m,ae);if(Xe==null&&(Xe="onselectstart"in v?!1:Y(v.style,"userSelect")),Xe){var S=n(v).style,L=S[Xe];S[Xe]="none"}return function(N){if(C.on(m,null),Xe&&(S[Xe]=L),N){var P=function(){C.on(T,null)};C.on(T,function(){ae(),P()},!0),setTimeout(P,0)}}}e.mouse=function(v){return Re(v,fe())};var Ke=this.navigator&&/WebKit/.test(this.navigator.userAgent)?-1:0;function Re(v,m){m.changedTouches&&(m=m.changedTouches[0]);var T=v.ownerSVGElement||v;if(T.createSVGPoint){var C=T.createSVGPoint();if(Ke<0){var S=i(v);if(S.scrollX||S.scrollY){T=e.select("body").append("svg").style({position:"absolute",top:0,left:0,margin:0,padding:0,border:"none"},"important");var L=T[0][0].getScreenCTM();Ke=!(L.f||L.e),T.remove()}}return Ke?(C.x=m.pageX,C.y=m.pageY):(C.x=m.clientX,C.y=m.clientY),C=C.matrixTransform(v.getScreenCTM().inverse()),[C.x,C.y]}var N=v.getBoundingClientRect();return[m.clientX-N.left-v.clientLeft,m.clientY-N.top-v.clientTop]}e.touch=function(v,m,T){if(arguments.length<3&&(T=m,m=fe().changedTouches),m){for(var C=0,S=m.length,L;C0?1:v<0?-1:0}function Ur(v,m,T){return(m[0]-v[0])*(T[1]-v[1])-(m[1]-v[1])*(T[0]-v[0])}function zt(v){return v>1?0:v<-1?qe:Math.acos(v)}function ht(v){return v>1?ar:v<-1?-ar:Math.asin(v)}function dt(v){return((v=Math.exp(v))-1/v)/2}function st(v){return((v=Math.exp(v))+1/v)/2}function Bt(v){return((v=Math.exp(2*v))-1)/(v+1)}function Ot(v){return(v=Math.sin(v/2))*v}var qt=Math.SQRT2,_a=2,br=4;e.interpolateZoom=function(v,m){var T=v[0],C=v[1],S=v[2],L=m[0],N=m[1],P=m[2],I=L-T,X=N-C,J=I*I+X*X,ee,re;if(J0&&(je=je.transition().duration(N)),je.call(Ce.event)}function dr(){ve&&ve.domain(te.range().map(function(je){return(je-v.x)/v.k}).map(te.invert)),Ae&&Ae.domain(ye.range().map(function(je){return(je-v.y)/v.k}).map(ye.invert))}function pr(je){P++||je({type:"zoomstart"})}function Sr(je){dr(),je({type:"zoom",scale:v.k,translate:[v.x,v.y]})}function yr(je){--P||(je({type:"zoomend"}),T=null)}function Mr(){var je=this,qr=ke.of(je,arguments),Dr=0,rt=e.select(i(je)).on(X,Ma).on(J,Ha),Et=xe(e.mouse(je)),ra=hr(je);Rv.call(je),pr(qr);function Ma(){Dr=1,fr(e.mouse(je),Et),Sr(qr)}function Ha(){rt.on(X,null).on(J,null),ra(Dr),yr(qr)}}function et(){var je=this,qr=ke.of(je,arguments),Dr={},rt=0,Et,ra=".zoom-"+e.event.changedTouches[0].identifier,Ma="touchmove"+ra,Ha="touchend"+ra,Qa=[],Ba=e.select(je),an=hr(je);nn(),pr(qr),Ba.on(I,null).on(re,nn);function Da(){var oi=e.touches(je);return Et=v.k,oi.forEach(function(Aa){Aa.identifier in Dr&&(Dr[Aa.identifier]=xe(Aa))}),oi}function nn(){var oi=e.event.target;e.select(oi).on(Ma,wo).on(Ha,gY),Qa.push(oi);for(var Aa=e.event.changedTouches,Dn=0,Pi=Aa.length;Dn1){var To=En[0],Cl=En[1],Fv=To[0]-Cl[0],Ux=To[1]-Cl[1];rt=Fv*Fv+Ux*Ux}}function wo(){var oi=e.touches(je),Aa,Dn,Pi,En;Rv.call(je);for(var Rs=0,To=oi.length;Rs1?1:m,T=T<0?0:T>1?1:T,S=T<=.5?T*(1+m):T+m-T*m,C=2*T-S;function L(P){return P>360?P-=360:P<0&&(P+=360),P<60?C+(S-C)*P/60:P<180?S:P<240?C+(S-C)*(240-P)/60:C}function N(P){return Math.round(L(P)*255)}return new Nt(N(v+120),N(v),N(v-120))}e.hcl=Yt;function Yt(v,m,T){return this instanceof Yt?(this.h=+v,this.c=+m,void(this.l=+T)):arguments.length<2?v instanceof Yt?new Yt(v.h,v.c,v.l):v instanceof Ut?kt(v.l,v.a,v.b):kt((v=ur((v=e.rgb(v)).r,v.g,v.b)).l,v.a,v.b):new Yt(v,m,T)}var Ia=Yt.prototype=new At;Ia.brighter=function(v){return new Yt(this.h,this.c,Math.min(100,this.l+at*(arguments.length?v:1)))},Ia.darker=function(v){return new Yt(this.h,this.c,Math.max(0,this.l-at*(arguments.length?v:1)))},Ia.rgb=function(){return rn(this.h,this.c,this.l).rgb()};function rn(v,m,T){return isNaN(v)&&(v=0),isNaN(m)&&(m=0),new Ut(T,Math.cos(v*=Tr)*m,Math.sin(v)*m)}e.lab=Ut;function Ut(v,m,T){return this instanceof Ut?(this.l=+v,this.a=+m,void(this.b=+T)):arguments.length<2?v instanceof Ut?new Ut(v.l,v.a,v.b):v instanceof Yt?rn(v.h,v.c,v.l):ur((v=Nt(v)).r,v.g,v.b):new Ut(v,m,T)}var at=18,sa=.95047,$i=1,ji=1.08883,it=Ut.prototype=new At;it.brighter=function(v){return new Ut(Math.min(100,this.l+at*(arguments.length?v:1)),this.a,this.b)},it.darker=function(v){return new Ut(Math.max(0,this.l-at*(arguments.length?v:1)),this.a,this.b)},it.rgb=function(){return tn(this.l,this.a,this.b)};function tn(v,m,T){var C=(v+16)/116,S=C+m/500,L=C-T/200;return S=yo(S)*sa,C=yo(C)*$i,L=yo(L)*ji,new Nt(ai(3.2404542*S-1.5371385*C-.4985314*L),ai(-.969266*S+1.8760108*C+.041556*L),ai(.0556434*S-.2040259*C+1.0572252*L))}function kt(v,m,T){return v>0?new Yt(Math.atan2(T,m)*Fr,Math.sqrt(m*m+T*T),v):new Yt(NaN,NaN,v)}function yo(v){return v>.206893034?v*v*v:(v-4/29)/7.787037}function Ka(v){return v>.008856?Math.pow(v,1/3):7.787037*v+4/29}function ai(v){return Math.round(255*(v<=.00304?12.92*v:1.055*Math.pow(v,1/2.4)-.055))}e.rgb=Nt;function Nt(v,m,T){return this instanceof Nt?(this.r=~~v,this.g=~~m,void(this.b=~~T)):arguments.length<2?v instanceof Nt?new Nt(v.r,v.g,v.b):Xr(""+v,Nt,oa):new Nt(v,m,T)}function ni(v){return new Nt(v>>16,v>>8&255,v&255)}function Ml(v){return ni(v)+""}var Al=Nt.prototype=new At;Al.brighter=function(v){v=Math.pow(.7,arguments.length?v:1);var m=this.r,T=this.g,C=this.b,S=30;return!m&&!T&&!C?new Nt(S,S,S):(m&&m>4,C=C>>4|C,S=I&240,S=S>>4|S,L=I&15,L=L<<4|L):v.length===7&&(C=(I&16711680)>>16,S=(I&65280)>>8,L=I&255)),m(C,S,L))}function lt(v,m,T){var C=Math.min(v/=255,m/=255,T/=255),S=Math.max(v,m,T),L=S-C,N,P,I=(S+C)/2;return L?(P=I<.5?L/(S+C):L/(2-S-C),v==S?N=(m-T)/L+(m0&&I<1?0:N),new $r(N,P,I)}function ur(v,m,T){v=wa(v),m=wa(m),T=wa(T);var C=Ka((.4124564*v+.3575761*m+.1804375*T)/sa),S=Ka((.2126729*v+.7151522*m+.072175*T)/$i),L=Ka((.0193339*v+.119192*m+.9503041*T)/ji);return Ut(116*S-16,500*(C-S),200*(S-L))}function wa(v){return(v/=255)<=.04045?v/12.92:Math.pow((v+.055)/1.055,2.4)}function mt(v){var m=parseFloat(v);return v.charAt(v.length-1)==="%"?Math.round(m*2.55):m}var Ta=e.map({aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074});Ta.forEach(function(v,m){Ta.set(v,ni(m))});function Ir(v){return typeof v=="function"?v:function(){return v}}e.functor=Ir,e.xhr=ii(W);function ii(v){return function(m,T,C){return arguments.length===2&&typeof T=="function"&&(C=T,T=null),go(m,T,v,C)}}function go(v,m,T,C){var S={},L=e.dispatch("beforesend","progress","load","error"),N={},P=new XMLHttpRequest,I=null;self.XDomainRequest&&!("withCredentials"in P)&&/^(http(s)?:)?\/\//.test(v)&&(P=new XDomainRequest),"onload"in P?P.onload=P.onerror=X:P.onreadystatechange=function(){P.readyState>3&&X()};function X(){var J=P.status,ee;if(!J&&xB(P)||J>=200&&J<300||J===304){try{ee=T.call(S,P)}catch(re){L.error.call(S,re);return}L.load.call(S,ee)}else L.error.call(S,P)}return P.onprogress=function(J){var ee=e.event;e.event=J;try{L.progress.call(S,P)}finally{e.event=ee}},S.header=function(J,ee){return J=(J+"").toLowerCase(),arguments.length<2?N[J]:(ee==null?delete N[J]:N[J]=ee+"",S)},S.mimeType=function(J){return arguments.length?(m=J==null?null:J+"",S):m},S.responseType=function(J){return arguments.length?(I=J,S):I},S.response=function(J){return T=J,S},["get","post"].forEach(function(J){S[J]=function(){return S.send.apply(S,[J].concat(t(arguments)))}}),S.send=function(J,ee,re){if(arguments.length===2&&typeof ee=="function"&&(re=ee,ee=null),P.open(J,v,!0),m!=null&&!("accept"in N)&&(N.accept=m+",*/*"),P.setRequestHeader)for(var _e in N)P.setRequestHeader(_e,N[_e]);return m!=null&&P.overrideMimeType&&P.overrideMimeType(m),I!=null&&(P.responseType=I),re!=null&&S.on("error",re).on("load",function(ke){re(null,ke)}),L.beforesend.call(S,P),P.send(ee==null?null:ee),S},S.abort=function(){return P.abort(),S},e.rebind(S,L,"on"),C==null?S:S.get(bB(C))}function bB(v){return v.length===1?function(m,T){v(m==null?T:null)}:v}function xB(v){var m=v.responseType;return m&&m!=="text"?v.response:v.responseText}e.dsv=function(v,m){var T=new RegExp('["'+v+`
+]`),C=v.charCodeAt(0);function S(X,J,ee){arguments.length<3&&(ee=J,J=null);var re=go(X,m,J==null?L:N(J),ee);return re.row=function(_e){return arguments.length?re.response((J=_e)==null?L:N(_e)):J},re}function L(X){return S.parse(X.responseText)}function N(X){return function(J){return S.parse(J.responseText,X)}}S.parse=function(X,J){var ee;return S.parseRows(X,function(re,_e){if(ee)return ee(re,_e-1);var ke=function(te){for(var ve={},ye=re.length,Ae=0;Ae=ke)return re;if(Ae)return Ae=!1,ee;var Ge=te;if(X.charCodeAt(Ge)===34){for(var nr=Ge;nr++24?(isFinite(m)&&(clearTimeout(gv),gv=setTimeout(lp,m)),yv=0):(yv=1,qb(lp))}e.timer.flush=function(){Lb(),Db()};function Lb(){for(var v=Date.now(),m=pv;m;)v>=m.t&&m.c(v-m.t)&&(m.c=null),m=m.n;return v}function Db(){for(var v,m=pv,T=1/0;m;)m.c?(m.t=0;--P)te.push(S[X[ee[P]][2]]);for(P=+_e;P1&&Ur(v[T[C-2]],v[T[C-1]],v[S])<=0;)--C;T[C++]=S}return T.slice(0,C)}function _B(v,m){return v[0]-m[0]||v[1]-m[1]}e.geom.polygon=function(v){return $(v,xv),v};var xv=e.geom.polygon.prototype=[];xv.area=function(){for(var v=-1,m=this.length,T,C=this[m-1],S=0;++vhe)P=P.L;else if(N=m-AB(P,T),N>he){if(!P.R){C=P;break}P=P.R}else{L>-he?(C=P.P,S=P):N>-he?(C=P,S=P.N):C=S=P;break}var I=Nb(v);if(Ls.insert(C,I),!(!C&&!S)){if(C===S){Es(C),S=Nb(C.site),Ls.insert(I,S),I.edge=S.edge=pf(C.site,I.site),Ds(C),Ds(S);return}if(!S){I.edge=pf(C.site,I.site);return}Es(C),Es(S);var X=C.site,J=X.x,ee=X.y,re=v.x-J,_e=v.y-ee,ke=S.site,te=ke.x-J,ve=ke.y-ee,ye=2*(re*ve-_e*te),Ae=re*re+_e*_e,Ce=te*te+ve*ve,xe={x:(ve*Ae-_e*Ce)/ye+J,y:(re*Ce-te*Ae)/ye+ee};_v(S.edge,X,ke,xe),I.edge=pf(X,v,null,xe),S.edge=pf(v,ke,null,xe),Ds(C),Ds(S)}}function Fb(v,m){var T=v.site,C=T.x,S=T.y,L=S-m;if(!L)return C;var N=v.P;if(!N)return-1/0;T=N.site;var P=T.x,I=T.y,X=I-m;if(!X)return P;var J=P-C,ee=1/L-1/X,re=J/X;return ee?(-re+Math.sqrt(re*re-2*ee*(J*J/(-2*X)-I+X/2+S-L/2)))/ee+C:(C+P)/2}function AB(v,m){var T=v.N;if(T)return Fb(T,m);var C=v.site;return C.y===m?C.x:1/0}function Ib(v){this.site=v,this.edges=[]}Ib.prototype.prepare=function(){for(var v=this.edges,m=v.length,T;m--;)T=v[m].edge,(!T.b||!T.a)&&v.splice(m,1);return v.sort(Hb),v.length};function kB(v){for(var m=v[0][0],T=v[1][0],C=v[0][1],S=v[1][1],L,N,P,I,X=bo,J=X.length,ee,re,_e,ke,te,ve;J--;)if(ee=X[J],!(!ee||!ee.prepare()))for(_e=ee.edges,ke=_e.length,re=0;rehe||x(I-N)>he)&&(_e.splice(re,0,new wv(DB(ee.site,ve,x(P-m)he?{x:m,y:x(L-m)he?{x:x(N-S)he?{x:T,y:x(L-T)he?{x:x(N-C)=-De)){var re=I*I+X*X,_e=J*J+ve*ve,ke=(ve*re-X*_e)/ee,te=(I*_e-J*re)/ee,ve=te+P,ye=zb.pop()||new CB;ye.arc=v,ye.site=S,ye.x=ke+N,ye.y=ve+Math.sqrt(ke*ke+te*te),ye.cy=ve,v.circle=ye;for(var Ae=null,Ce=df._;Ce;)if(ye.y0)){if(te/=_e,_e<0){if(te0){if(te>re)return;te>ee&&(ee=te)}if(te=T-P,!(!_e&&te<0)){if(te/=_e,_e<0){if(te>re)return;te>ee&&(ee=te)}else if(_e>0){if(te0)){if(te/=ke,ke<0){if(te0){if(te>re)return;te>ee&&(ee=te)}if(te=C-I,!(!ke&&te<0)){if(te/=ke,ke<0){if(te>re)return;te>ee&&(ee=te)}else if(ke>0){if(te0&&(S.a={x:P+ee*_e,y:I+ee*ke}),re<1&&(S.b={x:P+re*_e,y:I+re*ke}),S}}}}}}function qB(v){for(var m=qs,T=SB(v[0][0],v[0][1],v[1][0],v[1][1]),C=m.length,S;C--;)S=m[C],(!LB(S,v)||!T(S)||x(S.a.x-S.b.x)=L)return;if(J>re){if(!C)C={x:ke,y:N};else if(C.y>=P)return;T={x:ke,y:P}}else{if(!C)C={x:ke,y:P};else if(C.y1)if(J>re){if(!C)C={x:(N-ye)/ve,y:N};else if(C.y>=P)return;T={x:(P-ye)/ve,y:P}}else{if(!C)C={x:(P-ye)/ve,y:P};else if(C.y=L)return;T={x:L,y:ve*L+ye}}else{if(!C)C={x:L,y:ve*L+ye};else if(C.x=J&&ye.x<=re&&ye.y>=ee&&ye.y<=_e?[[J,_e],[re,_e],[re,ee],[J,ee]]:[];Ae.point=I[te]}),X}function P(I){return I.map(function(X,J){return{x:Math.round(C(X,J)/he)*he,y:Math.round(S(X,J)/he)*he,i:J}})}return N.links=function(I){return vp(P(I)).edges.filter(function(X){return X.l&&X.r}).map(function(X){return{source:I[X.l.i],target:I[X.r.i]}})},N.triangles=function(I){var X=[];return vp(P(I)).cells.forEach(function(J,ee){for(var re=J.site,_e=J.edges.sort(Hb),ke=-1,te=_e.length,ve,ye,Ae=_e[te-1].edge,Ce=Ae.l===re?Ae.r:Ae.l;++keCe&&(Ce=J.x),J.y>xe&&(xe=J.y),_e.push(J.x),ke.push(J.y);else for(te=0;teCe&&(Ce=Ge),nr>xe&&(xe=nr),_e.push(Ge),ke.push(nr)}var fr=Ce-ye,ir=xe-Ae;fr>ir?xe=Ae+fr:Ce=ye+ir;function dr(yr,Mr,et,Gt,ft,je,qr,Dr){if(!(isNaN(et)||isNaN(Gt)))if(yr.leaf){var rt=yr.x,Et=yr.y;if(rt!=null)if(x(rt-et)+x(Et-Gt)<.01)pr(yr,Mr,et,Gt,ft,je,qr,Dr);else{var ra=yr.point;yr.x=yr.y=yr.point=null,pr(yr,ra,rt,Et,ft,je,qr,Dr),pr(yr,Mr,et,Gt,ft,je,qr,Dr)}else yr.x=et,yr.y=Gt,yr.point=Mr}else pr(yr,Mr,et,Gt,ft,je,qr,Dr)}function pr(yr,Mr,et,Gt,ft,je,qr,Dr){var rt=(ft+qr)*.5,Et=(je+Dr)*.5,ra=et>=rt,Ma=Gt>=Et,Ha=Ma<<1|ra;yr.leaf=!1,yr=yr.nodes[Ha]||(yr.nodes[Ha]=Yb()),ra?ft=rt:qr=rt,Ma?je=Et:Dr=Et,dr(yr,Mr,et,Gt,ft,je,qr,Dr)}var Sr=Yb();if(Sr.add=function(yr){dr(Sr,yr,+ee(yr,++te),+re(yr,te),ye,Ae,Ce,xe)},Sr.visit=function(yr){gf(yr,Sr,ye,Ae,Ce,xe)},Sr.find=function(yr){return NB(Sr,yr[0],yr[1],ye,Ae,Ce,xe)},te=-1,m==null){for(;++teL||re>N||_e=Ge,ir=T>=nr,dr=ir<<1|fr,pr=dr+4;drT&&(L=m.slice(T,L),P[N]?P[N]+=L:P[++N]=L),(C=C[0])===(S=S[0])?P[N]?P[N]+=S:P[++N]=S:(P[++N]=null,I.push({i:N,x:el(C,S)})),T=pp.lastIndex;return T=0&&!(C=e.interpolators[T](v,m)););return C}e.interpolators=[function(v,m){var T=typeof m;return(T==="string"?Ta.has(m.toLowerCase())||/^(#|rgb\(|hsl\()/i.test(m)?hp:Gb:m instanceof At?hp:Array.isArray(m)?Av:T==="object"&&isNaN(m)?Ub:el)(v,m)}],e.interpolateArray=Av;function Av(v,m){var T=[],C=[],S=v.length,L=m.length,N=Math.min(v.length,m.length),P;for(P=0;P=0?v.slice(0,m):v,C=m>=0?v.slice(m+1):"in";return T=FB.get(T)||Vb,C=IB.get(C)||W,HB(C(T.apply(null,r.call(arguments,1))))};function HB(v){return function(m){return m<=0?0:m>=1?1:v(m)}}function Wb(v){return function(m){return 1-v(1-m)}}function Zb(v){return function(m){return .5*(m<.5?v(2*m):2-v(2-2*m))}}function BB(v){return v*v}function OB(v){return v*v*v}function YB(v){if(v<=0)return 0;if(v>=1)return 1;var m=v*v,T=m*v;return 4*(v<.5?T:3*(v-m)+T-.75)}function UB(v){return function(m){return Math.pow(m,v)}}function GB(v){return 1-Math.cos(v*ar)}function VB(v){return Math.pow(2,10*(v-1))}function WB(v){return 1-Math.sqrt(1-v*v)}function ZB(v,m){var T;return arguments.length<2&&(m=.45),arguments.length?T=m/tr*Math.asin(1/v):(v=1,T=m/4),function(C){return 1+v*Math.pow(2,-10*C)*Math.sin((C-T)*tr/m)}}function XB(v){return v||(v=1.70158),function(m){return m*m*((v+1)*m-v)}}function JB(v){return v<1/2.75?7.5625*v*v:v<2/2.75?7.5625*(v-=1.5/2.75)*v+.75:v<2.5/2.75?7.5625*(v-=2.25/2.75)*v+.9375:7.5625*(v-=2.625/2.75)*v+.984375}e.interpolateHcl=KB;function KB(v,m){v=e.hcl(v),m=e.hcl(m);var T=v.h,C=v.c,S=v.l,L=m.h-T,N=m.c-C,P=m.l-S;return isNaN(N)&&(N=0,C=isNaN(C)?m.c:C),isNaN(L)?(L=0,T=isNaN(T)?m.h:T):L>180?L-=360:L<-180&&(L+=360),function(I){return rn(T+L*I,C+N*I,S+P*I)+""}}e.interpolateHsl=QB;function QB(v,m){v=e.hsl(v),m=e.hsl(m);var T=v.h,C=v.s,S=v.l,L=m.h-T,N=m.s-C,P=m.l-S;return isNaN(N)&&(N=0,C=isNaN(C)?m.s:C),isNaN(L)?(L=0,T=isNaN(T)?m.h:T):L>180?L-=360:L<-180&&(L+=360),function(I){return oa(T+L*I,C+N*I,S+P*I)+""}}e.interpolateLab=$B;function $B(v,m){v=e.lab(v),m=e.lab(m);var T=v.l,C=v.a,S=v.b,L=m.l-T,N=m.a-C,P=m.b-S;return function(I){return tn(T+L*I,C+N*I,S+P*I)+""}}e.interpolateRound=Xb;function Xb(v,m){return m-=v,function(T){return Math.round(v+m*T)}}e.transform=function(v){var m=a.createElementNS(e.ns.prefix.svg,"g");return(e.transform=function(T){if(T!=null){m.setAttribute("transform",T);var C=m.transform.baseVal.consolidate()}return new Jb(C?C.matrix:eO)})(v)};function Jb(v){var m=[v.a,v.b],T=[v.c,v.d],C=Qb(m),S=Kb(m,T),L=Qb(jB(T,m,-S))||0;m[0]*T[1]180?m+=360:m-v>180&&(v+=360),C.push({i:T.push(Ps(T)+"rotate(",null,")")-2,x:el(v,m)})):m&&T.push(Ps(T)+"rotate("+m+")")}function aO(v,m,T,C){v!==m?C.push({i:T.push(Ps(T)+"skewX(",null,")")-2,x:el(v,m)}):m&&T.push(Ps(T)+"skewX("+m+")")}function nO(v,m,T,C){if(v[0]!==m[0]||v[1]!==m[1]){var S=T.push(Ps(T)+"scale(",null,",",null,")");C.push({i:S-4,x:el(v[0],m[0])},{i:S-2,x:el(v[1],m[1])})}else(m[0]!==1||m[1]!==1)&&T.push(Ps(T)+"scale("+m+")")}function $b(v,m){var T=[],C=[];return v=e.transform(v),m=e.transform(m),rO(v.translate,m.translate,T,C),tO(v.rotate,m.rotate,T,C),aO(v.skew,m.skew,T,C),nO(v.scale,m.scale,T,C),v=m=null,function(S){for(var L=-1,N=C.length,P;++L0?L=xe:(T.c=null,T.t=NaN,T=null,m.end({type:"end",alpha:L=0})):xe>0&&(m.start({type:"start",alpha:L=xe}),T=bv(v.tick)),v):L},v.start=function(){var xe,Ge=_e.length,nr=ke.length,fr=C[0],ir=C[1],dr,pr;for(xe=0;xe=0;)L.push(J=X[I]),J.parent=P,J.depth=P.depth+1;T&&(P.value=0),P.children=X}else T&&(P.value=+T.call(C,P,P.depth)||0),delete P.children;return Ei(S,function(ee){var re,_e;v&&(re=ee.children)&&re.sort(v),T&&(_e=ee.parent)&&(_e.value+=ee.value)}),N}return C.sort=function(S){return arguments.length?(v=S,C):v},C.children=function(S){return arguments.length?(m=S,C):m},C.value=function(S){return arguments.length?(T=S,C):T},C.revalue=function(S){return T&&(xf(S,function(L){L.children&&(L.value=0)}),Ei(S,function(L){var N;L.children||(L.value=+T.call(C,L,L.depth)||0),(N=L.parent)&&(N.value+=L.value)})),S},C};function bf(v,m){return e.rebind(v,m,"sort","children","value"),v.nodes=v,v.links=bO,v}function xf(v,m){for(var T=[v];(v=T.pop())!=null;)if(m(v),(S=v.children)&&(C=S.length))for(var C,S;--C>=0;)T.push(S[C])}function Ei(v,m){for(var T=[v],C=[];(v=T.pop())!=null;)if(C.push(v),(N=v.children)&&(L=N.length))for(var S=-1,L,N;++SS&&(S=P),C.push(P)}for(N=0;NC&&(T=m,C=S);return T}function kO(v){return v.reduce(CO,0)}function CO(v,m){return v+m[1]}e.layout.histogram=function(){var v=!0,m=Number,T=qO,C=SO;function S(L,re){for(var P=[],I=L.map(m,this),X=T.call(this,I,re),J=C.call(this,X,I,re),ee,re=-1,_e=I.length,ke=J.length-1,te=v?1:1/_e,ve;++re0)for(re=-1;++re<_e;)ve=I[re],ve>=X[0]&&ve<=X[1]&&(ee=P[e.bisect(J,ve,1,ke)-1],ee.y+=te,ee.push(L[re]));return P}return S.value=function(L){return arguments.length?(m=L,S):m},S.range=function(L){return arguments.length?(T=Ir(L),S):T},S.bins=function(L){return arguments.length?(C=typeof L=="number"?function(N){return tx(N,L)}:Ir(L),S):C},S.frequency=function(L){return arguments.length?(v=!!L,S):v},S};function SO(v,m){return tx(v,Math.ceil(Math.log(m.length)/Math.LN2+1))}function tx(v,m){for(var T=-1,C=+v[0],S=(v[1]-C)/m,L=[];++T<=m;)L[T]=S*T+C;return L}function qO(v){return[e.min(v),e.max(v)]}e.layout.pack=function(){var v=e.layout.hierarchy().sort(LO),m=0,T=[1,1],C;function S(L,N){var P=v.call(this,L,N),I=P[0],X=T[0],J=T[1],ee=C==null?Math.sqrt:typeof C=="function"?C:function(){return C};if(I.x=I.y=0,Ei(I,function(_e){_e.r=+ee(_e.value)}),Ei(I,ix),m){var re=m*(C?1:Math.max(2*I.r/X,2*I.r/J))/2;Ei(I,function(_e){_e.r+=re}),Ei(I,ix),Ei(I,function(_e){_e.r-=re})}return lx(I,X/2,J/2,C?1:1/Math.max(2*I.r/X,2*I.r/J)),P}return S.size=function(L){return arguments.length?(T=L,S):T},S.radius=function(L){return arguments.length?(C=L==null||typeof L=="function"?L:+L,S):C},S.padding=function(L){return arguments.length?(m=+L,S):m},bf(S,v)};function LO(v,m){return v.value-m.value}function gp(v,m){var T=v._pack_next;v._pack_next=m,m._pack_prev=v,m._pack_next=T,T._pack_prev=m}function ax(v,m){v._pack_next=m,m._pack_prev=v}function nx(v,m){var T=m.x-v.x,C=m.y-v.y,S=v.r+m.r;return .999*S*S>T*T+C*C}function ix(v){if(!(m=v.children)||!(re=m.length))return;var m,T=1/0,C=-1/0,S=1/0,L=-1/0,N,P,I,X,J,ee,re;function _e(xe){T=Math.min(xe.x-xe.r,T),C=Math.max(xe.x+xe.r,C),S=Math.min(xe.y-xe.r,S),L=Math.max(xe.y+xe.r,L)}if(m.forEach(DO),N=m[0],N.x=-N.r,N.y=0,_e(N),re>1&&(P=m[1],P.x=P.r,P.y=0,_e(P),re>2))for(I=m[2],ox(N,P,I),_e(I),gp(N,I),N._pack_prev=I,gp(I,P),P=N._pack_next,X=3;Xve.x&&(ve=Ge),Ge.depth>ye.depth&&(ye=Ge)});var Ae=m(te,ve)/2-te.x,Ce=T[0]/(ve.x+m(ve,te)/2+Ae),xe=T[1]/(ye.depth||1);xf(_e,function(Ge){Ge.x=(Ge.x+Ae)*Ce,Ge.y=Ge.depth*xe})}return re}function L(J){for(var ee={A:null,children:[J]},re=[ee],_e;(_e=re.pop())!=null;)for(var ke=_e.children,te,ve=0,ye=ke.length;ve0&&(PO(zO(te,J,re),J,Ge),ye+=Ge,Ae+=Ge),Ce+=te.m,ye+=_e.m,xe+=ve.m,Ae+=ke.m;te&&!xp(ke)&&(ke.t=te,ke.m+=Ce-Ae),_e&&!bp(ve)&&(ve.t=_e,ve.m+=ye-xe,re=J)}return re}function X(J){J.x*=T[0],J.y=J.depth*T[1]}return S.separation=function(J){return arguments.length?(m=J,S):m},S.size=function(J){return arguments.length?(C=(T=J)==null?X:null,S):C?null:T},S.nodeSize=function(J){return arguments.length?(C=(T=J)==null?null:X,S):C?T:null},bf(S,v)};function sx(v,m){return v.parent==m.parent?1:2}function bp(v){var m=v.children;return m.length?m[0]:v.t}function xp(v){var m=v.children,T;return(T=m.length)?m[T-1]:v.t}function PO(v,m,T){var C=T/(m.i-v.i);m.c-=C,m.s+=T,v.c+=C,m.z+=T,m.m+=T}function RO(v){for(var m=0,T=0,C=v.children,S=C.length,L;--S>=0;)L=C[S],L.z+=m,L.m+=m,m+=L.s+(T+=L.c)}function zO(v,m,T){return v.a.parent===m.parent?v.a:T}e.layout.cluster=function(){var v=e.layout.hierarchy().sort(null).value(null),m=sx,T=[1,1],C=!1;function S(L,N){var P=v.call(this,L,N),I=P[0],X,J=0;Ei(I,function(te){var ve=te.children;ve&&ve.length?(te.x=FO(ve),te.y=NO(ve)):(te.x=X?J+=m(te,X):0,te.y=0,X=te)});var ee=ux(I),re=fx(I),_e=ee.x-m(ee,re)/2,ke=re.x+m(re,ee)/2;return Ei(I,C?function(te){te.x=(te.x-I.x)*T[0],te.y=(I.y-te.y)*T[1]}:function(te){te.x=(te.x-_e)/(ke-_e)*T[0],te.y=(1-(I.y?te.y/I.y:1))*T[1]}),P}return S.separation=function(L){return arguments.length?(m=L,S):m},S.size=function(L){return arguments.length?(C=(T=L)==null,S):C?null:T},S.nodeSize=function(L){return arguments.length?(C=(T=L)!=null,S):C?T:null},bf(S,v)};function NO(v){return 1+e.max(v,function(m){return m.y})}function FO(v){return v.reduce(function(m,T){return m+T.x},0)/v.length}function ux(v){var m=v.children;return m&&m.length?ux(m[0]):v}function fx(v){var m=v.children,T;return m&&(T=m.length)?fx(m[T-1]):v}e.layout.treemap=function(){var v=e.layout.hierarchy(),m=Math.round,T=[1,1],C=null,S=_p,L=!1,N,P="squarify",I=.5*(1+Math.sqrt(5));function X(te,ve){for(var ye=-1,Ae=te.length,Ce,xe;++ye0;)Ae.push(xe=Ce[ir-1]),Ae.area+=xe.area,P!=="squarify"||(nr=re(Ae,fr))<=Ge?(Ce.pop(),Ge=nr):(Ae.area-=Ae.pop().area,_e(Ae,fr,ye,!1),fr=Math.min(ye.dx,ye.dy),Ae.length=Ae.area=0,Ge=1/0);Ae.length&&(_e(Ae,fr,ye,!0),Ae.length=Ae.area=0),ve.forEach(J)}}function ee(te){var ve=te.children;if(ve&&ve.length){var ye=S(te),Ae=ve.slice(),Ce,xe=[];for(X(Ae,ye.dx*ye.dy/te.value),xe.area=0;Ce=Ae.pop();)xe.push(Ce),xe.area+=Ce.area,Ce.z!=null&&(_e(xe,Ce.z?ye.dx:ye.dy,ye,!Ae.length),xe.length=xe.area=0);ve.forEach(ee)}}function re(te,ve){for(var ye=te.area,Ae,Ce=0,xe=1/0,Ge=-1,nr=te.length;++GeCe&&(Ce=Ae));return ye*=ye,ve*=ve,ye?Math.max(ve*Ce*I/ye,ye/(ve*xe*I)):1/0}function _e(te,ve,ye,Ae){var Ce=-1,xe=te.length,Ge=ye.x,nr=ye.y,fr=ve?m(te.area/ve):0,ir;if(ve==ye.dx){for((Ae||fr>ye.dy)&&(fr=ye.dy);++Ceye.dx)&&(fr=ye.dx);++Ce1);return v+m*C*Math.sqrt(-2*Math.log(L)/L)}},logNormal:function(){var v=e.random.normal.apply(e,arguments);return function(){return Math.exp(v())}},bates:function(v){var m=e.random.irwinHall(v);return function(){return m()/v}},irwinHall:function(v){return function(){for(var m=0,T=0;T2?BO:IO,X=C?lO:iO;return S=I(v,m,X,T),L=I(m,v,X,xo),P}function P(I){return S(I)}return P.invert=function(I){return L(I)},P.domain=function(I){return arguments.length?(v=I.map(Number),N()):v},P.range=function(I){return arguments.length?(m=I,N()):m},P.rangeRound=function(I){return P.range(I).interpolate(Xb)},P.clamp=function(I){return arguments.length?(C=I,N()):C},P.interpolate=function(I){return arguments.length?(T=I,N()):T},P.ticks=function(I){return Mp(v,I)},P.tickFormat=function(I,X){return d3_scale_linearTickFormat(v,I,X)},P.nice=function(I){return px(v,I),N()},P.copy=function(){return hx(v,m,T,C)},N()}function dx(v,m){return e.rebind(v,m,"range","rangeRound","interpolate","clamp")}function px(v,m){return wp(v,vx(Tp(v,m)[2])),wp(v,vx(Tp(v,m)[2])),v}function Tp(v,m){m==null&&(m=10);var T=kv(v),C=T[1]-T[0],S=Math.pow(10,Math.floor(Math.log(C/m)/Math.LN10)),L=m/C*S;return L<=.15?S*=10:L<=.35?S*=5:L<=.75&&(S*=2),T[0]=Math.ceil(T[0]/S)*S,T[1]=Math.floor(T[1]/S)*S+S*.5,T[2]=S,T}function Mp(v,m){return e.range.apply(e,Tp(v,m))}var OO={s:1,g:1,p:1,r:1,e:1};function mx(v){return-Math.floor(Math.log(v)/Math.LN10+.01)}function dce(v,m){var T=mx(m[2]);return v in OO?Math.abs(T-mx(Math.max(x(m[0]),x(m[1]))))+ +(v!=="e"):T-(v==="%")*2}e.scale.log=function(){return yx(e.scale.linear().domain([0,1]),10,!0,[1,10])};function yx(v,m,T,C){function S(P){return(T?Math.log(P<0?0:P):-Math.log(P>0?0:-P))/Math.log(m)}function L(P){return T?Math.pow(m,P):-Math.pow(m,-P)}function N(P){return v(S(P))}return N.invert=function(P){return L(v.invert(P))},N.domain=function(P){return arguments.length?(T=P[0]>=0,v.domain((C=P.map(Number)).map(S)),N):C},N.base=function(P){return arguments.length?(m=+P,v.domain(C.map(S)),N):m},N.nice=function(){var P=wp(C.map(S),T?Math:YO);return v.domain(P),C=P.map(L),N},N.ticks=function(){var P=kv(C),I=[],X=P[0],J=P[1],ee=Math.floor(S(X)),re=Math.ceil(S(J)),_e=m%1?2:m;if(isFinite(re-ee)){if(T){for(;ee0;ke--)I.push(L(ee)*ke);for(ee=0;I[ee]J;re--);I=I.slice(ee,re)}return I},N.copy=function(){return yx(v.copy(),m,T,C)},dx(N,v)}var YO={floor:function(v){return-Math.ceil(-v)},ceil:function(v){return-Math.floor(-v)}};e.scale.pow=function(){return gx(e.scale.linear(),1,[0,1])};function gx(v,m,T){var C=Sv(m),S=Sv(1/m);function L(N){return v(C(N))}return L.invert=function(N){return S(v.invert(N))},L.domain=function(N){return arguments.length?(v.domain((T=N.map(Number)).map(C)),L):T},L.ticks=function(N){return Mp(T,N)},L.tickFormat=function(N,P){return d3_scale_linearTickFormat(T,N,P)},L.nice=function(N){return L.domain(px(T,N))},L.exponent=function(N){return arguments.length?(C=Sv(m=N),S=Sv(1/m),v.domain(T.map(C)),L):m},L.copy=function(){return gx(v.copy(),m,T)},dx(L,v)}function Sv(v){return function(m){return m<0?-Math.pow(-m,v):Math.pow(m,v)}}e.scale.sqrt=function(){return e.scale.pow().exponent(.5)},e.scale.ordinal=function(){return bx([],{t:"range",a:[[]]})};function bx(v,m){var T,C,S;function L(P){return C[((T.get(P)||(m.t==="range"?T.set(P,v.push(P)):NaN))-1)%C.length]}function N(P,I){return e.range(v.length).map(function(X){return P+I*X})}return L.domain=function(P){if(!arguments.length)return v;v=[],T=new b;for(var I=-1,X=P.length,J;++I0?T[L-1]:v[0],Lre?0:1;if(J=or)return I(J,ke)+(X?I(X,1-ke):"")+"Z";var te,ve,ye,Ae,Ce=0,xe=0,Ge,nr,fr,ir,dr,pr,Sr,yr,Mr=[];if((Ae=(+N.apply(this,arguments)||0)/2)&&(ye=C===qv?Math.sqrt(X*X+J*J):+C.apply(this,arguments),ke||(xe*=-1),J&&(xe=ht(ye/J*Math.sin(Ae))),X&&(Ce=ht(ye/X*Math.sin(Ae)))),J){Ge=J*Math.cos(ee+xe),nr=J*Math.sin(ee+xe),fr=J*Math.cos(re-xe),ir=J*Math.sin(re-xe);var et=Math.abs(re-ee-2*xe)<=qe?0:1;if(xe&&Lv(Ge,nr,fr,ir)===ke^et){var Gt=(ee+re)/2;Ge=J*Math.cos(Gt),nr=J*Math.sin(Gt),fr=ir=null}}else Ge=nr=0;if(X){dr=X*Math.cos(re-Ce),pr=X*Math.sin(re-Ce),Sr=X*Math.cos(ee+Ce),yr=X*Math.sin(ee+Ce);var ft=Math.abs(ee-re+2*Ce)<=qe?0:1;if(Ce&&Lv(dr,pr,Sr,yr)===1-ke^ft){var je=(ee+re)/2;dr=X*Math.cos(je),pr=X*Math.sin(je),Sr=yr=null}}else dr=pr=0;if(_e>he&&(te=Math.min(Math.abs(J-X)/2,+T.apply(this,arguments)))>.001){ve=X0?0:1}function Dv(v,m,T,C,S){var L=v[0]-m[0],N=v[1]-m[1],P=(S?C:-C)/Math.sqrt(L*L+N*N),I=P*N,X=-P*L,J=v[0]+I,ee=v[1]+X,re=m[0]+I,_e=m[1]+X,ke=(J+re)/2,te=(ee+_e)/2,ve=re-J,ye=_e-ee,Ae=ve*ve+ye*ye,Ce=T-C,xe=J*_e-re*ee,Ge=(ye<0?-1:1)*Math.sqrt(Math.max(0,Ce*Ce*Ae-xe*xe)),nr=(xe*ye-ve*Ge)/Ae,fr=(-xe*ve-ye*Ge)/Ae,ir=(xe*ye+ve*Ge)/Ae,dr=(-xe*ve+ye*Ge)/Ae,pr=nr-ke,Sr=fr-te,yr=ir-ke,Mr=dr-te;return pr*pr+Sr*Sr>yr*yr+Mr*Mr&&(nr=ir,fr=dr),[[nr-I,fr-X],[nr*T/Ce,fr*T/Ce]]}function kx(){return!0}function Cx(v){var m=Ss,T=hf,C=kx,S=li,L=S.key,N=.7;function P(I){var X=[],J=[],ee=-1,re=I.length,_e,ke=Ir(m),te=Ir(T);function ve(){X.push("M",S(v(J),N))}for(;++ee1?v.join("L"):v+"Z"}function Sx(v){return v.join("L")+"Z"}function QO(v){for(var m=0,T=v.length,C=v[0],S=[C[0],",",C[1]];++m1&&S.push("H",C[0]),S.join("")}function kp(v){for(var m=0,T=v.length,C=v[0],S=[C[0],",",C[1]];++m1){P=m[1],L=v[I],I++,C+="C"+(S[0]+N[0])+","+(S[1]+N[1])+","+(L[0]-P[0])+","+(L[1]-P[1])+","+L[0]+","+L[1];for(var X=2;X9&&(L=T*3/Math.sqrt(L),N[P]=L*C,N[P+1]=L*S));for(P=-1;++P<=I;)L=(v[Math.min(I,P+1)][0]-v[Math.max(0,P-1)][0])/(6*(1+N[P]*N[P])),m.push([L||0,N[P]*L||0]);return m}function lY(v){return v.length<3?li(v):v[0]+Ev(v,iY(v))}e.svg.line.radial=function(){var v=Cx(Ex);return v.radius=v.x,delete v.x,v.angle=v.y,delete v.y,v};function Ex(v){for(var m,T=-1,C=v.length,S,L;++Tqe)+",1 "+ee}function X(J,ee,re,_e){return"Q 0,0 "+_e}return L.radius=function(J){return arguments.length?(T=Ir(J),L):T},L.source=function(J){return arguments.length?(v=Ir(J),L):v},L.target=function(J){return arguments.length?(m=Ir(J),L):m},L.startAngle=function(J){return arguments.length?(C=Ir(J),L):C},L.endAngle=function(J){return arguments.length?(S=Ir(J),L):S},L};function oY(v){return v.radius}e.svg.diagonal=function(){var v=Rx,m=zx,T=Nx;function C(S,L){var N=v.call(this,S,L),P=m.call(this,S,L),I=(N.y+P.y)/2,X=[N,{x:N.x,y:I},{x:P.x,y:I},P];return X=X.map(T),"M"+X[0]+"C"+X[1]+" "+X[2]+" "+X[3]}return C.source=function(S){return arguments.length?(v=Ir(S),C):v},C.target=function(S){return arguments.length?(m=Ir(S),C):m},C.projection=function(S){return arguments.length?(T=S,C):T},C};function Nx(v){return[v.x,v.y]}e.svg.diagonal.radial=function(){var v=e.svg.diagonal(),m=Nx,T=v.projection;return v.projection=function(C){return arguments.length?T(sY(m=C)):m},v};function sY(v){return function(){var m=v.apply(this,arguments),T=m[0],C=m[1]-ar;return[T*Math.cos(C),T*Math.sin(C)]}}e.svg.symbol=function(){var v=fY,m=uY;function T(C,S){return(Ix.get(v.call(this,C,S))||Fx)(m.call(this,C,S))}return T.type=function(C){return arguments.length?(v=Ir(C),T):v},T.size=function(C){return arguments.length?(m=Ir(C),T):m},T};function uY(){return 64}function fY(){return"circle"}function Fx(v){var m=Math.sqrt(v/qe);return"M0,"+m+"A"+m+","+m+" 0 1,1 0,"+-m+"A"+m+","+m+" 0 1,1 0,"+m+"Z"}var Ix=e.map({circle:Fx,cross:function(v){var m=Math.sqrt(v/5)/2;return"M"+-3*m+","+-m+"H"+-m+"V"+-3*m+"H"+m+"V"+-m+"H"+3*m+"V"+m+"H"+m+"V"+3*m+"H"+-m+"V"+m+"H"+-3*m+"Z"},diamond:function(v){var m=Math.sqrt(v/(2*Hx)),T=m*Hx;return"M0,"+-m+"L"+T+",0 0,"+m+" "+-T+",0Z"},square:function(v){var m=Math.sqrt(v)/2;return"M"+-m+","+-m+"L"+m+","+-m+" "+m+","+m+" "+-m+","+m+"Z"},"triangle-down":function(v){var m=Math.sqrt(v/Pv),T=m*Pv/2;return"M0,"+T+"L"+m+","+-T+" "+-m+","+-T+"Z"},"triangle-up":function(v){var m=Math.sqrt(v/Pv),T=m*Pv/2;return"M0,"+-T+"L"+m+","+T+" "+-m+","+T+"Z"}});e.svg.symbolTypes=Ix.keys();var Pv=Math.sqrt(3),Hx=Math.tan(30*Tr);de.transition=function(v){for(var m=_o||++Ox,T=Ep(v),C=[],S,L,N=zv||{time:Date.now(),ease:YB,delay:0,duration:250},P=-1,I=this.length;++P0;)ee[--Ae].call(v,ye);if(ve>=1)return N.event&&N.event.end.call(v,v.__data__,m),--L.count?delete L[C]:delete v[T],1}N||(P=S.time,I=bv(re,0,P),N=L[C]={tween:new b,time:P,timer:I,delay:S.delay,duration:S.duration,ease:S.ease,index:m},S=null,++L.count)}e.svg.axis=function(){var v=e.scale.linear(),m=Yx,T=6,C=6,S=3,L=[10],N=null,P;function I(X){X.each(function(){var J=e.select(this),ee=this.__chart__||v,re=this.__chart__=v.copy(),_e=N==null?re.ticks?re.ticks.apply(re,L):re.domain():N,ke=P==null?re.tickFormat?re.tickFormat.apply(re,L):W:P,te=J.selectAll(".tick").data(_e,re),ve=te.enter().insert("g",".domain").attr("class","tick").style("opacity",he),ye=e.transition(te.exit()).style("opacity",he).remove(),Ae=e.transition(te.order()).style("opacity",1),Ce=Math.max(T,0)+S,xe,Ge=Cv(re),nr=J.selectAll(".domain").data([0]),fr=(nr.enter().append("path").attr("class","domain"),e.transition(nr));ve.append("line"),ve.append("text");var ir=ve.select("line"),dr=Ae.select("line"),pr=te.select("text").text(ke),Sr=ve.select("text"),yr=Ae.select("text"),Mr=m==="top"||m==="left"?-1:1,et,Gt,ft,je;if(m==="bottom"||m==="top"?(xe=hY,et="x",ft="y",Gt="x2",je="y2",pr.attr("dy",Mr<0?"0em":".71em").style("text-anchor","middle"),fr.attr("d","M"+Ge[0]+","+Mr*C+"V0H"+Ge[1]+"V"+Mr*C)):(xe=dY,et="y",ft="x",Gt="y2",je="x2",pr.attr("dy",".32em").style("text-anchor",Mr<0?"end":"start"),fr.attr("d","M"+Mr*C+","+Ge[0]+"H0V"+Ge[1]+"H"+Mr*C)),ir.attr(je,Mr*T),Sr.attr(ft,Mr*Ce),dr.attr(Gt,0).attr(je,Mr*T),yr.attr(et,0).attr(ft,Mr*Ce),re.rangeBand){var qr=re,Dr=qr.rangeBand()/2;ee=re=function(rt){return qr(rt)+Dr}}else ee.rangeBand?ee=re:ye.call(xe,re,ee);ve.call(xe,ee,re),Ae.call(xe,re,re)})}return I.scale=function(X){return arguments.length?(v=X,I):v},I.orient=function(X){return arguments.length?(m=X in vY?X+"":Yx,I):m},I.ticks=function(){return arguments.length?(L=t(arguments),I):L},I.tickValues=function(X){return arguments.length?(N=X,I):N},I.tickFormat=function(X){return arguments.length?(P=X,I):P},I.tickSize=function(X){var J=arguments.length;return J?(T=+X,C=+arguments[J-1],I):T},I.innerTickSize=function(X){return arguments.length?(T=+X,I):T},I.outerTickSize=function(X){return arguments.length?(C=+X,I):C},I.tickPadding=function(X){return arguments.length?(S=+X,I):S},I.tickSubdivide=function(){return arguments.length&&I},I};var Yx="bottom",vY={top:1,right:1,bottom:1,left:1};function hY(v,m,T){v.attr("transform",function(C){var S=m(C);return"translate("+(isFinite(S)?S:T(C))+",0)"})}function dY(v,m,T){v.attr("transform",function(C){var S=m(C);return"translate(0,"+(isFinite(S)?S:T(C))+")"})}e.svg.brush=function(){var v=oe(J,"brushstart","brush","brushend"),m=null,T=null,C=[0,0],S=[0,0],L,N,P=!0,I=!0,X=Pp[0];function J(te){te.each(function(){var ve=e.select(this).style("pointer-events","all").style("-webkit-tap-highlight-color","rgba(0,0,0,0)").on("mousedown.brush",ke).on("touchstart.brush",ke),ye=ve.selectAll(".background").data([0]);ye.enter().append("rect").attr("class","background").style("visibility","hidden").style("cursor","crosshair"),ve.selectAll(".extent").data([0]).enter().append("rect").attr("class","extent").style("cursor","move");var Ae=ve.selectAll(".resize").data(X,W);Ae.exit().remove(),Ae.enter().append("g").attr("class",function(nr){return"resize "+nr}).style("cursor",function(nr){return pY[nr]}).append("rect").attr("x",function(nr){return/[ew]$/.test(nr)?-3:null}).attr("y",function(nr){return/^[ns]/.test(nr)?-3:null}).attr("width",6).attr("height",6).style("visibility","hidden"),Ae.style("display",J.empty()?"none":null);var Ce=e.transition(ve),xe=e.transition(ye),Ge;m&&(Ge=Cv(m),xe.attr("x",Ge[0]).attr("width",Ge[1]-Ge[0]),re(Ce)),T&&(Ge=Cv(T),xe.attr("y",Ge[0]).attr("height",Ge[1]-Ge[0]),_e(Ce)),ee(Ce)})}J.event=function(te){te.each(function(){var ve=v.of(this,arguments),ye={x:C,y:S,i:L,j:N},Ae=this.__chart__||ye;this.__chart__=ye,_o?e.select(this).transition().each("start.brush",function(){L=Ae.i,N=Ae.j,C=Ae.x,S=Ae.y,ve({type:"brushstart"})}).tween("brush:brush",function(){var Ce=Av(C,ye.x),xe=Av(S,ye.y);return L=N=null,function(Ge){C=ye.x=Ce(Ge),S=ye.y=xe(Ge),ve({type:"brush",mode:"resize"})}}).each("end.brush",function(){L=ye.i,N=ye.j,ve({type:"brush",mode:"resize"}),ve({type:"brushend"})}):(ve({type:"brushstart"}),ve({type:"brush",mode:"resize"}),ve({type:"brushend"}))})};function ee(te){te.selectAll(".resize").attr("transform",function(ve){return"translate("+C[+/e$/.test(ve)]+","+S[+/^s/.test(ve)]+")"})}function re(te){te.select(".extent").attr("x",C[0]),te.selectAll(".extent,.n>rect,.s>rect").attr("width",C[1]-C[0])}function _e(te){te.select(".extent").attr("y",S[0]),te.selectAll(".extent,.e>rect,.w>rect").attr("height",S[1]-S[0])}function ke(){var te=this,ve=e.select(e.event.target),ye=v.of(te,arguments),Ae=e.select(te),Ce=ve.datum(),xe=!/^(n|s)$/.test(Ce)&&m,Ge=!/^(e|w)$/.test(Ce)&&T,nr=ve.classed("extent"),fr=hr(te),ir,dr=e.mouse(te),pr,Sr=e.select(i(te)).on("keydown.brush",et).on("keyup.brush",Gt);if(e.event.changedTouches?Sr.on("touchmove.brush",ft).on("touchend.brush",qr):Sr.on("mousemove.brush",ft).on("mouseup.brush",qr),Ae.interrupt().selectAll("*").interrupt(),nr)dr[0]=C[0]-dr[0],dr[1]=S[0]-dr[1];else if(Ce){var yr=+/w$/.test(Ce),Mr=+/^n/.test(Ce);pr=[C[1-yr]-dr[0],S[1-Mr]-dr[1]],dr[0]=C[yr],dr[1]=S[Mr]}else e.event.altKey&&(ir=dr.slice());Ae.style("pointer-events","none").selectAll(".resize").style("display",null),e.select("body").style("cursor",ve.style("cursor")),ye({type:"brushstart"}),ft();function et(){e.event.keyCode==32&&(nr||(ir=null,dr[0]-=C[1],dr[1]-=S[1],nr=2),ae())}function Gt(){e.event.keyCode==32&&nr==2&&(dr[0]+=C[1],dr[1]+=S[1],nr=0,ae())}function ft(){var Dr=e.mouse(te),rt=!1;pr&&(Dr[0]+=pr[0],Dr[1]+=pr[1]),nr||(e.event.altKey?(ir||(ir=[(C[0]+C[1])/2,(S[0]+S[1])/2]),dr[0]=C[+(Dr[0]{(function(e,r){typeof Ov=="object"&&typeof Zx!="undefined"?r(Ov):(e=e||self,r(e.d3=e.d3||{}))})(Ov,function(e){"use strict";var r=new Date,t=new Date;function a(le,Me,We,sr){function lr(se){return le(se=arguments.length===0?new Date:new Date(+se)),se}return lr.floor=function(se){return le(se=new Date(+se)),se},lr.ceil=function(se){return le(se=new Date(se-1)),Me(se,1),le(se),se},lr.round=function(se){var Se=lr(se),He=lr.ceil(se);return se-Se0))return Ze;do Ze.push(Ye=new Date(+se)),Me(se,He),le(se);while(Ye=Se)for(;le(Se),!se(Se);)Se.setTime(Se-1)},function(Se,He){if(Se>=Se)if(He<0)for(;++He<=0;)for(;Me(Se,-1),!se(Se););else for(;--He>=0;)for(;Me(Se,1),!se(Se););})},We&&(lr.count=function(se,Se){return r.setTime(+se),t.setTime(+Se),le(r),le(t),Math.floor(We(r,t))},lr.every=function(se){return se=Math.floor(se),!isFinite(se)||!(se>0)?null:se>1?lr.filter(sr?function(Se){return sr(Se)%se===0}:function(Se){return lr.count(0,Se)%se===0}):lr}),lr}var n=a(function(){},function(le,Me){le.setTime(+le+Me)},function(le,Me){return Me-le});n.every=function(le){return le=Math.floor(le),!isFinite(le)||!(le>0)?null:le>1?a(function(Me){Me.setTime(Math.floor(Me/le)*le)},function(Me,We){Me.setTime(+Me+We*le)},function(Me,We){return(We-Me)/le}):n};var i=n.range,l=1e3,o=6e4,s=36e5,u=864e5,f=6048e5,c=a(function(le){le.setTime(le-le.getMilliseconds())},function(le,Me){le.setTime(+le+Me*l)},function(le,Me){return(Me-le)/l},function(le){return le.getUTCSeconds()}),h=c.range,d=a(function(le){le.setTime(le-le.getMilliseconds()-le.getSeconds()*l)},function(le,Me){le.setTime(+le+Me*o)},function(le,Me){return(Me-le)/o},function(le){return le.getMinutes()}),p=d.range,y=a(function(le){le.setTime(le-le.getMilliseconds()-le.getSeconds()*l-le.getMinutes()*o)},function(le,Me){le.setTime(+le+Me*s)},function(le,Me){return(Me-le)/s},function(le){return le.getHours()}),g=y.range,x=a(function(le){le.setHours(0,0,0,0)},function(le,Me){le.setDate(le.getDate()+Me)},function(le,Me){return(Me-le-(Me.getTimezoneOffset()-le.getTimezoneOffset())*o)/u},function(le){return le.getDate()-1}),_=x.range;function M(le){return a(function(Me){Me.setDate(Me.getDate()-(Me.getDay()+7-le)%7),Me.setHours(0,0,0,0)},function(Me,We){Me.setDate(Me.getDate()+We*7)},function(Me,We){return(We-Me-(We.getTimezoneOffset()-Me.getTimezoneOffset())*o)/f})}var b=M(0),w=M(1),k=M(2),A=M(3),q=M(4),D=M(5),E=M(6),R=b.range,z=w.range,F=k.range,H=A.range,W=q.range,Z=D.range,Y=E.range,B=a(function(le){le.setDate(1),le.setHours(0,0,0,0)},function(le,Me){le.setMonth(le.getMonth()+Me)},function(le,Me){return Me.getMonth()-le.getMonth()+(Me.getFullYear()-le.getFullYear())*12},function(le){return le.getMonth()}),U=B.range,K=a(function(le){le.setMonth(0,1),le.setHours(0,0,0,0)},function(le,Me){le.setFullYear(le.getFullYear()+Me)},function(le,Me){return Me.getFullYear()-le.getFullYear()},function(le){return le.getFullYear()});K.every=function(le){return!isFinite(le=Math.floor(le))||!(le>0)?null:a(function(Me){Me.setFullYear(Math.floor(Me.getFullYear()/le)*le),Me.setMonth(0,1),Me.setHours(0,0,0,0)},function(Me,We){Me.setFullYear(Me.getFullYear()+We*le)})};var Q=K.range,ae=a(function(le){le.setUTCSeconds(0,0)},function(le,Me){le.setTime(+le+Me*o)},function(le,Me){return(Me-le)/o},function(le){return le.getUTCMinutes()}),fe=ae.range,oe=a(function(le){le.setUTCMinutes(0,0,0)},function(le,Me){le.setTime(+le+Me*s)},function(le,Me){return(Me-le)/s},function(le){return le.getUTCHours()}),ce=oe.range,$=a(function(le){le.setUTCHours(0,0,0,0)},function(le,Me){le.setUTCDate(le.getUTCDate()+Me)},function(le,Me){return(Me-le)/u},function(le){return le.getUTCDate()-1}),Te=$.range;function ue(le){return a(function(Me){Me.setUTCDate(Me.getUTCDate()-(Me.getUTCDay()+7-le)%7),Me.setUTCHours(0,0,0,0)},function(Me,We){Me.setUTCDate(Me.getUTCDate()+We*7)},function(Me,We){return(We-Me)/f})}var me=ue(0),ie=ue(1),de=ue(2),O=ue(3),j=ue(4),V=ue(5),pe=ue(6),we=me.range,ge=ie.range,Pe=de.range,Ne=O.range,Ee=j.range,Fe=V.range,Ue=pe.range,Oe=a(function(le){le.setUTCDate(1),le.setUTCHours(0,0,0,0)},function(le,Me){le.setUTCMonth(le.getUTCMonth()+Me)},function(le,Me){return Me.getUTCMonth()-le.getUTCMonth()+(Me.getUTCFullYear()-le.getUTCFullYear())*12},function(le){return le.getUTCMonth()}),Le=Oe.range,Ie=a(function(le){le.setUTCMonth(0,1),le.setUTCHours(0,0,0,0)},function(le,Me){le.setUTCFullYear(le.getUTCFullYear()+Me)},function(le,Me){return Me.getUTCFullYear()-le.getUTCFullYear()},function(le){return le.getUTCFullYear()});Ie.every=function(le){return!isFinite(le=Math.floor(le))||!(le>0)?null:a(function(Me){Me.setUTCFullYear(Math.floor(Me.getUTCFullYear()/le)*le),Me.setUTCMonth(0,1),Me.setUTCHours(0,0,0,0)},function(Me,We){Me.setUTCFullYear(Me.getUTCFullYear()+We*le)})};var Be=Ie.range;e.timeDay=x,e.timeDays=_,e.timeFriday=D,e.timeFridays=Z,e.timeHour=y,e.timeHours=g,e.timeInterval=a,e.timeMillisecond=n,e.timeMilliseconds=i,e.timeMinute=d,e.timeMinutes=p,e.timeMonday=w,e.timeMondays=z,e.timeMonth=B,e.timeMonths=U,e.timeSaturday=E,e.timeSaturdays=Y,e.timeSecond=c,e.timeSeconds=h,e.timeSunday=b,e.timeSundays=R,e.timeThursday=q,e.timeThursdays=W,e.timeTuesday=k,e.timeTuesdays=F,e.timeWednesday=A,e.timeWednesdays=H,e.timeWeek=b,e.timeWeeks=R,e.timeYear=K,e.timeYears=Q,e.utcDay=$,e.utcDays=Te,e.utcFriday=V,e.utcFridays=Fe,e.utcHour=oe,e.utcHours=ce,e.utcMillisecond=n,e.utcMilliseconds=i,e.utcMinute=ae,e.utcMinutes=fe,e.utcMonday=ie,e.utcMondays=ge,e.utcMonth=Oe,e.utcMonths=Le,e.utcSaturday=pe,e.utcSaturdays=Ue,e.utcSecond=c,e.utcSeconds=h,e.utcSunday=me,e.utcSundays=we,e.utcThursday=j,e.utcThursdays=Ee,e.utcTuesday=de,e.utcTuesdays=Pe,e.utcWednesday=O,e.utcWednesdays=Ne,e.utcWeek=me,e.utcWeeks=we,e.utcYear=Ie,e.utcYears=Be,Object.defineProperty(e,"__esModule",{value:!0})})});var zs=G((Yv,Xx)=>{(function(e,r){typeof Yv=="object"&&typeof Xx!="undefined"?r(Yv,Rp()):(e=e||self,r(e.d3=e.d3||{},e.d3))})(Yv,function(e,r){"use strict";function t(ne){if(0<=ne.y&&ne.y<100){var he=new Date(-1,ne.m,ne.d,ne.H,ne.M,ne.S,ne.L);return he.setFullYear(ne.y),he}return new Date(ne.y,ne.m,ne.d,ne.H,ne.M,ne.S,ne.L)}function a(ne){if(0<=ne.y&&ne.y<100){var he=new Date(Date.UTC(-1,ne.m,ne.d,ne.H,ne.M,ne.S,ne.L));return he.setUTCFullYear(ne.y),he}return new Date(Date.UTC(ne.y,ne.m,ne.d,ne.H,ne.M,ne.S,ne.L))}function n(ne,he,De){return{y:ne,m:he,d:De,H:0,M:0,S:0,L:0}}function i(ne){var he=ne.dateTime,De=ne.date,qe=ne.time,tr=ne.periods,or=ne.days,ar=ne.shortDays,Tr=ne.months,Fr=ne.shortMonths,Vr=h(tr),Ur=d(tr),zt=h(or),ht=d(or),dt=h(ar),st=d(ar),Bt=h(Tr),Ot=d(Tr),qt=h(Fr),_a=d(Fr),br={a:$i,A:ji,b:it,B:tn,c:null,d:B,e:B,f:fe,H:U,I:K,j:Q,L:ae,m:oe,M:ce,p:kt,q:yo,Q:Se,s:He,S:$,u:Te,U:ue,V:me,w:ie,W:de,x:null,X:null,y:O,Y:j,Z:V,"%":se},ut={a:Ka,A:ai,b:Nt,B:ni,c:null,d:pe,e:pe,f:Ee,H:we,I:ge,j:Pe,L:Ne,m:Fe,M:Ue,p:Ml,q:Al,Q:Se,s:He,S:Oe,u:Le,U:Ie,V:Be,w:le,W:Me,x:null,X:null,y:We,Y:sr,Z:lr,"%":se},Pr={a:oa,A:Yt,b:Ia,B:rn,c:Ut,d:q,e:q,f:H,H:E,I:E,j:D,L:F,m:A,M:R,p:nt,q:k,Q:Z,s:Y,S:z,u:y,U:g,V:x,w:p,W:_,x:at,X:sa,y:b,Y:M,Z:w,"%":W};br.x=tt(De,br),br.X=tt(qe,br),br.c=tt(he,br),ut.x=tt(De,ut),ut.X=tt(qe,ut),ut.c=tt(he,ut);function tt(mr,Xr){return function(lt){var ur=[],wa=-1,mt=0,Ta=mr.length,Ir,ii,go;for(lt instanceof Date||(lt=new Date(+lt));++wa53)return null;"w"in ur||(ur.w=1),"Z"in ur?(mt=a(n(ur.y,0,1)),Ta=mt.getUTCDay(),mt=Ta>4||Ta===0?r.utcMonday.ceil(mt):r.utcMonday(mt),mt=r.utcDay.offset(mt,(ur.V-1)*7),ur.y=mt.getUTCFullYear(),ur.m=mt.getUTCMonth(),ur.d=mt.getUTCDate()+(ur.w+6)%7):(mt=t(n(ur.y,0,1)),Ta=mt.getDay(),mt=Ta>4||Ta===0?r.timeMonday.ceil(mt):r.timeMonday(mt),mt=r.timeDay.offset(mt,(ur.V-1)*7),ur.y=mt.getFullYear(),ur.m=mt.getMonth(),ur.d=mt.getDate()+(ur.w+6)%7)}else("W"in ur||"U"in ur)&&("w"in ur||(ur.w="u"in ur?ur.u%7:"W"in ur?1:0),Ta="Z"in ur?a(n(ur.y,0,1)).getUTCDay():t(n(ur.y,0,1)).getDay(),ur.m=0,ur.d="W"in ur?(ur.w+6)%7+ur.W*7-(Ta+5)%7:ur.w+ur.U*7-(Ta+6)%7);return"Z"in ur?(ur.H+=ur.Z/100|0,ur.M+=ur.Z%100,a(ur)):t(ur)}}function $r(mr,Xr,lt,ur){for(var wa=0,mt=Xr.length,Ta=lt.length,Ir,ii;wa=Ta)return-1;if(Ir=Xr.charCodeAt(wa++),Ir===37){if(Ir=Xr.charAt(wa++),ii=Pr[Ir in l?Xr.charAt(wa++):Ir],!ii||(ur=ii(mr,lt,ur))<0)return-1}else if(Ir!=lt.charCodeAt(ur++))return-1}return ur}function nt(mr,Xr,lt){var ur=Vr.exec(Xr.slice(lt));return ur?(mr.p=Ur[ur[0].toLowerCase()],lt+ur[0].length):-1}function oa(mr,Xr,lt){var ur=dt.exec(Xr.slice(lt));return ur?(mr.w=st[ur[0].toLowerCase()],lt+ur[0].length):-1}function Yt(mr,Xr,lt){var ur=zt.exec(Xr.slice(lt));return ur?(mr.w=ht[ur[0].toLowerCase()],lt+ur[0].length):-1}function Ia(mr,Xr,lt){var ur=qt.exec(Xr.slice(lt));return ur?(mr.m=_a[ur[0].toLowerCase()],lt+ur[0].length):-1}function rn(mr,Xr,lt){var ur=Bt.exec(Xr.slice(lt));return ur?(mr.m=Ot[ur[0].toLowerCase()],lt+ur[0].length):-1}function Ut(mr,Xr,lt){return $r(mr,he,Xr,lt)}function at(mr,Xr,lt){return $r(mr,De,Xr,lt)}function sa(mr,Xr,lt){return $r(mr,qe,Xr,lt)}function $i(mr){return ar[mr.getDay()]}function ji(mr){return or[mr.getDay()]}function it(mr){return Fr[mr.getMonth()]}function tn(mr){return Tr[mr.getMonth()]}function kt(mr){return tr[+(mr.getHours()>=12)]}function yo(mr){return 1+~~(mr.getMonth()/3)}function Ka(mr){return ar[mr.getUTCDay()]}function ai(mr){return or[mr.getUTCDay()]}function Nt(mr){return Fr[mr.getUTCMonth()]}function ni(mr){return Tr[mr.getUTCMonth()]}function Ml(mr){return tr[+(mr.getUTCHours()>=12)]}function Al(mr){return 1+~~(mr.getUTCMonth()/3)}return{format:function(mr){var Xr=tt(mr+="",br);return Xr.toString=function(){return mr},Xr},parse:function(mr){var Xr=At(mr+="",!1);return Xr.toString=function(){return mr},Xr},utcFormat:function(mr){var Xr=tt(mr+="",ut);return Xr.toString=function(){return mr},Xr},utcParse:function(mr){var Xr=At(mr+="",!0);return Xr.toString=function(){return mr},Xr}}}var l={"-":"",_:" ",0:"0"},o=/^\s*\d+/,s=/^%/,u=/[\\^$*+?|[\]().{}]/g;function f(ne,he,De){var qe=ne<0?"-":"",tr=(qe?-ne:ne)+"",or=tr.length;return qe+(or68?1900:2e3),De+qe[0].length):-1}function w(ne,he,De){var qe=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(he.slice(De,De+6));return qe?(ne.Z=qe[1]?0:-(qe[2]+(qe[3]||"00")),De+qe[0].length):-1}function k(ne,he,De){var qe=o.exec(he.slice(De,De+1));return qe?(ne.q=qe[0]*3-3,De+qe[0].length):-1}function A(ne,he,De){var qe=o.exec(he.slice(De,De+2));return qe?(ne.m=qe[0]-1,De+qe[0].length):-1}function q(ne,he,De){var qe=o.exec(he.slice(De,De+2));return qe?(ne.d=+qe[0],De+qe[0].length):-1}function D(ne,he,De){var qe=o.exec(he.slice(De,De+3));return qe?(ne.m=0,ne.d=+qe[0],De+qe[0].length):-1}function E(ne,he,De){var qe=o.exec(he.slice(De,De+2));return qe?(ne.H=+qe[0],De+qe[0].length):-1}function R(ne,he,De){var qe=o.exec(he.slice(De,De+2));return qe?(ne.M=+qe[0],De+qe[0].length):-1}function z(ne,he,De){var qe=o.exec(he.slice(De,De+2));return qe?(ne.S=+qe[0],De+qe[0].length):-1}function F(ne,he,De){var qe=o.exec(he.slice(De,De+3));return qe?(ne.L=+qe[0],De+qe[0].length):-1}function H(ne,he,De){var qe=o.exec(he.slice(De,De+6));return qe?(ne.L=Math.floor(qe[0]/1e3),De+qe[0].length):-1}function W(ne,he,De){var qe=s.exec(he.slice(De,De+1));return qe?De+qe[0].length:-1}function Z(ne,he,De){var qe=o.exec(he.slice(De));return qe?(ne.Q=+qe[0],De+qe[0].length):-1}function Y(ne,he,De){var qe=o.exec(he.slice(De));return qe?(ne.s=+qe[0],De+qe[0].length):-1}function B(ne,he){return f(ne.getDate(),he,2)}function U(ne,he){return f(ne.getHours(),he,2)}function K(ne,he){return f(ne.getHours()%12||12,he,2)}function Q(ne,he){return f(1+r.timeDay.count(r.timeYear(ne),ne),he,3)}function ae(ne,he){return f(ne.getMilliseconds(),he,3)}function fe(ne,he){return ae(ne,he)+"000"}function oe(ne,he){return f(ne.getMonth()+1,he,2)}function ce(ne,he){return f(ne.getMinutes(),he,2)}function $(ne,he){return f(ne.getSeconds(),he,2)}function Te(ne){var he=ne.getDay();return he===0?7:he}function ue(ne,he){return f(r.timeSunday.count(r.timeYear(ne)-1,ne),he,2)}function me(ne,he){var De=ne.getDay();return ne=De>=4||De===0?r.timeThursday(ne):r.timeThursday.ceil(ne),f(r.timeThursday.count(r.timeYear(ne),ne)+(r.timeYear(ne).getDay()===4),he,2)}function ie(ne){return ne.getDay()}function de(ne,he){return f(r.timeMonday.count(r.timeYear(ne)-1,ne),he,2)}function O(ne,he){return f(ne.getFullYear()%100,he,2)}function j(ne,he){return f(ne.getFullYear()%1e4,he,4)}function V(ne){var he=ne.getTimezoneOffset();return(he>0?"-":(he*=-1,"+"))+f(he/60|0,"0",2)+f(he%60,"0",2)}function pe(ne,he){return f(ne.getUTCDate(),he,2)}function we(ne,he){return f(ne.getUTCHours(),he,2)}function ge(ne,he){return f(ne.getUTCHours()%12||12,he,2)}function Pe(ne,he){return f(1+r.utcDay.count(r.utcYear(ne),ne),he,3)}function Ne(ne,he){return f(ne.getUTCMilliseconds(),he,3)}function Ee(ne,he){return Ne(ne,he)+"000"}function Fe(ne,he){return f(ne.getUTCMonth()+1,he,2)}function Ue(ne,he){return f(ne.getUTCMinutes(),he,2)}function Oe(ne,he){return f(ne.getUTCSeconds(),he,2)}function Le(ne){var he=ne.getUTCDay();return he===0?7:he}function Ie(ne,he){return f(r.utcSunday.count(r.utcYear(ne)-1,ne),he,2)}function Be(ne,he){var De=ne.getUTCDay();return ne=De>=4||De===0?r.utcThursday(ne):r.utcThursday.ceil(ne),f(r.utcThursday.count(r.utcYear(ne),ne)+(r.utcYear(ne).getUTCDay()===4),he,2)}function le(ne){return ne.getUTCDay()}function Me(ne,he){return f(r.utcMonday.count(r.utcYear(ne)-1,ne),he,2)}function We(ne,he){return f(ne.getUTCFullYear()%100,he,2)}function sr(ne,he){return f(ne.getUTCFullYear()%1e4,he,4)}function lr(){return"+0000"}function se(){return"%"}function Se(ne){return+ne}function He(ne){return Math.floor(+ne/1e3)}var Ze;Ye({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});function Ye(ne){return Ze=i(ne),e.timeFormat=Ze.format,e.timeParse=Ze.parse,e.utcFormat=Ze.utcFormat,e.utcParse=Ze.utcParse,Ze}var Xe="%Y-%m-%dT%H:%M:%S.%LZ";function Qe(ne){return ne.toISOString()}var hr=Date.prototype.toISOString?Qe:e.utcFormat(Xe);function Ke(ne){var he=new Date(ne);return isNaN(he)?null:he}var Re=+new Date("2000-01-01T00:00:00.000Z")?Ke:e.utcParse(Xe);e.isoFormat=hr,e.isoParse=Re,e.timeFormatDefaultLocale=Ye,e.timeFormatLocale=i,Object.defineProperty(e,"__esModule",{value:!0})})});var zp=G((Uv,Jx)=>{(function(e,r){typeof Uv=="object"&&typeof Jx!="undefined"?r(Uv):(e=typeof globalThis!="undefined"?globalThis:e||self,r(e.d3=e.d3||{}))})(Uv,function(e){"use strict";function r(A){return Math.abs(A=Math.round(A))>=1e21?A.toLocaleString("en").replace(/,/g,""):A.toString(10)}function t(A,q){if((D=(A=q?A.toExponential(q-1):A.toExponential()).indexOf("e"))<0)return null;var D,E=A.slice(0,D);return[E.length>1?E[0]+E.slice(2):E,+A.slice(D+1)]}function a(A){return A=t(Math.abs(A)),A?A[1]:NaN}function n(A,q){return function(D,E){for(var R=D.length,z=[],F=0,H=A[0],W=0;R>0&&H>0&&(W+H+1>E&&(H=Math.max(1,E-W)),z.push(D.substring(R-=H,R+H)),!((W+=H+1)>E));)H=A[F=(F+1)%A.length];return z.reverse().join(q)}}function i(A){return function(q){return q.replace(/[0-9]/g,function(D){return A[+D]})}}var l=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function o(A){if(!(q=l.exec(A)))throw new Error("invalid format: "+A);var q;return new s({fill:q[1],align:q[2],sign:q[3],symbol:q[4],zero:q[5],width:q[6],comma:q[7],precision:q[8]&&q[8].slice(1),trim:q[9],type:q[10]})}o.prototype=s.prototype;function s(A){this.fill=A.fill===void 0?" ":A.fill+"",this.align=A.align===void 0?">":A.align+"",this.sign=A.sign===void 0?"-":A.sign+"",this.symbol=A.symbol===void 0?"":A.symbol+"",this.zero=!!A.zero,this.width=A.width===void 0?void 0:+A.width,this.comma=!!A.comma,this.precision=A.precision===void 0?void 0:+A.precision,this.trim=!!A.trim,this.type=A.type===void 0?"":A.type+""}s.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(this.width===void 0?"":Math.max(1,this.width|0))+(this.comma?",":"")+(this.precision===void 0?"":"."+Math.max(0,this.precision|0))+(this.trim?"~":"")+this.type};function u(A){e:for(var q=A.length,D=1,E=-1,R;D0&&(E=0);break}return E>0?A.slice(0,E)+A.slice(R+1):A}var f;function c(A,q){var D=t(A,q);if(!D)return A+"";var E=D[0],R=D[1],z=R-(f=Math.max(-8,Math.min(8,Math.floor(R/3)))*3)+1,F=E.length;return z===F?E:z>F?E+new Array(z-F+1).join("0"):z>0?E.slice(0,z)+"."+E.slice(z):"0."+new Array(1-z).join("0")+t(A,Math.max(0,q+z-1))[0]}function h(A,q){var D=t(A,q);if(!D)return A+"";var E=D[0],R=D[1];return R<0?"0."+new Array(-R).join("0")+E:E.length>R+1?E.slice(0,R+1)+"."+E.slice(R+1):E+new Array(R-E.length+2).join("0")}var d={"%":function(A,q){return(A*100).toFixed(q)},b:function(A){return Math.round(A).toString(2)},c:function(A){return A+""},d:r,e:function(A,q){return A.toExponential(q)},f:function(A,q){return A.toFixed(q)},g:function(A,q){return A.toPrecision(q)},o:function(A){return Math.round(A).toString(8)},p:function(A,q){return h(A*100,q)},r:h,s:c,X:function(A){return Math.round(A).toString(16).toUpperCase()},x:function(A){return Math.round(A).toString(16)}};function p(A){return A}var y=Array.prototype.map,g=["y","z","a","f","p","n","\xB5","m","","k","M","G","T","P","E","Z","Y"];function x(A){var q=A.grouping===void 0||A.thousands===void 0?p:n(y.call(A.grouping,Number),A.thousands+""),D=A.currency===void 0?"":A.currency[0]+"",E=A.currency===void 0?"":A.currency[1]+"",R=A.decimal===void 0?".":A.decimal+"",z=A.numerals===void 0?p:i(y.call(A.numerals,String)),F=A.percent===void 0?"%":A.percent+"",H=A.minus===void 0?"-":A.minus+"",W=A.nan===void 0?"NaN":A.nan+"";function Z(B){B=o(B);var U=B.fill,K=B.align,Q=B.sign,ae=B.symbol,fe=B.zero,oe=B.width,ce=B.comma,$=B.precision,Te=B.trim,ue=B.type;ue==="n"?(ce=!0,ue="g"):d[ue]||($===void 0&&($=12),Te=!0,ue="g"),(fe||U==="0"&&K==="=")&&(fe=!0,U="0",K="=");var me=ae==="$"?D:ae==="#"&&/[boxX]/.test(ue)?"0"+ue.toLowerCase():"",ie=ae==="$"?E:/[%p]/.test(ue)?F:"",de=d[ue],O=/[defgprs%]/.test(ue);$=$===void 0?6:/[gprs]/.test(ue)?Math.max(1,Math.min(21,$)):Math.max(0,Math.min(20,$));function j(V){var pe=me,we=ie,ge,Pe,Ne;if(ue==="c")we=de(V)+we,V="";else{V=+V;var Ee=V<0||1/V<0;if(V=isNaN(V)?W:de(Math.abs(V),$),Te&&(V=u(V)),Ee&&+V==0&&Q!=="+"&&(Ee=!1),pe=(Ee?Q==="("?Q:H:Q==="-"||Q==="("?"":Q)+pe,we=(ue==="s"?g[8+f/3]:"")+we+(Ee&&Q==="("?")":""),O){for(ge=-1,Pe=V.length;++geNe||Ne>57){we=(Ne===46?R+V.slice(ge+1):V.slice(ge))+we,V=V.slice(0,ge);break}}}ce&&!fe&&(V=q(V,1/0));var Fe=pe.length+V.length+we.length,Ue=Fe>1)+pe+V+we+Ue.slice(Fe);break;default:V=Ue+pe+V+we;break}return z(V)}return j.toString=function(){return B+""},j}function Y(B,U){var K=Z((B=o(B),B.type="f",B)),Q=Math.max(-8,Math.min(8,Math.floor(a(U)/3)))*3,ae=Math.pow(10,-Q),fe=g[8+Q/3];return function(oe){return K(ae*oe)+fe}}return{format:Z,formatPrefix:Y}}var _;M({decimal:".",thousands:",",grouping:[3],currency:["$",""],minus:"-"});function M(A){return _=x(A),e.format=_.format,e.formatPrefix=_.formatPrefix,_}function b(A){return Math.max(0,-a(Math.abs(A)))}function w(A,q){return Math.max(0,Math.max(-8,Math.min(8,Math.floor(a(q)/3)))*3-a(Math.abs(A)))}function k(A,q){return A=Math.abs(A),q=Math.abs(q)-A,Math.max(0,a(q)-a(A))+1}e.FormatSpecifier=s,e.formatDefaultLocale=M,e.formatLocale=x,e.formatSpecifier=o,e.precisionFixed=b,e.precisionPrefix=w,e.precisionRound=k,Object.defineProperty(e,"__esModule",{value:!0})})});var Qx=G((gce,Kx)=>{"use strict";Kx.exports=function(e){for(var r=e.length,t,a=0;a13)&&t!==32&&t!==133&&t!==160&&t!==5760&&t!==6158&&(t<8192||t>8205)&&t!==8232&&t!==8233&&t!==8239&&t!==8287&&t!==8288&&t!==12288&&t!==65279)return!1;return!0}});var zr=G((bce,$x)=>{"use strict";var bY=Qx();$x.exports=function(e){var r=typeof e;if(r==="string"){var t=e;if(e=+e,e===0&&bY(t))return!1}else if(r!=="number")return!1;return e-e<1}});var Ct=G((xce,jx)=>{"use strict";jx.exports={BADNUM:void 0,FP_SAFE:Number.MAX_VALUE*1e-4,ONEMAXYEAR:316224e5,ONEAVGYEAR:315576e5,ONEMINYEAR:31536e6,ONEMAXQUARTER:79488e5,ONEAVGQUARTER:78894e5,ONEMINQUARTER:76896e5,ONEMAXMONTH:26784e5,ONEAVGMONTH:26298e5,ONEMINMONTH:24192e5,ONEWEEK:6048e5,ONEDAY:864e5,ONEHOUR:36e5,ONEMIN:6e4,ONESEC:1e3,ONEMILLI:1,ONEMICROSEC:.001,EPOCHJD:24405875e-1,ALMOST_EQUAL:1-1e-6,LOG_CLIP:10,MINUS_SIGN:"\u2212"}});var Np=G((Gv,e_)=>{(function(e,r){typeof Gv=="object"&&typeof e_!="undefined"?r(Gv):(e=typeof globalThis!="undefined"?globalThis:e||self,r(e["base64-arraybuffer"]={}))})(Gv,function(e){"use strict";for(var r="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",t=typeof Uint8Array=="undefined"?[]:new Uint8Array(256),a=0;a>2],f+=r[(o[s]&3)<<4|o[s+1]>>4],f+=r[(o[s+1]&15)<<2|o[s+2]>>6],f+=r[o[s+2]&63];return u%3===2?f=f.substring(0,f.length-1)+"=":u%3===1&&(f=f.substring(0,f.length-2)+"=="),f},i=function(l){var o=l.length*.75,s=l.length,u,f=0,c,h,d,p;l[l.length-1]==="="&&(o--,l[l.length-2]==="="&&o--);var y=new ArrayBuffer(o),g=new Uint8Array(y);for(u=0;u>4,g[f++]=(h&15)<<4|d>>2,g[f++]=(d&3)<<6|p&63;return y};e.decode=i,e.encode=n,Object.defineProperty(e,"__esModule",{value:!0})})});var Sl=G((_ce,r_)=>{"use strict";r_.exports=function(r){return window&&window.process&&window.process.versions?Object.prototype.toString.call(r)==="[object Object]":Object.prototype.toString.call(r)==="[object Object]"&&Object.getPrototypeOf(r).hasOwnProperty("hasOwnProperty")}});var $a=G(si=>{"use strict";var xY=Np().decode,_Y=Sl(),Fp=Array.isArray,wY=ArrayBuffer,TY=DataView;function t_(e){return wY.isView(e)&&!(e instanceof TY)}si.isTypedArray=t_;function Vv(e){return Fp(e)||t_(e)}si.isArrayOrTypedArray=Vv;function MY(e){return!Vv(e[0])}si.isArray1D=MY;si.ensureArray=function(e,r){return Fp(e)||(e=[]),e.length=r,e};var ma={u1c:typeof Uint8ClampedArray=="undefined"?void 0:Uint8ClampedArray,i1:typeof Int8Array=="undefined"?void 0:Int8Array,u1:typeof Uint8Array=="undefined"?void 0:Uint8Array,i2:typeof Int16Array=="undefined"?void 0:Int16Array,u2:typeof Uint16Array=="undefined"?void 0:Uint16Array,i4:typeof Int32Array=="undefined"?void 0:Int32Array,u4:typeof Uint32Array=="undefined"?void 0:Uint32Array,f4:typeof Float32Array=="undefined"?void 0:Float32Array,f8:typeof Float64Array=="undefined"?void 0:Float64Array};ma.uint8c=ma.u1c;ma.uint8=ma.u1;ma.int8=ma.i1;ma.uint16=ma.u2;ma.int16=ma.i2;ma.uint32=ma.u4;ma.int32=ma.i4;ma.float32=ma.f4;ma.float64=ma.f8;function Ip(e){return e.constructor===ArrayBuffer}si.isArrayBuffer=Ip;si.decodeTypedArraySpec=function(e){var r=[],t=AY(e),a=t.dtype,n=ma[a];if(!n)throw new Error('Error in dtype: "'+a+'"');var i=n.BYTES_PER_ELEMENT,l=t.bdata;Ip(l)||(l=xY(l));var o=t.shape===void 0?[l.byteLength/i]:(""+t.shape).split(",");o.reverse();var s=o.length,u,f,c=+o[0],h=i*c,d=0;if(s===1)r=new n(l);else if(s===2)for(u=+o[1],f=0;f{"use strict";var n_=zr(),Bp=$a().isArrayOrTypedArray;s_.exports=function(r,t){if(n_(t))t=String(t);else if(typeof t!="string"||t.substr(t.length-4)==="[-1]")throw"bad property string";var a=t.split("."),n,i,l,o;for(o=0;o{"use strict";var Ns=Wv(),LY=/^\w*$/,DY=0,u_=1,Zv=2,f_=3,Mo=4;c_.exports=function(r,t,a,n){a=a||"name",n=n||"value";var i,l,o,s={};t&&t.length?(o=Ns(r,t),l=o.get()):l=r,t=t||"";var u={};if(l)for(i=0;i2)return s[d]=s[d]|Zv,c.set(h,null);if(f){for(i=d;i{"use strict";var EY=/^(.*)(\.[^\.\[\]]+|\[\d\])$/,PY=/^[^\.\[\]]+$/;h_.exports=function(e,r){for(;r;){var t=e.match(EY);if(t)e=t[1];else if(e.match(PY))e="";else throw new Error("bad relativeAttr call:"+[e,r]);if(r.charAt(0)==="^")r=r.slice(1);else break}return e&&r.charAt(0)!=="["?e+"."+r:e+r}});var Xv=G((kce,p_)=>{"use strict";var RY=zr();p_.exports=function(r,t){if(r>0)return Math.log(r)/Math.LN10;var a=Math.log(Math.min(t[0],t[1]))/Math.LN10;return RY(a)||(a=Math.log(Math.max(t[0],t[1]))/Math.LN10-6),a}});var g_=G((Cce,y_)=>{"use strict";var m_=$a().isArrayOrTypedArray,wf=Sl();y_.exports=function e(r,t){for(var a in t){var n=t[a],i=r[a];if(i!==n)if(a.charAt(0)==="_"||typeof n=="function"){if(a in r)continue;r[a]=n}else if(m_(n)&&m_(i)&&wf(n[0])){if(a==="customdata"||a==="ids")continue;for(var l=Math.min(n.length,i.length),o=0;o{"use strict";function zY(e,r){var t=e%r;return t<0?t+r:t}function NY(e,r){return Math.abs(e)>r/2?e-Math.round(e/r)*r:e}b_.exports={mod:zY,modHalf:NY}});var Pn=G((qce,Jv)=>{(function(e){var r=/^\s+/,t=/\s+$/,a=0,n=e.round,i=e.min,l=e.max,o=e.random;function s(O,j){if(O=O||"",j=j||{},O instanceof s)return O;if(!(this instanceof s))return new s(O,j);var V=u(O);this._originalInput=O,this._r=V.r,this._g=V.g,this._b=V.b,this._a=V.a,this._roundA=n(100*this._a)/100,this._format=j.format||V.format,this._gradientType=j.gradientType,this._r<1&&(this._r=n(this._r)),this._g<1&&(this._g=n(this._g)),this._b<1&&(this._b=n(this._b)),this._ok=V.ok,this._tc_id=a++}s.prototype={isDark:function(){return this.getBrightness()<128},isLight:function(){return!this.isDark()},isValid:function(){return this._ok},getOriginalInput:function(){return this._originalInput},getFormat:function(){return this._format},getAlpha:function(){return this._a},getBrightness:function(){var O=this.toRgb();return(O.r*299+O.g*587+O.b*114)/1e3},getLuminance:function(){var O=this.toRgb(),j,V,pe,we,ge,Pe;return j=O.r/255,V=O.g/255,pe=O.b/255,j<=.03928?we=j/12.92:we=e.pow((j+.055)/1.055,2.4),V<=.03928?ge=V/12.92:ge=e.pow((V+.055)/1.055,2.4),pe<=.03928?Pe=pe/12.92:Pe=e.pow((pe+.055)/1.055,2.4),.2126*we+.7152*ge+.0722*Pe},setAlpha:function(O){return this._a=B(O),this._roundA=n(100*this._a)/100,this},toHsv:function(){var O=d(this._r,this._g,this._b);return{h:O.h*360,s:O.s,v:O.v,a:this._a}},toHsvString:function(){var O=d(this._r,this._g,this._b),j=n(O.h*360),V=n(O.s*100),pe=n(O.v*100);return this._a==1?"hsv("+j+", "+V+"%, "+pe+"%)":"hsva("+j+", "+V+"%, "+pe+"%, "+this._roundA+")"},toHsl:function(){var O=c(this._r,this._g,this._b);return{h:O.h*360,s:O.s,l:O.l,a:this._a}},toHslString:function(){var O=c(this._r,this._g,this._b),j=n(O.h*360),V=n(O.s*100),pe=n(O.l*100);return this._a==1?"hsl("+j+", "+V+"%, "+pe+"%)":"hsla("+j+", "+V+"%, "+pe+"%, "+this._roundA+")"},toHex:function(O){return y(this._r,this._g,this._b,O)},toHexString:function(O){return"#"+this.toHex(O)},toHex8:function(O){return g(this._r,this._g,this._b,this._a,O)},toHex8String:function(O){return"#"+this.toHex8(O)},toRgb:function(){return{r:n(this._r),g:n(this._g),b:n(this._b),a:this._a}},toRgbString:function(){return this._a==1?"rgb("+n(this._r)+", "+n(this._g)+", "+n(this._b)+")":"rgba("+n(this._r)+", "+n(this._g)+", "+n(this._b)+", "+this._roundA+")"},toPercentageRgb:function(){return{r:n(U(this._r,255)*100)+"%",g:n(U(this._g,255)*100)+"%",b:n(U(this._b,255)*100)+"%",a:this._a}},toPercentageRgbString:function(){return this._a==1?"rgb("+n(U(this._r,255)*100)+"%, "+n(U(this._g,255)*100)+"%, "+n(U(this._b,255)*100)+"%)":"rgba("+n(U(this._r,255)*100)+"%, "+n(U(this._g,255)*100)+"%, "+n(U(this._b,255)*100)+"%, "+this._roundA+")"},toName:function(){return this._a===0?"transparent":this._a<1?!1:Z[y(this._r,this._g,this._b,!0)]||!1},toFilter:function(O){var j="#"+x(this._r,this._g,this._b,this._a),V=j,pe=this._gradientType?"GradientType = 1, ":"";if(O){var we=s(O);V="#"+x(we._r,we._g,we._b,we._a)}return"progid:DXImageTransform.Microsoft.gradient("+pe+"startColorstr="+j+",endColorstr="+V+")"},toString:function(O){var j=!!O;O=O||this._format;var V=!1,pe=this._a<1&&this._a>=0,we=!j&&pe&&(O==="hex"||O==="hex6"||O==="hex3"||O==="hex4"||O==="hex8"||O==="name");return we?O==="name"&&this._a===0?this.toName():this.toRgbString():(O==="rgb"&&(V=this.toRgbString()),O==="prgb"&&(V=this.toPercentageRgbString()),(O==="hex"||O==="hex6")&&(V=this.toHexString()),O==="hex3"&&(V=this.toHexString(!0)),O==="hex4"&&(V=this.toHex8String(!0)),O==="hex8"&&(V=this.toHex8String()),O==="name"&&(V=this.toName()),O==="hsl"&&(V=this.toHslString()),O==="hsv"&&(V=this.toHsvString()),V||this.toHexString())},clone:function(){return s(this.toString())},_applyModification:function(O,j){var V=O.apply(null,[this].concat([].slice.call(j)));return this._r=V._r,this._g=V._g,this._b=V._b,this.setAlpha(V._a),this},lighten:function(){return this._applyModification(w,arguments)},brighten:function(){return this._applyModification(k,arguments)},darken:function(){return this._applyModification(A,arguments)},desaturate:function(){return this._applyModification(_,arguments)},saturate:function(){return this._applyModification(M,arguments)},greyscale:function(){return this._applyModification(b,arguments)},spin:function(){return this._applyModification(q,arguments)},_applyCombination:function(O,j){return O.apply(null,[this].concat([].slice.call(j)))},analogous:function(){return this._applyCombination(F,arguments)},complement:function(){return this._applyCombination(D,arguments)},monochromatic:function(){return this._applyCombination(H,arguments)},splitcomplement:function(){return this._applyCombination(z,arguments)},triad:function(){return this._applyCombination(E,arguments)},tetrad:function(){return this._applyCombination(R,arguments)}},s.fromRatio=function(O,j){if(typeof O=="object"){var V={};for(var pe in O)O.hasOwnProperty(pe)&&(pe==="a"?V[pe]=O[pe]:V[pe]=ce(O[pe]));O=V}return s(O,j)};function u(O){var j={r:0,g:0,b:0},V=1,pe=null,we=null,ge=null,Pe=!1,Ne=!1;return typeof O=="string"&&(O=ie(O)),typeof O=="object"&&(me(O.r)&&me(O.g)&&me(O.b)?(j=f(O.r,O.g,O.b),Pe=!0,Ne=String(O.r).substr(-1)==="%"?"prgb":"rgb"):me(O.h)&&me(O.s)&&me(O.v)?(pe=ce(O.s),we=ce(O.v),j=p(O.h,pe,we),Pe=!0,Ne="hsv"):me(O.h)&&me(O.s)&&me(O.l)&&(pe=ce(O.s),ge=ce(O.l),j=h(O.h,pe,ge),Pe=!0,Ne="hsl"),O.hasOwnProperty("a")&&(V=O.a)),V=B(V),{ok:Pe,format:O.format||Ne,r:i(255,l(j.r,0)),g:i(255,l(j.g,0)),b:i(255,l(j.b,0)),a:V}}function f(O,j,V){return{r:U(O,255)*255,g:U(j,255)*255,b:U(V,255)*255}}function c(O,j,V){O=U(O,255),j=U(j,255),V=U(V,255);var pe=l(O,j,V),we=i(O,j,V),ge,Pe,Ne=(pe+we)/2;if(pe==we)ge=Pe=0;else{var Ee=pe-we;switch(Pe=Ne>.5?Ee/(2-pe-we):Ee/(pe+we),pe){case O:ge=(j-V)/Ee+(j1&&(Oe-=1),Oe<1/6?Fe+(Ue-Fe)*6*Oe:Oe<1/2?Ue:Oe<2/3?Fe+(Ue-Fe)*(2/3-Oe)*6:Fe}if(j===0)pe=we=ge=V;else{var Ne=V<.5?V*(1+j):V+j-V*j,Ee=2*V-Ne;pe=Pe(Ee,Ne,O+1/3),we=Pe(Ee,Ne,O),ge=Pe(Ee,Ne,O-1/3)}return{r:pe*255,g:we*255,b:ge*255}}function d(O,j,V){O=U(O,255),j=U(j,255),V=U(V,255);var pe=l(O,j,V),we=i(O,j,V),ge,Pe,Ne=pe,Ee=pe-we;if(Pe=pe===0?0:Ee/pe,pe==we)ge=0;else{switch(pe){case O:ge=(j-V)/Ee+(j>1)+720)%360;--j;)pe.h=(pe.h+we)%360,ge.push(s(pe));return ge}function H(O,j){j=j||6;for(var V=s(O).toHsv(),pe=V.h,we=V.s,ge=V.v,Pe=[],Ne=1/j;j--;)Pe.push(s({h:pe,s:we,v:ge})),ge=(ge+Ne)%1;return Pe}s.mix=function(O,j,V){V=V===0?0:V||50;var pe=s(O).toRgb(),we=s(j).toRgb(),ge=V/100,Pe={r:(we.r-pe.r)*ge+pe.r,g:(we.g-pe.g)*ge+pe.g,b:(we.b-pe.b)*ge+pe.b,a:(we.a-pe.a)*ge+pe.a};return s(Pe)},s.readability=function(O,j){var V=s(O),pe=s(j);return(e.max(V.getLuminance(),pe.getLuminance())+.05)/(e.min(V.getLuminance(),pe.getLuminance())+.05)},s.isReadable=function(O,j,V){var pe=s.readability(O,j),we,ge;switch(ge=!1,we=de(V),we.level+we.size){case"AAsmall":case"AAAlarge":ge=pe>=4.5;break;case"AAlarge":ge=pe>=3;break;case"AAAsmall":ge=pe>=7;break}return ge},s.mostReadable=function(O,j,V){var pe=null,we=0,ge,Pe,Ne,Ee;V=V||{},Pe=V.includeFallbackColors,Ne=V.level,Ee=V.size;for(var Fe=0;Fewe&&(we=ge,pe=s(j[Fe]));return s.isReadable(O,pe,{level:Ne,size:Ee})||!Pe?pe:(V.includeFallbackColors=!1,s.mostReadable(O,["#fff","#000"],V))};var W=s.names={aliceblue:"f0f8ff",antiquewhite:"faebd7",aqua:"0ff",aquamarine:"7fffd4",azure:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"000",blanchedalmond:"ffebcd",blue:"00f",blueviolet:"8a2be2",brown:"a52a2a",burlywood:"deb887",burntsienna:"ea7e5d",cadetblue:"5f9ea0",chartreuse:"7fff00",chocolate:"d2691e",coral:"ff7f50",cornflowerblue:"6495ed",cornsilk:"fff8dc",crimson:"dc143c",cyan:"0ff",darkblue:"00008b",darkcyan:"008b8b",darkgoldenrod:"b8860b",darkgray:"a9a9a9",darkgreen:"006400",darkgrey:"a9a9a9",darkkhaki:"bdb76b",darkmagenta:"8b008b",darkolivegreen:"556b2f",darkorange:"ff8c00",darkorchid:"9932cc",darkred:"8b0000",darksalmon:"e9967a",darkseagreen:"8fbc8f",darkslateblue:"483d8b",darkslategray:"2f4f4f",darkslategrey:"2f4f4f",darkturquoise:"00ced1",darkviolet:"9400d3",deeppink:"ff1493",deepskyblue:"00bfff",dimgray:"696969",dimgrey:"696969",dodgerblue:"1e90ff",firebrick:"b22222",floralwhite:"fffaf0",forestgreen:"228b22",fuchsia:"f0f",gainsboro:"dcdcdc",ghostwhite:"f8f8ff",gold:"ffd700",goldenrod:"daa520",gray:"808080",green:"008000",greenyellow:"adff2f",grey:"808080",honeydew:"f0fff0",hotpink:"ff69b4",indianred:"cd5c5c",indigo:"4b0082",ivory:"fffff0",khaki:"f0e68c",lavender:"e6e6fa",lavenderblush:"fff0f5",lawngreen:"7cfc00",lemonchiffon:"fffacd",lightblue:"add8e6",lightcoral:"f08080",lightcyan:"e0ffff",lightgoldenrodyellow:"fafad2",lightgray:"d3d3d3",lightgreen:"90ee90",lightgrey:"d3d3d3",lightpink:"ffb6c1",lightsalmon:"ffa07a",lightseagreen:"20b2aa",lightskyblue:"87cefa",lightslategray:"789",lightslategrey:"789",lightsteelblue:"b0c4de",lightyellow:"ffffe0",lime:"0f0",limegreen:"32cd32",linen:"faf0e6",magenta:"f0f",maroon:"800000",mediumaquamarine:"66cdaa",mediumblue:"0000cd",mediumorchid:"ba55d3",mediumpurple:"9370db",mediumseagreen:"3cb371",mediumslateblue:"7b68ee",mediumspringgreen:"00fa9a",mediumturquoise:"48d1cc",mediumvioletred:"c71585",midnightblue:"191970",mintcream:"f5fffa",mistyrose:"ffe4e1",moccasin:"ffe4b5",navajowhite:"ffdead",navy:"000080",oldlace:"fdf5e6",olive:"808000",olivedrab:"6b8e23",orange:"ffa500",orangered:"ff4500",orchid:"da70d6",palegoldenrod:"eee8aa",palegreen:"98fb98",paleturquoise:"afeeee",palevioletred:"db7093",papayawhip:"ffefd5",peachpuff:"ffdab9",peru:"cd853f",pink:"ffc0cb",plum:"dda0dd",powderblue:"b0e0e6",purple:"800080",rebeccapurple:"663399",red:"f00",rosybrown:"bc8f8f",royalblue:"4169e1",saddlebrown:"8b4513",salmon:"fa8072",sandybrown:"f4a460",seagreen:"2e8b57",seashell:"fff5ee",sienna:"a0522d",silver:"c0c0c0",skyblue:"87ceeb",slateblue:"6a5acd",slategray:"708090",slategrey:"708090",snow:"fffafa",springgreen:"00ff7f",steelblue:"4682b4",tan:"d2b48c",teal:"008080",thistle:"d8bfd8",tomato:"ff6347",turquoise:"40e0d0",violet:"ee82ee",wheat:"f5deb3",white:"fff",whitesmoke:"f5f5f5",yellow:"ff0",yellowgreen:"9acd32"},Z=s.hexNames=Y(W);function Y(O){var j={};for(var V in O)O.hasOwnProperty(V)&&(j[O[V]]=V);return j}function B(O){return O=parseFloat(O),(isNaN(O)||O<0||O>1)&&(O=1),O}function U(O,j){ae(O)&&(O="100%");var V=fe(O);return O=i(j,l(0,parseFloat(O))),V&&(O=parseInt(O*j,10)/100),e.abs(O-j)<1e-6?1:O%j/parseFloat(j)}function K(O){return i(1,l(0,O))}function Q(O){return parseInt(O,16)}function ae(O){return typeof O=="string"&&O.indexOf(".")!=-1&&parseFloat(O)===1}function fe(O){return typeof O=="string"&&O.indexOf("%")!=-1}function oe(O){return O.length==1?"0"+O:""+O}function ce(O){return O<=1&&(O=O*100+"%"),O}function $(O){return e.round(parseFloat(O)*255).toString(16)}function Te(O){return Q(O)/255}var ue=function(){var O="[-\\+]?\\d+%?",j="[-\\+]?\\d*\\.\\d+%?",V="(?:"+j+")|(?:"+O+")",pe="[\\s|\\(]+("+V+")[,|\\s]+("+V+")[,|\\s]+("+V+")\\s*\\)?",we="[\\s|\\(]+("+V+")[,|\\s]+("+V+")[,|\\s]+("+V+")[,|\\s]+("+V+")\\s*\\)?";return{CSS_UNIT:new RegExp(V),rgb:new RegExp("rgb"+pe),rgba:new RegExp("rgba"+we),hsl:new RegExp("hsl"+pe),hsla:new RegExp("hsla"+we),hsv:new RegExp("hsv"+pe),hsva:new RegExp("hsva"+we),hex3:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex6:/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/,hex4:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex8:/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/}}();function me(O){return!!ue.CSS_UNIT.exec(O)}function ie(O){O=O.replace(r,"").replace(t,"").toLowerCase();var j=!1;if(W[O])O=W[O],j=!0;else if(O=="transparent")return{r:0,g:0,b:0,a:0,format:"name"};var V;return(V=ue.rgb.exec(O))?{r:V[1],g:V[2],b:V[3]}:(V=ue.rgba.exec(O))?{r:V[1],g:V[2],b:V[3],a:V[4]}:(V=ue.hsl.exec(O))?{h:V[1],s:V[2],l:V[3]}:(V=ue.hsla.exec(O))?{h:V[1],s:V[2],l:V[3],a:V[4]}:(V=ue.hsv.exec(O))?{h:V[1],s:V[2],v:V[3]}:(V=ue.hsva.exec(O))?{h:V[1],s:V[2],v:V[3],a:V[4]}:(V=ue.hex8.exec(O))?{r:Q(V[1]),g:Q(V[2]),b:Q(V[3]),a:Te(V[4]),format:j?"name":"hex8"}:(V=ue.hex6.exec(O))?{r:Q(V[1]),g:Q(V[2]),b:Q(V[3]),format:j?"name":"hex"}:(V=ue.hex4.exec(O))?{r:Q(V[1]+""+V[1]),g:Q(V[2]+""+V[2]),b:Q(V[3]+""+V[3]),a:Te(V[4]+""+V[4]),format:j?"name":"hex8"}:(V=ue.hex3.exec(O))?{r:Q(V[1]+""+V[1]),g:Q(V[2]+""+V[2]),b:Q(V[3]+""+V[3]),format:j?"name":"hex"}:!1}function de(O){var j,V;return O=O||{level:"AA",size:"small"},j=(O.level||"AA").toUpperCase(),V=(O.size||"small").toLowerCase(),j!=="AA"&&j!=="AAA"&&(j="AA"),V!=="small"&&V!=="large"&&(V="small"),{level:j,size:V}}typeof Jv!="undefined"&&Jv.exports?Jv.exports=s:window.tinycolor=s})(Math)});var Lt=G(Af=>{"use strict";var x_=Sl(),Tf=Array.isArray;function FY(e,r){var t,a;for(t=0;t{"use strict";__.exports=function(e){var r=e.variantValues,t=e.editType,a=e.colorEditType;a===void 0&&(a=t);var n={editType:t,valType:"integer",min:1,max:1e3,extras:["normal","bold"],dflt:"normal"};e.noNumericWeightValues&&(n.valType="enumerated",n.values=n.extras,n.extras=void 0,n.min=void 0,n.max=void 0);var i={family:{valType:"string",noBlank:!0,strict:!0,editType:t},size:{valType:"number",min:1,editType:t},color:{valType:"color",editType:a},weight:n,style:{editType:t,valType:"enumerated",values:["normal","italic"],dflt:"normal"},variant:e.noFontVariant?void 0:{editType:t,valType:"enumerated",values:r||["normal","small-caps","all-small-caps","all-petite-caps","petite-caps","unicase"],dflt:"normal"},textcase:e.noFontTextcase?void 0:{editType:t,valType:"enumerated",values:["normal","word caps","upper","lower"],dflt:"normal"},lineposition:e.noFontLineposition?void 0:{editType:t,valType:"flaglist",flags:["under","over","through"],extras:["none"],dflt:"none"},shadow:e.noFontShadow?void 0:{editType:t,valType:"string",dflt:e.autoShadowDflt?"auto":"none"},editType:t};return e.autoSize&&(i.size.dflt="auto"),e.autoColor&&(i.color.dflt="auto"),e.arrayOk&&(i.family.arrayOk=!0,i.weight.arrayOk=!0,i.style.arrayOk=!0,e.noFontVariant||(i.variant.arrayOk=!0),e.noFontTextcase||(i.textcase.arrayOk=!0),e.noFontLineposition||(i.lineposition.arrayOk=!0),e.noFontShadow||(i.shadow.arrayOk=!0),i.size.arrayOk=!0,i.color.arrayOk=!0),i}});var kf=G((Ece,w_)=>{"use strict";w_.exports={YANGLE:60,HOVERARROWSIZE:6,HOVERTEXTPAD:3,HOVERFONTSIZE:13,HOVERFONT:"Arial, sans-serif",HOVERMINTIME:50,HOVERID:"-hover"}});var Is=G((Pce,A_)=>{"use strict";var T_=kf(),M_=ya(),Op=M_({editType:"none"});Op.family.dflt=T_.HOVERFONT;Op.size.dflt=T_.HOVERFONTSIZE;A_.exports={clickmode:{valType:"flaglist",flags:["event","select"],dflt:"event",editType:"plot",extras:["none"]},dragmode:{valType:"enumerated",values:["zoom","pan","select","lasso","drawclosedpath","drawopenpath","drawline","drawrect","drawcircle","orbit","turntable",!1],dflt:"zoom",editType:"modebar"},hovermode:{valType:"enumerated",values:["x","y","closest",!1,"x unified","y unified"],dflt:"closest",editType:"modebar"},hoversubplots:{valType:"enumerated",values:["single","overlaying","axis"],dflt:"overlaying",editType:"none"},hoverdistance:{valType:"integer",min:-1,dflt:20,editType:"none"},spikedistance:{valType:"integer",min:-1,dflt:-1,editType:"none"},hoverlabel:{bgcolor:{valType:"color",editType:"none"},bordercolor:{valType:"color",editType:"none"},font:Op,grouptitlefont:M_({editType:"none"}),align:{valType:"enumerated",values:["left","right","auto"],dflt:"auto",editType:"none"},namelength:{valType:"integer",min:-1,dflt:15,editType:"none"},showarrow:{valType:"boolean",dflt:!0,editType:"none"},editType:"none"},selectdirection:{valType:"enumerated",values:["h","v","d","any"],dflt:"any",editType:"none"}}});var Kv=G((Rce,k_)=>{"use strict";var IY=ya(),Cf=Is().hoverlabel,Sf=Lt().extendFlat;k_.exports={hoverlabel:{bgcolor:Sf({},Cf.bgcolor,{arrayOk:!0}),bordercolor:Sf({},Cf.bordercolor,{arrayOk:!0}),font:IY({arrayOk:!0,editType:"none"}),align:Sf({},Cf.align,{arrayOk:!0}),namelength:Sf({},Cf.namelength,{arrayOk:!0}),showarrow:Sf({},Cf.showarrow),editType:"none"}}});var ui=G((zce,C_)=>{"use strict";var HY=ya(),BY=Kv();C_.exports={type:{valType:"enumerated",values:[],dflt:"scatter",editType:"calc+clearAxisTypes",_noTemplating:!0},visible:{valType:"enumerated",values:[!0,!1,"legendonly"],dflt:!0,editType:"calc"},showlegend:{valType:"boolean",dflt:!0,editType:"style"},legend:{valType:"subplotid",dflt:"legend",editType:"style"},legendgroup:{valType:"string",dflt:"",editType:"style"},legendgrouptitle:{text:{valType:"string",dflt:"",editType:"style"},font:HY({editType:"style"}),editType:"style"},legendrank:{valType:"number",dflt:1e3,editType:"style"},legendwidth:{valType:"number",min:0,editType:"style"},opacity:{valType:"number",min:0,max:1,dflt:1,editType:"style"},name:{valType:"string",editType:"style"},uid:{valType:"string",editType:"plot",anim:!0},ids:{valType:"data_array",editType:"calc",anim:!0},customdata:{valType:"data_array",editType:"calc"},meta:{valType:"any",arrayOk:!0,editType:"plot"},selectedpoints:{valType:"any",editType:"calc"},hoverinfo:{valType:"flaglist",flags:["x","y","z","text","name"],extras:["all","none","skip"],arrayOk:!0,dflt:"all",editType:"none"},hoverlabel:BY.hoverlabel,stream:{token:{valType:"string",noBlank:!0,strict:!0,editType:"calc"},maxpoints:{valType:"number",min:0,max:1e4,dflt:500,editType:"calc"},editType:"calc"},uirevision:{valType:"any",editType:"none"}}});var Ao=G((Nce,L_)=>{"use strict";var OY=Pn(),Qv={Greys:[[0,"rgb(0,0,0)"],[1,"rgb(255,255,255)"]],YlGnBu:[[0,"rgb(8,29,88)"],[.125,"rgb(37,52,148)"],[.25,"rgb(34,94,168)"],[.375,"rgb(29,145,192)"],[.5,"rgb(65,182,196)"],[.625,"rgb(127,205,187)"],[.75,"rgb(199,233,180)"],[.875,"rgb(237,248,217)"],[1,"rgb(255,255,217)"]],Greens:[[0,"rgb(0,68,27)"],[.125,"rgb(0,109,44)"],[.25,"rgb(35,139,69)"],[.375,"rgb(65,171,93)"],[.5,"rgb(116,196,118)"],[.625,"rgb(161,217,155)"],[.75,"rgb(199,233,192)"],[.875,"rgb(229,245,224)"],[1,"rgb(247,252,245)"]],YlOrRd:[[0,"rgb(128,0,38)"],[.125,"rgb(189,0,38)"],[.25,"rgb(227,26,28)"],[.375,"rgb(252,78,42)"],[.5,"rgb(253,141,60)"],[.625,"rgb(254,178,76)"],[.75,"rgb(254,217,118)"],[.875,"rgb(255,237,160)"],[1,"rgb(255,255,204)"]],Bluered:[[0,"rgb(0,0,255)"],[1,"rgb(255,0,0)"]],RdBu:[[0,"rgb(5,10,172)"],[.35,"rgb(106,137,247)"],[.5,"rgb(190,190,190)"],[.6,"rgb(220,170,132)"],[.7,"rgb(230,145,90)"],[1,"rgb(178,10,28)"]],Reds:[[0,"rgb(220,220,220)"],[.2,"rgb(245,195,157)"],[.4,"rgb(245,160,105)"],[1,"rgb(178,10,28)"]],Blues:[[0,"rgb(5,10,172)"],[.35,"rgb(40,60,190)"],[.5,"rgb(70,100,245)"],[.6,"rgb(90,120,245)"],[.7,"rgb(106,137,247)"],[1,"rgb(220,220,220)"]],Picnic:[[0,"rgb(0,0,255)"],[.1,"rgb(51,153,255)"],[.2,"rgb(102,204,255)"],[.3,"rgb(153,204,255)"],[.4,"rgb(204,204,255)"],[.5,"rgb(255,255,255)"],[.6,"rgb(255,204,255)"],[.7,"rgb(255,153,255)"],[.8,"rgb(255,102,204)"],[.9,"rgb(255,102,102)"],[1,"rgb(255,0,0)"]],Rainbow:[[0,"rgb(150,0,90)"],[.125,"rgb(0,0,200)"],[.25,"rgb(0,25,255)"],[.375,"rgb(0,152,255)"],[.5,"rgb(44,255,150)"],[.625,"rgb(151,255,0)"],[.75,"rgb(255,234,0)"],[.875,"rgb(255,111,0)"],[1,"rgb(255,0,0)"]],Portland:[[0,"rgb(12,51,131)"],[.25,"rgb(10,136,186)"],[.5,"rgb(242,211,56)"],[.75,"rgb(242,143,56)"],[1,"rgb(217,30,30)"]],Jet:[[0,"rgb(0,0,131)"],[.125,"rgb(0,60,170)"],[.375,"rgb(5,255,255)"],[.625,"rgb(255,255,0)"],[.875,"rgb(250,0,0)"],[1,"rgb(128,0,0)"]],Hot:[[0,"rgb(0,0,0)"],[.3,"rgb(230,0,0)"],[.6,"rgb(255,210,0)"],[1,"rgb(255,255,255)"]],Blackbody:[[0,"rgb(0,0,0)"],[.2,"rgb(230,0,0)"],[.4,"rgb(230,210,0)"],[.7,"rgb(255,255,255)"],[1,"rgb(160,200,255)"]],Earth:[[0,"rgb(0,0,130)"],[.1,"rgb(0,180,180)"],[.2,"rgb(40,210,40)"],[.4,"rgb(230,230,50)"],[.6,"rgb(120,70,20)"],[1,"rgb(255,255,255)"]],Electric:[[0,"rgb(0,0,0)"],[.15,"rgb(30,0,100)"],[.4,"rgb(120,0,100)"],[.6,"rgb(160,90,0)"],[.8,"rgb(230,200,0)"],[1,"rgb(255,250,220)"]],Viridis:[[0,"#440154"],[.06274509803921569,"#48186a"],[.12549019607843137,"#472d7b"],[.18823529411764706,"#424086"],[.25098039215686274,"#3b528b"],[.3137254901960784,"#33638d"],[.3764705882352941,"#2c728e"],[.4392156862745098,"#26828e"],[.5019607843137255,"#21918c"],[.5647058823529412,"#1fa088"],[.6274509803921569,"#28ae80"],[.6901960784313725,"#3fbc73"],[.7529411764705882,"#5ec962"],[.8156862745098039,"#84d44b"],[.8784313725490196,"#addc30"],[.9411764705882353,"#d8e219"],[1,"#fde725"]],Cividis:[[0,"rgb(0,32,76)"],[.058824,"rgb(0,42,102)"],[.117647,"rgb(0,52,110)"],[.176471,"rgb(39,63,108)"],[.235294,"rgb(60,74,107)"],[.294118,"rgb(76,85,107)"],[.352941,"rgb(91,95,109)"],[.411765,"rgb(104,106,112)"],[.470588,"rgb(117,117,117)"],[.529412,"rgb(131,129,120)"],[.588235,"rgb(146,140,120)"],[.647059,"rgb(161,152,118)"],[.705882,"rgb(176,165,114)"],[.764706,"rgb(192,177,109)"],[.823529,"rgb(209,191,102)"],[.882353,"rgb(225,204,92)"],[.941176,"rgb(243,219,79)"],[1,"rgb(255,233,69)"]]},S_=Qv.RdBu;function YY(e,r){if(r||(r=S_),!e)return r;function t(){try{e=Qv[e]||JSON.parse(e)}catch(a){e=r}}return typeof e=="string"&&(t(),typeof e=="string"&&t()),q_(e)?e:r}function q_(e){var r=0;if(!Array.isArray(e)||e.length<2||!e[0]||!e[e.length-1]||+e[0][0]!=0||+e[e.length-1][0]!=1)return!1;for(var t=0;t{"use strict";ko.defaults=["#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd","#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf"];ko.defaultLine="#444";ko.lightLine="#eee";ko.background="#fff";ko.borderLine="#BEC8D9";ko.lightFraction=100*10/11});var Lr=G((Ice,D_)=>{"use strict";var ln=Pn(),GY=zr(),VY=$a().isTypedArray,ua=D_.exports={},$v=Ri();ua.defaults=$v.defaults;var WY=ua.defaultLine=$v.defaultLine;ua.lightLine=$v.lightLine;var Up=ua.background=$v.background;ua.tinyRGB=function(e){var r=e.toRgb();return"rgb("+Math.round(r.r)+", "+Math.round(r.g)+", "+Math.round(r.b)+")"};ua.rgb=function(e){return ua.tinyRGB(ln(e))};ua.opacity=function(e){return e?ln(e).getAlpha():0};ua.addOpacity=function(e,r){var t=ln(e).toRgb();return"rgba("+Math.round(t.r)+", "+Math.round(t.g)+", "+Math.round(t.b)+", "+r+")"};ua.combine=function(e,r){var t=ln(e).toRgb();if(t.a===1)return ln(e).toRgbString();var a=ln(r||Up).toRgb(),n=a.a===1?a:{r:255*(1-a.a)+a.r*a.a,g:255*(1-a.a)+a.g*a.a,b:255*(1-a.a)+a.b*a.a},i={r:n.r*(1-t.a)+t.r*t.a,g:n.g*(1-t.a)+t.g*t.a,b:n.b*(1-t.a)+t.b*t.a};return ln(i).toRgbString()};ua.interpolate=function(e,r,t){var a=ln(e).toRgb(),n=ln(r).toRgb(),i={r:t*a.r+(1-t)*n.r,g:t*a.g+(1-t)*n.g,b:t*a.b+(1-t)*n.b};return ln(i).toRgbString()};ua.contrast=function(e,r,t){var a=ln(e);a.getAlpha()!==1&&(a=ln(ua.combine(e,Up)));var n=a.isDark()?r?a.lighten(r):Up:t?a.darken(t):WY;return n.toString()};ua.stroke=function(e,r){var t=ln(r);e.style({stroke:ua.tinyRGB(t),"stroke-opacity":t.getAlpha()})};ua.fill=function(e,r){var t=ln(r);e.style({fill:ua.tinyRGB(t),"fill-opacity":t.getAlpha()})};ua.clean=function(e){if(!(!e||typeof e!="object")){var r=Object.keys(e),t,a,n,i;for(t=0;t=0)))return e;if(i===3)a[i]>1&&(a[i]=1);else if(a[i]>=1)return e}var l=Math.round(a[0]*255)+", "+Math.round(a[1]*255)+", "+Math.round(a[2]*255);return n?"rgba("+l+", "+a[3]+")":"rgb("+l+")"}});var jv=G((Hce,E_)=>{"use strict";E_.exports={SHOW_PLACEHOLDER:100,HIDE_PLACEHOLDER:1e3,DESELECTDIM:.2}});var Hs=G(P_=>{"use strict";P_.counter=function(e,r,t,a){var n=(r||"")+(t?"":"$"),i=a===!1?"":"^";return e==="xy"?new RegExp(i+"x([2-9]|[1-9][0-9]+)?y([2-9]|[1-9][0-9]+)?"+n):new RegExp(i+e+"([2-9]|[1-9][0-9]+)?"+n)}});var F_=G(on=>{"use strict";var Gp=zr(),R_=Pn(),z_=Lt().extendFlat,ZY=ui(),XY=Ao(),JY=Lr(),KY=jv().DESELECTDIM,Bs=Wv(),N_=Hs().counter,QY=Fs().modHalf,zi=$a().isArrayOrTypedArray,ql=$a().isTypedArraySpec,Ll=$a().decodeTypedArraySpec;on.valObjectMeta={data_array:{coerceFunction:function(e,r,t){r.set(zi(e)?e:ql(e)?Ll(e):t)}},enumerated:{coerceFunction:function(e,r,t,a){a.coerceNumber&&(e=+e),a.values.indexOf(e)===-1?r.set(t):r.set(e)},validateFunction:function(e,r){r.coerceNumber&&(e=+e);for(var t=r.values,a=0;aa.max?r.set(t):r.set(+e)}},integer:{coerceFunction:function(e,r,t,a){if((a.extras||[]).indexOf(e)!==-1){r.set(e);return}ql(e)&&(e=Ll(e)),e%1||!Gp(e)||a.min!==void 0&&ea.max?r.set(t):r.set(+e)}},string:{coerceFunction:function(e,r,t,a){if(typeof e!="string"){var n=typeof e=="number";a.strict===!0||!n?r.set(t):r.set(String(e))}else a.noBlank&&!e?r.set(t):r.set(e)}},color:{coerceFunction:function(e,r,t){ql(e)&&(e=Ll(e)),R_(e).isValid()?r.set(e):r.set(t)}},colorlist:{coerceFunction:function(e,r,t){function a(n){return R_(n).isValid()}!Array.isArray(e)||!e.length?r.set(t):e.every(a)?r.set(e):r.set(t)}},colorscale:{coerceFunction:function(e,r,t){r.set(XY.get(e,t))}},angle:{coerceFunction:function(e,r,t){ql(e)&&(e=Ll(e)),e==="auto"?r.set("auto"):Gp(e)?r.set(QY(+e,360)):r.set(t)}},subplotid:{coerceFunction:function(e,r,t,a){var n=a.regex||N_(t);if(typeof e=="string"&&n.test(e)){r.set(e);return}r.set(t)},validateFunction:function(e,r){var t=r.dflt;return e===t?!0:typeof e!="string"?!1:!!N_(t).test(e)}},flaglist:{coerceFunction:function(e,r,t,a){if((a.extras||[]).indexOf(e)!==-1){r.set(e);return}if(typeof e!="string"){r.set(t);return}for(var n=e.split("+"),i=0;i{"use strict";var I_={staticPlot:{valType:"boolean",dflt:!1},typesetMath:{valType:"boolean",dflt:!0},plotlyServerURL:{valType:"string",dflt:""},editable:{valType:"boolean",dflt:!1},edits:{annotationPosition:{valType:"boolean",dflt:!1},annotationTail:{valType:"boolean",dflt:!1},annotationText:{valType:"boolean",dflt:!1},axisTitleText:{valType:"boolean",dflt:!1},colorbarPosition:{valType:"boolean",dflt:!1},colorbarTitleText:{valType:"boolean",dflt:!1},legendPosition:{valType:"boolean",dflt:!1},legendText:{valType:"boolean",dflt:!1},shapePosition:{valType:"boolean",dflt:!1},titleText:{valType:"boolean",dflt:!1}},editSelection:{valType:"boolean",dflt:!0},autosizable:{valType:"boolean",dflt:!1},responsive:{valType:"boolean",dflt:!1},fillFrame:{valType:"boolean",dflt:!1},frameMargins:{valType:"number",dflt:0,min:0,max:.5},scrollZoom:{valType:"flaglist",flags:["cartesian","gl3d","geo","mapbox","map"],extras:[!0,!1],dflt:"gl3d+geo+map"},doubleClick:{valType:"enumerated",values:[!1,"reset","autosize","reset+autosize"],dflt:"reset+autosize"},doubleClickDelay:{valType:"number",dflt:300,min:0},showAxisDragHandles:{valType:"boolean",dflt:!0},showAxisRangeEntryBoxes:{valType:"boolean",dflt:!0},showTips:{valType:"boolean",dflt:!0},showLink:{valType:"boolean",dflt:!1},linkText:{valType:"string",dflt:"Edit chart",noBlank:!0},sendData:{valType:"boolean",dflt:!0},showSources:{valType:"any",dflt:!1},displayModeBar:{valType:"enumerated",values:["hover",!0,!1],dflt:"hover"},showSendToCloud:{valType:"boolean",dflt:!1},showEditInChartStudio:{valType:"boolean",dflt:!1},modeBarButtonsToRemove:{valType:"any",dflt:[]},modeBarButtonsToAdd:{valType:"any",dflt:[]},modeBarButtons:{valType:"any",dflt:!1},toImageButtonOptions:{valType:"any",dflt:{}},displaylogo:{valType:"boolean",dflt:!0},watermark:{valType:"boolean",dflt:!1},plotGlPixelRatio:{valType:"number",dflt:2,min:1,max:4},setBackground:{valType:"any",dflt:"transparent"},topojsonURL:{valType:"string",noBlank:!0,dflt:"https://cdn.plot.ly/un/"},mapboxAccessToken:{valType:"string",dflt:null},logging:{valType:"integer",min:0,max:2,dflt:1},notifyOnLogging:{valType:"integer",min:0,max:2,dflt:0},queueLength:{valType:"integer",min:0,dflt:0},locale:{valType:"string",dflt:"en-US"},locales:{valType:"any",dflt:{}}},H_={};function B_(e,r){for(var t in e){var a=e[t];a.valType?r[t]=a.dflt:(r[t]||(r[t]={}),B_(a,r[t]))}}B_(I_,H_);O_.exports={configAttributes:I_,dfltConfig:H_}});var Wp=G((Uce,Y_)=>{"use strict";var Vp=Rr(),$Y=zr(),qf=[];Y_.exports=function(e,r){if(qf.indexOf(e)!==-1)return;qf.push(e);var t=1e3;$Y(r)?t=r:r==="long"&&(t=3e3);var a=Vp.select("body").selectAll(".plotly-notifier").data([0]);a.enter().append("div").classed("plotly-notifier",!0);var n=a.selectAll(".notifier-note").data(qf);function i(l){l.duration(700).style("opacity",0).each("end",function(o){var s=qf.indexOf(o);s!==-1&&qf.splice(s,1),Vp.select(this).remove()})}n.enter().append("div").classed("notifier-note",!0).style("opacity",0).each(function(l){var o=Vp.select(this);o.append("button").classed("notifier-close",!0).html("×").on("click",function(){o.transition().call(i)});for(var s=o.append("p"),u=l.split(/ /g),f=0;f{"use strict";var Os=Co().dfltConfig,Zp=Wp(),Xp=U_.exports={};Xp.log=function(){var e;if(Os.logging>1){var r=["LOG:"];for(e=0;e1){var t=[];for(e=0;e"),"long")}};Xp.warn=function(){var e;if(Os.logging>0){var r=["WARN:"];for(e=0;e0){var t=[];for(e=0;e"),"stick")}};Xp.error=function(){var e;if(Os.logging>0){var r=["ERROR:"];for(e=0;e0){var t=[];for(e=0;e"),"stick")}}});var r0=G((Vce,G_)=>{"use strict";G_.exports=function(){}});var Jp=G((Wce,V_)=>{"use strict";V_.exports=function(r,t){if(t instanceof RegExp){for(var a=t.toString(),n=0;n{W_.exports=jY;function jY(){var e=new Float32Array(16);return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var J_=G((Xce,X_)=>{X_.exports=eU;function eU(e){var r=new Float32Array(16);return r[0]=e[0],r[1]=e[1],r[2]=e[2],r[3]=e[3],r[4]=e[4],r[5]=e[5],r[6]=e[6],r[7]=e[7],r[8]=e[8],r[9]=e[9],r[10]=e[10],r[11]=e[11],r[12]=e[12],r[13]=e[13],r[14]=e[14],r[15]=e[15],r}});var Q_=G((Jce,K_)=>{K_.exports=rU;function rU(e,r){return e[0]=r[0],e[1]=r[1],e[2]=r[2],e[3]=r[3],e[4]=r[4],e[5]=r[5],e[6]=r[6],e[7]=r[7],e[8]=r[8],e[9]=r[9],e[10]=r[10],e[11]=r[11],e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15],e}});var Kp=G((Kce,$_)=>{$_.exports=tU;function tU(e){return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var e4=G((Qce,j_)=>{j_.exports=aU;function aU(e,r){if(e===r){var t=r[1],a=r[2],n=r[3],i=r[6],l=r[7],o=r[11];e[1]=r[4],e[2]=r[8],e[3]=r[12],e[4]=t,e[6]=r[9],e[7]=r[13],e[8]=a,e[9]=i,e[11]=r[14],e[12]=n,e[13]=l,e[14]=o}else e[0]=r[0],e[1]=r[4],e[2]=r[8],e[3]=r[12],e[4]=r[1],e[5]=r[5],e[6]=r[9],e[7]=r[13],e[8]=r[2],e[9]=r[6],e[10]=r[10],e[11]=r[14],e[12]=r[3],e[13]=r[7],e[14]=r[11],e[15]=r[15];return e}});var t4=G(($ce,r4)=>{r4.exports=nU;function nU(e,r){var t=r[0],a=r[1],n=r[2],i=r[3],l=r[4],o=r[5],s=r[6],u=r[7],f=r[8],c=r[9],h=r[10],d=r[11],p=r[12],y=r[13],g=r[14],x=r[15],_=t*o-a*l,M=t*s-n*l,b=t*u-i*l,w=a*s-n*o,k=a*u-i*o,A=n*u-i*s,q=f*y-c*p,D=f*g-h*p,E=f*x-d*p,R=c*g-h*y,z=c*x-d*y,F=h*x-d*g,H=_*F-M*z+b*R+w*E-k*D+A*q;return H?(H=1/H,e[0]=(o*F-s*z+u*R)*H,e[1]=(n*z-a*F-i*R)*H,e[2]=(y*A-g*k+x*w)*H,e[3]=(h*k-c*A-d*w)*H,e[4]=(s*E-l*F-u*D)*H,e[5]=(t*F-n*E+i*D)*H,e[6]=(g*b-p*A-x*M)*H,e[7]=(f*A-h*b+d*M)*H,e[8]=(l*z-o*E+u*q)*H,e[9]=(a*E-t*z-i*q)*H,e[10]=(p*k-y*b+x*_)*H,e[11]=(c*b-f*k-d*_)*H,e[12]=(o*D-l*R-s*q)*H,e[13]=(t*R-a*D+n*q)*H,e[14]=(y*M-p*w-g*_)*H,e[15]=(f*w-c*M+h*_)*H,e):null}});var n4=G((jce,a4)=>{a4.exports=iU;function iU(e,r){var t=r[0],a=r[1],n=r[2],i=r[3],l=r[4],o=r[5],s=r[6],u=r[7],f=r[8],c=r[9],h=r[10],d=r[11],p=r[12],y=r[13],g=r[14],x=r[15];return e[0]=o*(h*x-d*g)-c*(s*x-u*g)+y*(s*d-u*h),e[1]=-(a*(h*x-d*g)-c*(n*x-i*g)+y*(n*d-i*h)),e[2]=a*(s*x-u*g)-o*(n*x-i*g)+y*(n*u-i*s),e[3]=-(a*(s*d-u*h)-o*(n*d-i*h)+c*(n*u-i*s)),e[4]=-(l*(h*x-d*g)-f*(s*x-u*g)+p*(s*d-u*h)),e[5]=t*(h*x-d*g)-f*(n*x-i*g)+p*(n*d-i*h),e[6]=-(t*(s*x-u*g)-l*(n*x-i*g)+p*(n*u-i*s)),e[7]=t*(s*d-u*h)-l*(n*d-i*h)+f*(n*u-i*s),e[8]=l*(c*x-d*y)-f*(o*x-u*y)+p*(o*d-u*c),e[9]=-(t*(c*x-d*y)-f*(a*x-i*y)+p*(a*d-i*c)),e[10]=t*(o*x-u*y)-l*(a*x-i*y)+p*(a*u-i*o),e[11]=-(t*(o*d-u*c)-l*(a*d-i*c)+f*(a*u-i*o)),e[12]=-(l*(c*g-h*y)-f*(o*g-s*y)+p*(o*h-s*c)),e[13]=t*(c*g-h*y)-f*(a*g-n*y)+p*(a*h-n*c),e[14]=-(t*(o*g-s*y)-l*(a*g-n*y)+p*(a*s-n*o)),e[15]=t*(o*h-s*c)-l*(a*h-n*c)+f*(a*s-n*o),e}});var l4=G((eve,i4)=>{i4.exports=lU;function lU(e){var r=e[0],t=e[1],a=e[2],n=e[3],i=e[4],l=e[5],o=e[6],s=e[7],u=e[8],f=e[9],c=e[10],h=e[11],d=e[12],p=e[13],y=e[14],g=e[15],x=r*l-t*i,_=r*o-a*i,M=r*s-n*i,b=t*o-a*l,w=t*s-n*l,k=a*s-n*o,A=u*p-f*d,q=u*y-c*d,D=u*g-h*d,E=f*y-c*p,R=f*g-h*p,z=c*g-h*y;return x*z-_*R+M*E+b*D-w*q+k*A}});var s4=G((rve,o4)=>{o4.exports=oU;function oU(e,r,t){var a=r[0],n=r[1],i=r[2],l=r[3],o=r[4],s=r[5],u=r[6],f=r[7],c=r[8],h=r[9],d=r[10],p=r[11],y=r[12],g=r[13],x=r[14],_=r[15],M=t[0],b=t[1],w=t[2],k=t[3];return e[0]=M*a+b*o+w*c+k*y,e[1]=M*n+b*s+w*h+k*g,e[2]=M*i+b*u+w*d+k*x,e[3]=M*l+b*f+w*p+k*_,M=t[4],b=t[5],w=t[6],k=t[7],e[4]=M*a+b*o+w*c+k*y,e[5]=M*n+b*s+w*h+k*g,e[6]=M*i+b*u+w*d+k*x,e[7]=M*l+b*f+w*p+k*_,M=t[8],b=t[9],w=t[10],k=t[11],e[8]=M*a+b*o+w*c+k*y,e[9]=M*n+b*s+w*h+k*g,e[10]=M*i+b*u+w*d+k*x,e[11]=M*l+b*f+w*p+k*_,M=t[12],b=t[13],w=t[14],k=t[15],e[12]=M*a+b*o+w*c+k*y,e[13]=M*n+b*s+w*h+k*g,e[14]=M*i+b*u+w*d+k*x,e[15]=M*l+b*f+w*p+k*_,e}});var f4=G((tve,u4)=>{u4.exports=sU;function sU(e,r,t){var a=t[0],n=t[1],i=t[2],l,o,s,u,f,c,h,d,p,y,g,x;return r===e?(e[12]=r[0]*a+r[4]*n+r[8]*i+r[12],e[13]=r[1]*a+r[5]*n+r[9]*i+r[13],e[14]=r[2]*a+r[6]*n+r[10]*i+r[14],e[15]=r[3]*a+r[7]*n+r[11]*i+r[15]):(l=r[0],o=r[1],s=r[2],u=r[3],f=r[4],c=r[5],h=r[6],d=r[7],p=r[8],y=r[9],g=r[10],x=r[11],e[0]=l,e[1]=o,e[2]=s,e[3]=u,e[4]=f,e[5]=c,e[6]=h,e[7]=d,e[8]=p,e[9]=y,e[10]=g,e[11]=x,e[12]=l*a+f*n+p*i+r[12],e[13]=o*a+c*n+y*i+r[13],e[14]=s*a+h*n+g*i+r[14],e[15]=u*a+d*n+x*i+r[15]),e}});var v4=G((ave,c4)=>{c4.exports=uU;function uU(e,r,t){var a=t[0],n=t[1],i=t[2];return e[0]=r[0]*a,e[1]=r[1]*a,e[2]=r[2]*a,e[3]=r[3]*a,e[4]=r[4]*n,e[5]=r[5]*n,e[6]=r[6]*n,e[7]=r[7]*n,e[8]=r[8]*i,e[9]=r[9]*i,e[10]=r[10]*i,e[11]=r[11]*i,e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15],e}});var d4=G((nve,h4)=>{h4.exports=fU;function fU(e,r,t,a){var n=a[0],i=a[1],l=a[2],o=Math.sqrt(n*n+i*i+l*l),s,u,f,c,h,d,p,y,g,x,_,M,b,w,k,A,q,D,E,R,z,F,H,W;return Math.abs(o)<1e-6?null:(o=1/o,n*=o,i*=o,l*=o,s=Math.sin(t),u=Math.cos(t),f=1-u,c=r[0],h=r[1],d=r[2],p=r[3],y=r[4],g=r[5],x=r[6],_=r[7],M=r[8],b=r[9],w=r[10],k=r[11],A=n*n*f+u,q=i*n*f+l*s,D=l*n*f-i*s,E=n*i*f-l*s,R=i*i*f+u,z=l*i*f+n*s,F=n*l*f+i*s,H=i*l*f-n*s,W=l*l*f+u,e[0]=c*A+y*q+M*D,e[1]=h*A+g*q+b*D,e[2]=d*A+x*q+w*D,e[3]=p*A+_*q+k*D,e[4]=c*E+y*R+M*z,e[5]=h*E+g*R+b*z,e[6]=d*E+x*R+w*z,e[7]=p*E+_*R+k*z,e[8]=c*F+y*H+M*W,e[9]=h*F+g*H+b*W,e[10]=d*F+x*H+w*W,e[11]=p*F+_*H+k*W,r!==e&&(e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15]),e)}});var m4=G((ive,p4)=>{p4.exports=cU;function cU(e,r,t){var a=Math.sin(t),n=Math.cos(t),i=r[4],l=r[5],o=r[6],s=r[7],u=r[8],f=r[9],c=r[10],h=r[11];return r!==e&&(e[0]=r[0],e[1]=r[1],e[2]=r[2],e[3]=r[3],e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15]),e[4]=i*n+u*a,e[5]=l*n+f*a,e[6]=o*n+c*a,e[7]=s*n+h*a,e[8]=u*n-i*a,e[9]=f*n-l*a,e[10]=c*n-o*a,e[11]=h*n-s*a,e}});var g4=G((lve,y4)=>{y4.exports=vU;function vU(e,r,t){var a=Math.sin(t),n=Math.cos(t),i=r[0],l=r[1],o=r[2],s=r[3],u=r[8],f=r[9],c=r[10],h=r[11];return r!==e&&(e[4]=r[4],e[5]=r[5],e[6]=r[6],e[7]=r[7],e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15]),e[0]=i*n-u*a,e[1]=l*n-f*a,e[2]=o*n-c*a,e[3]=s*n-h*a,e[8]=i*a+u*n,e[9]=l*a+f*n,e[10]=o*a+c*n,e[11]=s*a+h*n,e}});var x4=G((ove,b4)=>{b4.exports=hU;function hU(e,r,t){var a=Math.sin(t),n=Math.cos(t),i=r[0],l=r[1],o=r[2],s=r[3],u=r[4],f=r[5],c=r[6],h=r[7];return r!==e&&(e[8]=r[8],e[9]=r[9],e[10]=r[10],e[11]=r[11],e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15]),e[0]=i*n+u*a,e[1]=l*n+f*a,e[2]=o*n+c*a,e[3]=s*n+h*a,e[4]=u*n-i*a,e[5]=f*n-l*a,e[6]=c*n-o*a,e[7]=h*n-s*a,e}});var w4=G((sve,_4)=>{_4.exports=dU;function dU(e,r,t){var a,n,i,l=t[0],o=t[1],s=t[2],u=Math.sqrt(l*l+o*o+s*s);return Math.abs(u)<1e-6?null:(u=1/u,l*=u,o*=u,s*=u,a=Math.sin(r),n=Math.cos(r),i=1-n,e[0]=l*l*i+n,e[1]=o*l*i+s*a,e[2]=s*l*i-o*a,e[3]=0,e[4]=l*o*i-s*a,e[5]=o*o*i+n,e[6]=s*o*i+l*a,e[7]=0,e[8]=l*s*i+o*a,e[9]=o*s*i-l*a,e[10]=s*s*i+n,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e)}});var M4=G((uve,T4)=>{T4.exports=pU;function pU(e,r,t){var a=r[0],n=r[1],i=r[2],l=r[3],o=a+a,s=n+n,u=i+i,f=a*o,c=a*s,h=a*u,d=n*s,p=n*u,y=i*u,g=l*o,x=l*s,_=l*u;return e[0]=1-(d+y),e[1]=c+_,e[2]=h-x,e[3]=0,e[4]=c-_,e[5]=1-(f+y),e[6]=p+g,e[7]=0,e[8]=h+x,e[9]=p-g,e[10]=1-(f+d),e[11]=0,e[12]=t[0],e[13]=t[1],e[14]=t[2],e[15]=1,e}});var k4=G((fve,A4)=>{A4.exports=mU;function mU(e,r){return e[0]=r[0],e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=r[1],e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=r[2],e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var S4=G((cve,C4)=>{C4.exports=yU;function yU(e,r){return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=r[0],e[13]=r[1],e[14]=r[2],e[15]=1,e}});var L4=G((vve,q4)=>{q4.exports=gU;function gU(e,r){var t=Math.sin(r),a=Math.cos(r);return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=a,e[6]=t,e[7]=0,e[8]=0,e[9]=-t,e[10]=a,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var E4=G((hve,D4)=>{D4.exports=bU;function bU(e,r){var t=Math.sin(r),a=Math.cos(r);return e[0]=a,e[1]=0,e[2]=-t,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=t,e[9]=0,e[10]=a,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var R4=G((dve,P4)=>{P4.exports=xU;function xU(e,r){var t=Math.sin(r),a=Math.cos(r);return e[0]=a,e[1]=t,e[2]=0,e[3]=0,e[4]=-t,e[5]=a,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var N4=G((pve,z4)=>{z4.exports=_U;function _U(e,r){var t=r[0],a=r[1],n=r[2],i=r[3],l=t+t,o=a+a,s=n+n,u=t*l,f=a*l,c=a*o,h=n*l,d=n*o,p=n*s,y=i*l,g=i*o,x=i*s;return e[0]=1-c-p,e[1]=f+x,e[2]=h-g,e[3]=0,e[4]=f-x,e[5]=1-u-p,e[6]=d+y,e[7]=0,e[8]=h+g,e[9]=d-y,e[10]=1-u-c,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var I4=G((mve,F4)=>{F4.exports=wU;function wU(e,r,t,a,n,i,l){var o=1/(t-r),s=1/(n-a),u=1/(i-l);return e[0]=i*2*o,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=i*2*s,e[6]=0,e[7]=0,e[8]=(t+r)*o,e[9]=(n+a)*s,e[10]=(l+i)*u,e[11]=-1,e[12]=0,e[13]=0,e[14]=l*i*2*u,e[15]=0,e}});var B4=G((yve,H4)=>{H4.exports=TU;function TU(e,r,t,a,n){var i=1/Math.tan(r/2),l=1/(a-n);return e[0]=i/t,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=i,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=(n+a)*l,e[11]=-1,e[12]=0,e[13]=0,e[14]=2*n*a*l,e[15]=0,e}});var Y4=G((gve,O4)=>{O4.exports=MU;function MU(e,r,t,a){var n=Math.tan(r.upDegrees*Math.PI/180),i=Math.tan(r.downDegrees*Math.PI/180),l=Math.tan(r.leftDegrees*Math.PI/180),o=Math.tan(r.rightDegrees*Math.PI/180),s=2/(l+o),u=2/(n+i);return e[0]=s,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=u,e[6]=0,e[7]=0,e[8]=-((l-o)*s*.5),e[9]=(n-i)*u*.5,e[10]=a/(t-a),e[11]=-1,e[12]=0,e[13]=0,e[14]=a*t/(t-a),e[15]=0,e}});var G4=G((bve,U4)=>{U4.exports=AU;function AU(e,r,t,a,n,i,l){var o=1/(r-t),s=1/(a-n),u=1/(i-l);return e[0]=-2*o,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=-2*s,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=2*u,e[11]=0,e[12]=(r+t)*o,e[13]=(n+a)*s,e[14]=(l+i)*u,e[15]=1,e}});var W4=G((xve,V4)=>{var kU=Kp();V4.exports=CU;function CU(e,r,t,a){var n,i,l,o,s,u,f,c,h,d,p=r[0],y=r[1],g=r[2],x=a[0],_=a[1],M=a[2],b=t[0],w=t[1],k=t[2];return Math.abs(p-b)<1e-6&&Math.abs(y-w)<1e-6&&Math.abs(g-k)<1e-6?kU(e):(f=p-b,c=y-w,h=g-k,d=1/Math.sqrt(f*f+c*c+h*h),f*=d,c*=d,h*=d,n=_*h-M*c,i=M*f-x*h,l=x*c-_*f,d=Math.sqrt(n*n+i*i+l*l),d?(d=1/d,n*=d,i*=d,l*=d):(n=0,i=0,l=0),o=c*l-h*i,s=h*n-f*l,u=f*i-c*n,d=Math.sqrt(o*o+s*s+u*u),d?(d=1/d,o*=d,s*=d,u*=d):(o=0,s=0,u=0),e[0]=n,e[1]=o,e[2]=f,e[3]=0,e[4]=i,e[5]=s,e[6]=c,e[7]=0,e[8]=l,e[9]=u,e[10]=h,e[11]=0,e[12]=-(n*p+i*y+l*g),e[13]=-(o*p+s*y+u*g),e[14]=-(f*p+c*y+h*g),e[15]=1,e)}});var X4=G((_ve,Z4)=>{Z4.exports=SU;function SU(e){return"mat4("+e[0]+", "+e[1]+", "+e[2]+", "+e[3]+", "+e[4]+", "+e[5]+", "+e[6]+", "+e[7]+", "+e[8]+", "+e[9]+", "+e[10]+", "+e[11]+", "+e[12]+", "+e[13]+", "+e[14]+", "+e[15]+")"}});var Qp=G((wve,J4)=>{J4.exports={create:Z_(),clone:J_(),copy:Q_(),identity:Kp(),transpose:e4(),invert:t4(),adjoint:n4(),determinant:l4(),multiply:s4(),translate:f4(),scale:v4(),rotate:d4(),rotateX:m4(),rotateY:g4(),rotateZ:x4(),fromRotation:w4(),fromRotationTranslation:M4(),fromScaling:k4(),fromTranslation:S4(),fromXRotation:L4(),fromYRotation:E4(),fromZRotation:R4(),fromQuat:N4(),frustum:I4(),perspective:B4(),perspectiveFromFieldOfView:Y4(),ortho:G4(),lookAt:W4(),str:X4()}});var t0=G(Vt=>{"use strict";var qU=Qp();Vt.init2dArray=function(e,r){for(var t=new Array(e),a=0;a{"use strict";var LU=Rr(),K4=So(),DU=t0(),EU=Qp();function PU(e){var r;if(typeof e=="string"){if(r=document.getElementById(e),r===null)throw new Error("No DOM element with id '"+e+"' exists on the page.");return r}else if(e==null)throw new Error("DOM element provided is null or undefined");return e}function RU(e){var r=LU.select(e);return r.node()instanceof HTMLElement&&r.size()&&r.classed("js-plotly-plot")}function Q4(e){var r=e&&e.parentNode;r&&r.removeChild(e)}function zU(e,r){$4("global",e,r)}function $4(e,r,t){var a="plotly.js-style-"+e,n=document.getElementById(a);if(!(n&&n.matches(".no-inline-styles"))){n||(n=document.createElement("style"),n.setAttribute("id",a),n.appendChild(document.createTextNode("")),document.head.appendChild(n));var i=n.sheet;i?i.insertRule?i.insertRule(r+"{"+t+"}",0):i.addRule?i.addRule(r,t,0):K4.warn("addStyleRule failed"):K4.warn("Cannot addRelatedStyleRule, probably due to strict CSP...")}}function NU(e){var r="plotly.js-style-"+e,t=document.getElementById(r);t&&Q4(t)}function FU(e,r,t,a,n,i){var l=a.split(":"),o=n.split(":"),s="data-btn-style-event-added";i||(i=document),i.querySelectorAll(e).forEach(function(u){u.getAttribute(s)||(u.addEventListener("mouseenter",function(){var f=this.querySelector(t);f&&(f.style[l[0]]=l[1])}),u.addEventListener("mouseleave",function(){var f=this.querySelector(t);f&&(r&&this.matches(r)?f.style[l[0]]=l[1]:f.style[o[0]]=o[1])}),u.setAttribute(s,!0))})}function IU(e){var r=e6(e),t=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1];return r.forEach(function(a){var n=j4(a);if(n){var i=DU.convertCssMatrix(n);t=EU.multiply(t,t,i)}}),t}function j4(e){var r=window.getComputedStyle(e,null),t=r.getPropertyValue("-webkit-transform")||r.getPropertyValue("-moz-transform")||r.getPropertyValue("-ms-transform")||r.getPropertyValue("-o-transform")||r.getPropertyValue("transform");return t==="none"?null:t.replace("matrix","").replace("3d","").slice(1,-1).split(",").map(function(a){return+a})}function e6(e){for(var r=[];HU(e);)r.push(e),e=e.parentNode,typeof ShadowRoot=="function"&&e instanceof ShadowRoot&&(e=e.host);return r}function HU(e){return e&&(e instanceof Element||e instanceof HTMLElement)}function BU(e,r){return e&&r&&e.top===r.top&&e.left===r.left&&e.right===r.right&&e.bottom===r.bottom}r6.exports={getGraphDiv:PU,isPlotDiv:RU,removeElement:Q4,addStyleRule:zU,addRelatedStyleRule:$4,deleteRelatedStyleRule:NU,setStyleOnHover:FU,getFullTransformMatrix:IU,getElementTransformMatrix:j4,getElementAndAncestors:e6,equalDomRects:BU}});var Df=G((Ave,t6)=>{"use strict";t6.exports={mode:{valType:"enumerated",dflt:"afterall",values:["immediate","next","afterall"]},direction:{valType:"enumerated",values:["forward","reverse"],dflt:"forward"},fromcurrent:{valType:"boolean",dflt:!1},frame:{duration:{valType:"number",min:0,dflt:500},redraw:{valType:"boolean",dflt:!0}},transition:{duration:{valType:"number",min:0,dflt:500,editType:"none"},easing:{valType:"enumerated",dflt:"cubic-in-out",values:["linear","quad","cubic","sin","exp","circle","elastic","back","bounce","linear-in","quad-in","cubic-in","sin-in","exp-in","circle-in","elastic-in","back-in","bounce-in","linear-out","quad-out","cubic-out","sin-out","exp-out","circle-out","elastic-out","back-out","bounce-out","linear-in-out","quad-in-out","cubic-in-out","sin-in-out","exp-in-out","circle-in-out","elastic-in-out","back-in-out","bounce-in-out"],editType:"none"},ordering:{valType:"enumerated",values:["layout first","traces first"],dflt:"layout first",editType:"none"}}}});var fi=G((kve,u6)=>{"use strict";var n6=Lt().extendFlat,OU=Sl(),i6={valType:"flaglist",extras:["none"],flags:["calc","clearAxisTypes","plot","style","markerSize","colorbars"]},l6={valType:"flaglist",extras:["none"],flags:["calc","plot","legend","ticks","axrange","layoutstyle","modebar","camera","arraydraw","colorbars"]},YU=i6.flags.slice().concat(["fullReplot"]),UU=l6.flags.slice().concat("layoutReplot");u6.exports={traces:i6,layout:l6,traceFlags:function(){return a6(YU)},layoutFlags:function(){return a6(UU)},update:function(e,r){var t=r.editType;if(t&&t!=="none")for(var a=t.split("+"),n=0;n{"use strict";$p.dash={valType:"string",values:["solid","dot","dash","longdash","dashdot","longdashdot"],dflt:"solid",editType:"style"};$p.pattern={shape:{valType:"enumerated",values:["","/","\\","x","-","|","+","."],dflt:"",arrayOk:!0,editType:"style"},path:{valType:"string",arrayOk:!0,editType:"style"},fillmode:{valType:"enumerated",values:["replace","overlay"],dflt:"replace",editType:"style"},bgcolor:{valType:"color",arrayOk:!0,editType:"style"},fgcolor:{valType:"color",arrayOk:!0,editType:"style"},fgopacity:{valType:"number",editType:"style",min:0,max:1},size:{valType:"number",min:0,dflt:8,arrayOk:!0,editType:"style"},solidity:{valType:"number",min:0,max:1,dflt:.3,arrayOk:!0,editType:"style"},editType:"style"}});var jp=G((Sve,f6)=>{"use strict";f6.exports={FORMAT_LINK:"https://github.com/d3/d3-format/tree/v1.4.5#d3-format",DATE_FORMAT_LINK:"https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format"}});var ci=G(Ef=>{"use strict";var c6=jp(),GU=c6.FORMAT_LINK,VU=c6.DATE_FORMAT_LINK;function WU(e){var r=e&&e.supportOther;return["Variables are inserted using %{variable},",'for example "y: %{y}"'+(r?" as well as %{xother}, {%_xother}, {%_xother_}, {%xother_}. When showing info for several points, *xother* will be added to those with different x positions from the first point. An underscore before or after *(x|y)other* will add a space on that side, only when this field is shown.":"."),`Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example "Price: %{y:$.2f}".`,GU,"for details on the formatting syntax.",`Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example "Day: %{2019-01-01|%A}".`,VU,"for details on the date formatting syntax."].join(" ")}Ef.templateFormatStringDescription=WU;function em(e){var r=e.description?" "+e.description:"",t=e.keys||[];if(t.length>0){for(var a=[],n=0;n{"use strict";function Dl(e,r){return r?r.d2l(e):e}function v6(e,r){return r?r.l2d(e):e}function ZU(e){return e.x0}function XU(e){return e.x1}function JU(e){return e.y0}function KU(e){return e.y1}function h6(e){return e.x0shift||0}function d6(e){return e.x1shift||0}function p6(e){return e.y0shift||0}function m6(e){return e.y1shift||0}function a0(e,r){return Dl(e.x1,r)+d6(e)-Dl(e.x0,r)-h6(e)}function n0(e,r,t){return Dl(e.y1,t)+m6(e)-Dl(e.y0,t)-p6(e)}function QU(e,r){return Math.abs(a0(e,r))}function $U(e,r,t){return Math.abs(n0(e,r,t))}function jU(e,r,t){return e.type!=="line"?void 0:Math.sqrt(Math.pow(a0(e,r),2)+Math.pow(n0(e,r,t),2))}function eG(e,r){return v6((Dl(e.x1,r)+d6(e)+Dl(e.x0,r)+h6(e))/2,r)}function rG(e,r,t){return v6((Dl(e.y1,t)+m6(e)+Dl(e.y0,t)+p6(e))/2,t)}function tG(e,r,t){return e.type!=="line"?void 0:n0(e,r,t)/a0(e,r)}y6.exports={x0:ZU,x1:XU,y0:JU,y1:KU,slope:tG,dx:a0,dy:n0,width:QU,height:$U,length:jU,xcenter:eG,ycenter:rG}});var x6=G((Dve,b6)=>{"use strict";var aG=fi().overrideAll,qo=ui(),g6=ya(),nG=rl().dash,El=Lt().extendFlat,iG=ci().shapeTexttemplateAttrs,lG=i0();b6.exports=aG({newshape:{visible:El({},qo.visible,{}),showlegend:{valType:"boolean",dflt:!1},legend:El({},qo.legend,{}),legendgroup:El({},qo.legendgroup,{}),legendgrouptitle:{text:El({},qo.legendgrouptitle.text,{}),font:g6({})},legendrank:El({},qo.legendrank,{}),legendwidth:El({},qo.legendwidth,{}),line:{color:{valType:"color"},width:{valType:"number",min:0,dflt:4},dash:El({},nG,{dflt:"solid"})},fillcolor:{valType:"color",dflt:"rgba(0,0,0,0)"},fillrule:{valType:"enumerated",values:["evenodd","nonzero"],dflt:"evenodd"},opacity:{valType:"number",min:0,max:1,dflt:1},layer:{valType:"enumerated",values:["below","above","between"],dflt:"above"},drawdirection:{valType:"enumerated",values:["ortho","horizontal","vertical","diagonal"],dflt:"diagonal"},name:El({},qo.name,{}),label:{text:{valType:"string",dflt:""},texttemplate:iG({newshape:!0},{keys:Object.keys(lG)}),font:g6({}),textposition:{valType:"enumerated",values:["top left","top center","top right","middle left","middle center","middle right","bottom left","bottom center","bottom right","start","middle","end"]},textangle:{valType:"angle",dflt:"auto"},xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"auto"},yanchor:{valType:"enumerated",values:["top","middle","bottom"]},padding:{valType:"number",dflt:3,min:0}}},activeshape:{fillcolor:{valType:"color",dflt:"rgb(255,0,255)"},opacity:{valType:"number",min:0,max:1,dflt:.5}}},"none","from-root")});var w6=G((Eve,_6)=>{"use strict";var oG=rl().dash,sG=Lt().extendFlat;_6.exports={newselection:{mode:{valType:"enumerated",values:["immediate","gradual"],dflt:"immediate",editType:"none"},line:{color:{valType:"color",editType:"none"},width:{valType:"number",min:1,dflt:1,editType:"none"},dash:sG({},oG,{dflt:"dot",editType:"none"}),editType:"none"},editType:"none"},activeselection:{fillcolor:{valType:"color",dflt:"rgba(0,0,0,0)",editType:"none"},opacity:{valType:"number",min:0,max:1,dflt:.5,editType:"none"},editType:"none"}}});var l0=G((Pve,T6)=>{"use strict";T6.exports=function(e){var r=e.editType;return{t:{valType:"number",dflt:0,editType:r},r:{valType:"number",dflt:0,editType:r},b:{valType:"number",dflt:0,editType:r},l:{valType:"number",dflt:0,editType:r},editType:r}}});var Ys=G((Rve,C6)=>{"use strict";var rm=ya(),uG=Df(),o0=Ri(),M6=x6(),A6=w6(),fG=l0(),k6=Lt().extendFlat,s0=rm({editType:"calc"});s0.family.dflt='"Open Sans", verdana, arial, sans-serif';s0.size.dflt=12;s0.color.dflt=o0.defaultLine;C6.exports={font:s0,title:{text:{valType:"string",editType:"layoutstyle"},font:rm({editType:"layoutstyle"}),subtitle:{text:{valType:"string",editType:"layoutstyle"},font:rm({editType:"layoutstyle"}),editType:"layoutstyle"},xref:{valType:"enumerated",dflt:"container",values:["container","paper"],editType:"layoutstyle"},yref:{valType:"enumerated",dflt:"container",values:["container","paper"],editType:"layoutstyle"},x:{valType:"number",min:0,max:1,dflt:.5,editType:"layoutstyle"},y:{valType:"number",min:0,max:1,dflt:"auto",editType:"layoutstyle"},xanchor:{valType:"enumerated",dflt:"auto",values:["auto","left","center","right"],editType:"layoutstyle"},yanchor:{valType:"enumerated",dflt:"auto",values:["auto","top","middle","bottom"],editType:"layoutstyle"},pad:k6(fG({editType:"layoutstyle"}),{}),automargin:{valType:"boolean",dflt:!1,editType:"plot"},editType:"layoutstyle"},uniformtext:{mode:{valType:"enumerated",values:[!1,"hide","show"],dflt:!1,editType:"plot"},minsize:{valType:"number",min:0,dflt:0,editType:"plot"},editType:"plot"},autosize:{valType:"boolean",dflt:!1,editType:"none"},width:{valType:"number",min:10,dflt:700,editType:"plot"},height:{valType:"number",min:10,dflt:450,editType:"plot"},minreducedwidth:{valType:"number",min:2,dflt:64,editType:"plot"},minreducedheight:{valType:"number",min:2,dflt:64,editType:"plot"},margin:{l:{valType:"number",min:0,dflt:80,editType:"plot"},r:{valType:"number",min:0,dflt:80,editType:"plot"},t:{valType:"number",min:0,dflt:100,editType:"plot"},b:{valType:"number",min:0,dflt:80,editType:"plot"},pad:{valType:"number",min:0,dflt:0,editType:"plot"},autoexpand:{valType:"boolean",dflt:!0,editType:"plot"},editType:"plot"},computed:{valType:"any",editType:"none"},paper_bgcolor:{valType:"color",dflt:o0.background,editType:"plot"},plot_bgcolor:{valType:"color",dflt:o0.background,editType:"layoutstyle"},autotypenumbers:{valType:"enumerated",values:["convert types","strict"],dflt:"convert types",editType:"calc"},separators:{valType:"string",editType:"plot"},hidesources:{valType:"boolean",dflt:!1,editType:"plot"},showlegend:{valType:"boolean",editType:"legend"},colorway:{valType:"colorlist",dflt:o0.defaults,editType:"calc"},datarevision:{valType:"any",editType:"calc"},uirevision:{valType:"any",editType:"none"},editrevision:{valType:"any",editType:"none"},selectionrevision:{valType:"any",editType:"none"},template:{valType:"any",editType:"calc"},newshape:M6.newshape,activeshape:M6.activeshape,newselection:A6.newselection,activeselection:A6.activeselection,meta:{valType:"any",arrayOk:!0,editType:"plot"},transition:k6({},uG.transition,{editType:"none"})}});var S6=G(()=>{(function(){if(!document.getElementById("3c064a8e3ac8724bf169711f7229736c11b26e1b8044500ca1e17f90ea98e88e")){var e=document.createElement("style");e.id="3c064a8e3ac8724bf169711f7229736c11b26e1b8044500ca1e17f90ea98e88e",e.textContent=`.maplibregl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0 0 0/0)}.maplibregl-canvas{left:0;position:absolute;top:0}.maplibregl-map:fullscreen{height:100%;width:100%}.maplibregl-ctrl-group button.maplibregl-ctrl-compass{touch-action:none}.maplibregl-canvas-container.maplibregl-interactive,.maplibregl-ctrl-group button.maplibregl-ctrl-compass{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-canvas-container.maplibregl-interactive.maplibregl-track-pointer{cursor:pointer}.maplibregl-canvas-container.maplibregl-interactive:active,.maplibregl-ctrl-group button.maplibregl-ctrl-compass:active{cursor:grabbing}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-canvas-container.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:pinch-zoom}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:none}.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures,.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.maplibregl-ctrl-top-left{left:0;top:0}.maplibregl-ctrl-top-right{right:0;top:0}.maplibregl-ctrl-bottom-left{bottom:0;left:0}.maplibregl-ctrl-bottom-right{bottom:0;right:0}.maplibregl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.maplibregl-ctrl-top-left .maplibregl-ctrl{float:left;margin:10px 0 0 10px}.maplibregl-ctrl-top-right .maplibregl-ctrl{float:right;margin:10px 10px 0 0}.maplibregl-ctrl-bottom-left .maplibregl-ctrl{float:left;margin:0 0 10px 10px}.maplibregl-ctrl-bottom-right .maplibregl-ctrl{float:right;margin:0 10px 10px 0}.maplibregl-ctrl-group{background:#fff;border-radius:4px}.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (forced-colors:active){.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.maplibregl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;padding:0;width:29px}.maplibregl-ctrl-group button+button{border-top:1px solid #ddd}.maplibregl-ctrl button .maplibregl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (forced-colors:active){.maplibregl-ctrl-icon{background-color:transparent}.maplibregl-ctrl-group button+button{border-top:1px solid ButtonText}}.maplibregl-ctrl button::-moz-focus-inner{border:0;padding:0}.maplibregl-ctrl-attrib-button:focus,.maplibregl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl button:disabled{cursor:not-allowed}.maplibregl-ctrl button:disabled .maplibregl-ctrl-icon{opacity:.25}.maplibregl-ctrl button:not(:disabled):hover{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.maplibregl-ctrl-group button:focus:first-child{border-radius:4px 4px 0 0}.maplibregl-ctrl-group button:focus:last-child{border-radius:0 0 4px 4px}.maplibregl-ctrl-group button:focus:only-child{border-radius:inherit}.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%23333' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%2333b5e5' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23aaa' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-waiting .maplibregl-ctrl-icon{animation:maplibregl-spin 2s linear infinite}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23999' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23666' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}}@keyframes maplibregl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.maplibregl-ctrl-logo.maplibregl-compact{width:14px}@media (forced-colors:active){a.maplibregl-ctrl-logo{background-color:transparent;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}.maplibregl-ctrl.maplibregl-ctrl-attrib{background-color:hsla(0,0%,100%,.5);margin:0;padding:0 5px}@media screen{.maplibregl-ctrl-attrib.maplibregl-compact{background-color:#fff;border-radius:12px;box-sizing:content-box;color:#000;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.maplibregl-ctrl-attrib.maplibregl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact-show,.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner{display:none}.maplibregl-ctrl-attrib-button{background-color:hsla(0,0%,100%,.5);background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;list-style:none}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker{display:none}.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button{left:0}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner{display:block}.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-bottom-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;right:0}.maplibregl-ctrl-top-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{right:0;top:0}.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{left:0;top:0}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;left:0}}@media screen and (forced-colors:active){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='%23fff' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.maplibregl-ctrl-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.maplibregl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.maplibregl-attrib-empty{display:none}.maplibregl-ctrl-scale{background-color:hsla(0,0%,100%,.75);border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px}.maplibregl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.maplibregl-popup-anchor-top,.maplibregl-popup-anchor-top-left,.maplibregl-popup-anchor-top-right{flex-direction:column}.maplibregl-popup-anchor-bottom,.maplibregl-popup-anchor-bottom-left,.maplibregl-popup-anchor-bottom-right{flex-direction:column-reverse}.maplibregl-popup-anchor-left{flex-direction:row}.maplibregl-popup-anchor-right{flex-direction:row-reverse}.maplibregl-popup-tip{border:10px solid transparent;height:0;width:0;z-index:1}.maplibregl-popup-anchor-top .maplibregl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.maplibregl-popup-anchor-top-left .maplibregl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.maplibregl-popup-anchor-top-right .maplibregl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.maplibregl-popup-anchor-bottom .maplibregl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.maplibregl-popup-anchor-left .maplibregl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.maplibregl-popup-anchor-right .maplibregl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.maplibregl-popup-close-button{background-color:transparent;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.maplibregl-popup-close-button:hover{background-color:rgb(0 0 0/5%)}.maplibregl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:15px 10px;pointer-events:auto;position:relative}.maplibregl-popup-anchor-top-left .maplibregl-popup-content{border-top-left-radius:0}.maplibregl-popup-anchor-top-right .maplibregl-popup-content{border-top-right-radius:0}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-content{border-bottom-left-radius:0}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-content{border-bottom-right-radius:0}.maplibregl-popup-track-pointer{display:none}.maplibregl-popup-track-pointer *{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-map:hover .maplibregl-popup-track-pointer{display:flex}.maplibregl-map:active .maplibregl-popup-track-pointer{display:none}.maplibregl-marker{left:0;position:absolute;top:0;transition:opacity .2s;will-change:transform}.maplibregl-user-location-dot,.maplibregl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.maplibregl-user-location-dot:before{animation:maplibregl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.maplibregl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px rgba(0,0,0,.35);box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}@keyframes maplibregl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.maplibregl-user-location-dot-stale{background-color:#aaa}.maplibregl-user-location-dot-stale:after{display:none}.maplibregl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive,.maplibregl-crosshair .maplibregl-interactive:active{cursor:crosshair}.maplibregl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}.maplibregl-cooperative-gesture-screen{align-items:center;background:rgba(0,0,0,.4);color:#fff;display:flex;font-size:1.4em;inset:0;justify-content:center;line-height:1.2;opacity:0;padding:1rem;pointer-events:none;position:absolute;transition:opacity 1s ease 1s;z-index:99999}.maplibregl-cooperative-gesture-screen.maplibregl-show{opacity:1;transition:opacity .05s}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:none}@media (hover:none),(width <= 480px){.maplibregl-cooperative-gesture-screen .maplibregl-desktop-message{display:none}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:block}}.maplibregl-pseudo-fullscreen{height:100%!important;left:0!important;position:fixed!important;top:0!important;width:100%!important;z-index:99999}`,document.head.appendChild(e)}})()});var gr=G(Hr=>{"use strict";var Us=So(),q6=r0(),L6=Jp(),cG=Sl(),vG=Lf().addStyleRule,D6=Lt(),hG=ui(),dG=Ys(),pG=D6.extendFlat,tm=D6.extendDeepAll;Hr.modules={};Hr.allCategories={};Hr.allTypes=[];Hr.subplotsRegistry={};Hr.componentsRegistry={};Hr.layoutArrayContainers=[];Hr.layoutArrayRegexes=[];Hr.traceLayoutAttributes={};Hr.localeRegistry={};Hr.apiMethodRegistry={};Hr.collectableSubplotTypes=null;Hr.register=function(r){if(Hr.collectableSubplotTypes=null,r)r&&!Array.isArray(r)&&(r=[r]);else throw new Error("No argument passed to Plotly.register.");for(var t=0;t{"use strict";var _G=zs().timeFormat,Y6=zr(),am=So(),Rl=Fs().mod,Ws=Ct(),Rn=Ws.BADNUM,sn=Ws.ONEDAY,Pf=Ws.ONEHOUR,Pl=Ws.ONEMIN,Vs=Ws.ONESEC,Rf=Ws.EPOCHJD,tl=gr(),N6=zs().utcFormat,wG=/^\s*(-?\d\d\d\d|\d\d)(-(\d?\d)(-(\d?\d)([ Tt]([01]?\d|2[0-3])(:([0-5]\d)(:([0-5]\d(\.\d+)?))?(Z|z|[+\-]\d\d(:?\d\d)?)?)?)?)?)?\s*$/m,TG=/^\s*(-?\d\d\d\d|\d\d)(-(\d?\di?)(-(\d?\d)([ Tt]([01]?\d|2[0-3])(:([0-5]\d)(:([0-5]\d(\.\d+)?))?(Z|z|[+\-]\d\d(:?\d\d)?)?)?)?)?)?\s*$/m,F6=new Date().getFullYear()-70;function al(e){return e&&tl.componentsRegistry.calendars&&typeof e=="string"&&e!=="gregorian"}Wt.dateTick0=function(e,r){var t=MG(e,!!r);if(r<2)return t;var a=Wt.dateTime2ms(t,e);return a+=sn*(r-1),Wt.ms2DateTime(a,0,e)};function MG(e,r){return al(e)?r?tl.getComponentMethod("calendars","CANONICAL_SUNDAY")[e]:tl.getComponentMethod("calendars","CANONICAL_TICK")[e]:r?"2000-01-02":"2000-01-01"}Wt.dfltRange=function(e){return al(e)?tl.getComponentMethod("calendars","DFLTRANGE")[e]:["2000-01-01","2001-01-01"]};Wt.isJSDate=function(e){return typeof e=="object"&&e!==null&&typeof e.getTime=="function"};var f0,c0;Wt.dateTime2ms=function(e,r){if(Wt.isJSDate(e)){var t=e.getTimezoneOffset()*Pl,a=(e.getUTCMinutes()-e.getMinutes())*Pl+(e.getUTCSeconds()-e.getSeconds())*Vs+(e.getUTCMilliseconds()-e.getMilliseconds());if(a){var n=3*Pl;t=t-n/2+Rl(a-t+n/2,n)}return e=Number(e)-t,e>=f0&&e<=c0?e:Rn}if(typeof e!="string"&&typeof e!="number")return Rn;e=String(e);var i=al(r),l=e.charAt(0);i&&(l==="G"||l==="g")&&(e=e.substr(1),r="");var o=i&&r.substr(0,7)==="chinese",s=e.match(o?TG:wG);if(!s)return Rn;var u=s[1],f=s[3]||"1",c=Number(s[5]||1),h=Number(s[7]||0),d=Number(s[9]||0),p=Number(s[11]||0);if(i){if(u.length===2)return Rn;u=Number(u);var y;try{var g=tl.getComponentMethod("calendars","getCal")(r);if(o){var x=f.charAt(f.length-1)==="i";f=parseInt(f,10),y=g.newDate(u,g.toMonthIndex(u,f,x),c)}else y=g.newDate(u,Number(f),c)}catch(M){return Rn}return y?(y.toJD()-Rf)*sn+h*Pf+d*Pl+p*Vs:Rn}u.length===2?u=(Number(u)+2e3-F6)%100+F6:u=Number(u),f-=1;var _=new Date(Date.UTC(2e3,f,c,h,d));return _.setUTCFullYear(u),_.getUTCMonth()!==f||_.getUTCDate()!==c?Rn:_.getTime()+p*Vs};f0=Wt.MIN_MS=Wt.dateTime2ms("-9999");c0=Wt.MAX_MS=Wt.dateTime2ms("9999-12-31 23:59:59.9999");Wt.isDateTime=function(e,r){return Wt.dateTime2ms(e,r)!==Rn};function Gs(e,r){return String(e+Math.pow(10,r)).substr(1)}var u0=90*sn,I6=3*Pf,H6=5*Pl;Wt.ms2DateTime=function(e,r,t){if(typeof e!="number"||!(e>=f0&&e<=c0))return Rn;r||(r=0);var a=Math.floor(Rl(e+.05,1)*10),n=Math.round(e-a/10),i,l,o,s,u,f;if(al(t)){var c=Math.floor(n/sn)+Rf,h=Math.floor(Rl(e,sn));try{i=tl.getComponentMethod("calendars","getCal")(t).fromJD(c).formatDate("yyyy-mm-dd")}catch(d){i=N6("G%Y-%m-%d")(new Date(n))}if(i.charAt(0)==="-")for(;i.length<11;)i="-0"+i.substr(1);else for(;i.length<10;)i="0"+i;l=r=f0+sn&&e<=c0-sn))return Rn;var r=Math.floor(Rl(e+.05,1)*10),t=new Date(Math.round(e-r/10)),a=_G("%Y-%m-%d")(t),n=t.getHours(),i=t.getMinutes(),l=t.getSeconds(),o=t.getUTCMilliseconds()*10+r;return U6(a,n,i,l,o)};function U6(e,r,t,a,n){if((r||t||a||n)&&(e+=" "+Gs(r,2)+":"+Gs(t,2),(a||n)&&(e+=":"+Gs(a,2),n))){for(var i=4;n%10===0;)i-=1,n/=10;e+="."+Gs(n,i)}return e}Wt.cleanDate=function(e,r,t){if(e===Rn)return r;if(Wt.isJSDate(e)||typeof e=="number"&&isFinite(e)){if(al(t))return am.error("JS Dates and milliseconds are incompatible with world calendars",e),r;if(e=Wt.ms2DateTimeLocal(+e),!e&&r!==void 0)return r}else if(!Wt.isDateTime(e,t))return am.error("unrecognized date",e),r;return e};var AG=/%\d?f/g,kG=/%h/g,CG={1:"1",2:"1",3:"2",4:"2"};function B6(e,r,t,a){e=e.replace(AG,function(i){var l=Math.min(+i.charAt(1)||6,6),o=(r/1e3%1+2).toFixed(l).substr(2).replace(/0+$/,"")||"0";return o});var n=new Date(Math.floor(r+.05));if(e=e.replace(kG,function(){return CG[t("%q")(n)]}),al(a))try{e=tl.getComponentMethod("calendars","worldCalFmt")(e,r,a)}catch(i){return"Invalid"}return t(e)(n)}var SG=[59,59.9,59.99,59.999,59.9999];function qG(e,r){var t=Rl(e+.05,sn),a=Gs(Math.floor(t/Pf),2)+":"+Gs(Rl(Math.floor(t/Pl),60),2);if(r!=="M"){Y6(r)||(r=0);var n=Math.min(Rl(e/Vs,60),SG[r]),i=(100+n).toFixed(r).substr(1);r>0&&(i=i.replace(/0+$/,"").replace(/[\.]$/,"")),a+=":"+i}return a}Wt.formatDate=function(e,r,t,a,n,i){if(n=al(n)&&n,!r)if(t==="y")r=i.year;else if(t==="m")r=i.month;else if(t==="d")r=i.dayMonth+`
+`+i.year;else return qG(e,t)+`
+`+B6(i.dayMonthYear,e,a,n);return B6(r,e,a,n)};var O6=3*sn;Wt.incrementMonth=function(e,r,t){t=al(t)&&t;var a=Rl(e,sn);if(e=Math.round(e-a),t)try{var n=Math.round(e/sn)+Rf,i=tl.getComponentMethod("calendars","getCal")(t),l=i.fromJD(n);return r%12?i.add(l,r,"m"):i.add(l,r/12,"y"),(l.toJD()-Rf)*sn+a}catch(s){am.error("invalid ms "+e+" in calendar "+t)}var o=new Date(e+O6);return o.setUTCMonth(o.getUTCMonth()+r)+a-O6};Wt.findExactDates=function(e,r){for(var t=0,a=0,n=0,i=0,l,o,s=al(r)&&tl.getComponentMethod("calendars","getCal")(r),u=0;u{"use strict";V6.exports=function(r){return r}});var im=G(nl=>{"use strict";var LG=zr(),DG=So(),EG=v0(),PG=Ct().BADNUM,nm=1e-9;nl.findBin=function(e,r,t){if(LG(r.start))return t?Math.ceil((e-r.start)/r.size-nm)-1:Math.floor((e-r.start)/r.size+nm);var a=0,n=r.length,i=0,l=n>1?(r[n-1]-r[0])/(n-1):1,o,s;for(l>=0?s=t?RG:zG:s=t?FG:NG,e+=l*nm*(t?-1:1)*(l>=0?1:-1);a90&&DG.log("Long binary search..."),a-1};function RG(e,r){return er}function FG(e,r){return e>=r}nl.sorterAsc=function(e,r){return e-r};nl.sorterDes=function(e,r){return r-e};nl.distinctVals=function(e){var r=e.slice();r.sort(nl.sorterAsc);var t;for(t=r.length-1;t>-1&&r[t]===PG;t--);for(var a=r[t]-r[0]||1,n=a/(t||1)/1e4,i=[],l,o=0;o<=t;o++){var s=r[o],u=s-l;l===void 0?(i.push(s),l=s):u>n&&(a=Math.min(a,u),i.push(s),l=s)}return{vals:i,minDiff:a}};nl.roundUp=function(e,r,t){for(var a=0,n=r.length-1,i,l=0,o=t?0:1,s=t?1:0,u=t?Math.ceil:Math.floor;a0&&(a=1),t&&a)return e.sort(r)}return a?e:e.reverse()};nl.findIndexOfMin=function(e,r){r=r||EG;for(var t=1/0,a,n=0;n{"use strict";W6.exports=function(r){return Object.keys(r).sort()}});var Z6=G(Zt=>{"use strict";var zf=zr(),IG=$a().isArrayOrTypedArray;Zt.aggNums=function(e,r,t,a){var n,i;if((!a||a>t.length)&&(a=t.length),zf(r)||(r=!1),IG(t[0])){for(i=new Array(a),n=0;ne.length-1)return e[e.length-1];var t=r%1;return t*e[Math.ceil(r)]+(1-t)*e[Math.floor(r)]}});var $6=G((Uve,Q6)=>{"use strict";var X6=Fs(),om=X6.mod,HG=X6.modHalf,Nf=Math.PI,zl=2*Nf;function BG(e){return e/180*Nf}function OG(e){return e/Nf*180}function sm(e){return Math.abs(e[1]-e[0])>zl-1e-14}function J6(e,r){return HG(r-e,zl)}function YG(e,r){return Math.abs(J6(e,r))}function K6(e,r){if(sm(r))return!0;var t,a;r[0]a&&(a+=zl);var n=om(e,zl),i=n+zl;return n>=t&&n<=a||i>=t&&i<=a}function UG(e,r,t,a){if(!K6(r,a))return!1;var n,i;return t[0]=n&&e<=i}function um(e,r,t,a,n,i,l){n=n||0,i=i||0;var o=sm([t,a]),s,u,f,c,h;o?(s=0,u=Nf,f=zl):t{"use strict";Lo.isLeftAnchor=function(r){return r.xanchor==="left"||r.xanchor==="auto"&&r.x<=1/3};Lo.isCenterAnchor=function(r){return r.xanchor==="center"||r.xanchor==="auto"&&r.x>1/3&&r.x<2/3};Lo.isRightAnchor=function(r){return r.xanchor==="right"||r.xanchor==="auto"&&r.x>=2/3};Lo.isTopAnchor=function(r){return r.yanchor==="top"||r.yanchor==="auto"&&r.y>=2/3};Lo.isMiddleAnchor=function(r){return r.yanchor==="middle"||r.yanchor==="auto"&&r.y>1/3&&r.y<2/3};Lo.isBottomAnchor=function(r){return r.yanchor==="bottom"||r.yanchor==="auto"&&r.y<=1/3}});var t7=G(Do=>{"use strict";var fm=Fs().mod;Do.segmentsIntersect=r7;function r7(e,r,t,a,n,i,l,o){var s=t-e,u=n-e,f=l-n,c=a-r,h=i-r,d=o-i,p=s*d-f*c;if(p===0)return null;var y=(u*d-f*h)/p,g=(u*c-s*h)/p;return g<0||g>1||y<0||y>1?null:{x:e+s*y,y:r+c*y}}Do.segmentDistance=function(r,t,a,n,i,l,o,s){if(r7(r,t,a,n,i,l,o,s))return 0;var u=a-r,f=n-t,c=o-i,h=s-l,d=u*u+f*f,p=c*c+h*h,y=Math.min(h0(u,f,d,i-r,l-t),h0(u,f,d,o-r,s-t),h0(c,h,p,r-i,t-l),h0(c,h,p,a-i,n-l));return Math.sqrt(y)};function h0(e,r,t,a,n){var i=a*e+n*r;if(i<0)return a*a+n*n;if(i>t){var l=a-e,o=n-r;return l*l+o*o}else{var s=a*r-n*e;return s*s/t}}var d0,cm,e7;Do.getTextLocation=function(r,t,a,n){if((r!==cm||n!==e7)&&(d0={},cm=r,e7=n),d0[a])return d0[a];var i=r.getPointAtLength(fm(a-n/2,t)),l=r.getPointAtLength(fm(a+n/2,t)),o=Math.atan((l.y-i.y)/(l.x-i.x)),s=r.getPointAtLength(fm(a,t)),u=(s.x*4+i.x+l.x)/6,f=(s.y*4+i.y+l.y)/6,c={x:u,y:f,theta:o};return d0[a]=c,c};Do.clearLocationCache=function(){cm=null};Do.getVisibleSegment=function(r,t,a){var n=t.left,i=t.right,l=t.top,o=t.bottom,s=0,u=r.getTotalLength(),f=u,c,h;function d(y){var g=r.getPointAtLength(y);y===0?c=g:y===u&&(h=g);var x=g.xi?g.x-i:0,_=g.yo?g.y-o:0;return Math.sqrt(x*x+_*_)}for(var p=d(s);p;){if(s+=p+a,s>f)return;p=d(s)}for(p=d(f);p;){if(f-=p+a,s>f)return;p=d(f)}return{min:s,max:f,len:f-s,total:u,isClosed:s===0&&f===u&&Math.abs(c.x-h.x)<.1&&Math.abs(c.y-h.y)<.1}};Do.findPointOnPath=function(r,t,a,n){n=n||{};for(var i=n.pathLength||r.getTotalLength(),l=n.tolerance||.001,o=n.iterationLimit||30,s=r.getPointAtLength(0)[a]>r.getPointAtLength(i)[a]?-1:1,u=0,f=0,c=i,h,d,p;u0?c=h:f=h,u++}return d}});var p0=G(Ff=>{"use strict";var il={};Ff.throttle=function(r,t,a){var n=il[r],i=Date.now();if(!n){for(var l in il)il[l].tsn.ts+t){o();return}n.timer=setTimeout(function(){o(),n.timer=null},t)};Ff.done=function(e){var r=il[e];return!r||!r.timer?Promise.resolve():new Promise(function(t){var a=r.onDone;r.onDone=function(){a&&a(),t(),r.onDone=null}})};Ff.clear=function(e){if(e)a7(il[e]),delete il[e];else for(var r in il)Ff.clear(r)};function a7(e){e&&e.timer!==null&&(clearTimeout(e.timer),e.timer=null)}});var i7=G((Zve,n7)=>{"use strict";n7.exports=function(r){r._responsiveChartHandler&&(window.removeEventListener("resize",r._responsiveChartHandler),delete r._responsiveChartHandler)}});var l7=G((Xve,m0)=>{"use strict";m0.exports=vm;m0.exports.isMobile=vm;m0.exports.default=vm;var ZG=/(android|bb\d+|meego).+mobile|armv7l|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|samsungbrowser.*mobile|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i,XG=/CrOS/,JG=/android|ipad|playbook|silk/i;function vm(e){e||(e={});let r=e.ua;if(!r&&typeof navigator!="undefined"&&(r=navigator.userAgent),r&&r.headers&&typeof r.headers["user-agent"]=="string"&&(r=r.headers["user-agent"]),typeof r!="string")return!1;let t=ZG.test(r)&&!XG.test(r)||!!e.tablet&&JG.test(r);return!t&&e.tablet&&e.featureDetect&&navigator&&navigator.maxTouchPoints>1&&r.indexOf("Macintosh")!==-1&&r.indexOf("Safari")!==-1&&(t=!0),t}});var s7=G((Jve,o7)=>{"use strict";var KG=zr(),QG=l7();o7.exports=function(r){var t;if(r&&r.hasOwnProperty("userAgent")?t=r.userAgent:t=$G(),typeof t!="string")return!0;var a=QG({ua:{headers:{"user-agent":t}},tablet:!0,featureDetect:!1});if(!a)for(var n=t.split(" "),i=1;i-1;o--){var s=n[o];if(s.substr(0,8)==="Version/"){var u=s.substr(8).split(".")[0];if(KG(u)&&(u=+u),u>=13)return!0}}}return a};function $G(){var e;return typeof navigator!="undefined"&&(e=navigator.userAgent),e&&e.headers&&typeof e.headers["user-agent"]=="string"&&(e=e.headers["user-agent"]),e}});var f7=G((Kve,u7)=>{"use strict";var jG=Rr();u7.exports=function(r,t,a){var n=r.selectAll("g."+a.replace(/\s/g,".")).data(t,function(l){return l[0].trace.uid});n.exit().remove(),n.enter().append("g").attr("class",a),n.order();var i=r.classed("rangeplot")?"nodeRangePlot3":"node3";return n.each(function(l){l[0][i]=jG.select(this)}),n}});var v7=G((Qve,c7)=>{"use strict";var eV=gr();c7.exports=function(r,t){for(var a=r._context.locale,n=0;n<2;n++){for(var i=r._context.locales,l=0;l<2;l++){var o=(i[a]||{}).dictionary;if(o){var s=o[t];if(s)return s}i=eV.localeRegistry}var u=a.split("-")[0];if(u===a)break;a=u}return t}});var d7=G(($ve,h7)=>{"use strict";h7.exports=function(r){for(var t={},a=[],n=0,i=0;i{"use strict";p7.exports=function(r){for(var t=aV(r)?tV:rV,a=[],n=0;n{"use strict";y7.exports=function(r,t){if(!t)return r;var a=1/Math.abs(t),n=a>1?(a*r+a*t)/a:r+t,i=String(n).length;if(i>16){var l=String(t).length,o=String(r).length;if(i>=o+l){var s=parseFloat(n).toPrecision(12);s.indexOf("e+")===-1&&(n=+s)}}return n}});var x7=G((r0e,b7)=>{"use strict";var nV=zr(),iV=Ct().BADNUM,lV=/^['"%,$#\s']+|[, ]|['"%,$#\s']+$/g;b7.exports=function(r){return typeof r=="string"&&(r=r.replace(lV,"")),nV(r)?Number(r):iV}});var ze=G((t0e,P7)=>{"use strict";var If=Rr(),oV=zs().utcFormat,sV=zp().format,k7=zr(),C7=Ct(),S7=C7.FP_SAFE,uV=-S7,_7=C7.BADNUM,be=P7.exports={};be.adjustFormat=function(r){return!r||/^\d[.]\df/.test(r)||/[.]\d%/.test(r)?r:r==="0.f"?"~f":/^\d%/.test(r)?"~%":/^\ds/.test(r)?"~s":!/^[~,.0$]/.test(r)&&/[&fps]/.test(r)?"~"+r:r};var w7={};be.warnBadFormat=function(e){var r=String(e);w7[r]||(w7[r]=1,be.warn('encountered bad format: "'+r+'"'))};be.noFormat=function(e){return String(e)};be.numberFormat=function(e){var r;try{r=sV(be.adjustFormat(e))}catch(t){return be.warnBadFormat(e),be.noFormat}return r};be.nestedProperty=Wv();be.keyedContainer=v_();be.relativeAttr=d_();be.isPlainObject=Sl();be.toLogRange=Xv();be.relinkPrivateKeys=g_();var Nl=$a();be.isArrayBuffer=Nl.isArrayBuffer;be.isTypedArray=Nl.isTypedArray;be.isArrayOrTypedArray=Nl.isArrayOrTypedArray;be.isArray1D=Nl.isArray1D;be.ensureArray=Nl.ensureArray;be.concat=Nl.concat;be.maxRowLength=Nl.maxRowLength;be.minRowLength=Nl.minRowLength;var q7=Fs();be.mod=q7.mod;be.modHalf=q7.modHalf;var Fl=F_();be.valObjectMeta=Fl.valObjectMeta;be.coerce=Fl.coerce;be.coerce2=Fl.coerce2;be.coerceFont=Fl.coerceFont;be.coercePattern=Fl.coercePattern;be.coerceHoverinfo=Fl.coerceHoverinfo;be.coerceSelectionMarkerOpacity=Fl.coerceSelectionMarkerOpacity;be.validate=Fl.validate;var yn=G6();be.dateTime2ms=yn.dateTime2ms;be.isDateTime=yn.isDateTime;be.ms2DateTime=yn.ms2DateTime;be.ms2DateTimeLocal=yn.ms2DateTimeLocal;be.cleanDate=yn.cleanDate;be.isJSDate=yn.isJSDate;be.formatDate=yn.formatDate;be.incrementMonth=yn.incrementMonth;be.dateTick0=yn.dateTick0;be.dfltRange=yn.dfltRange;be.findExactDates=yn.findExactDates;be.MIN_MS=yn.MIN_MS;be.MAX_MS=yn.MAX_MS;var Eo=im();be.findBin=Eo.findBin;be.sorterAsc=Eo.sorterAsc;be.sorterDes=Eo.sorterDes;be.distinctVals=Eo.distinctVals;be.roundUp=Eo.roundUp;be.sort=Eo.sort;be.findIndexOfMin=Eo.findIndexOfMin;be.sortObjectKeys=lm();var ll=Z6();be.aggNums=ll.aggNums;be.len=ll.len;be.mean=ll.mean;be.geometricMean=ll.geometricMean;be.median=ll.median;be.midRange=ll.midRange;be.variance=ll.variance;be.stdev=ll.stdev;be.interp=ll.interp;var vi=t0();be.init2dArray=vi.init2dArray;be.transposeRagged=vi.transposeRagged;be.dot=vi.dot;be.translationMatrix=vi.translationMatrix;be.rotationMatrix=vi.rotationMatrix;be.rotationXYMatrix=vi.rotationXYMatrix;be.apply3DTransform=vi.apply3DTransform;be.apply2DTransform=vi.apply2DTransform;be.apply2DTransform2=vi.apply2DTransform2;be.convertCssMatrix=vi.convertCssMatrix;be.inverseTransformMatrix=vi.inverseTransformMatrix;var Ni=$6();be.deg2rad=Ni.deg2rad;be.rad2deg=Ni.rad2deg;be.angleDelta=Ni.angleDelta;be.angleDist=Ni.angleDist;be.isFullCircle=Ni.isFullCircle;be.isAngleInsideSector=Ni.isAngleInsideSector;be.isPtInsideSector=Ni.isPtInsideSector;be.pathArc=Ni.pathArc;be.pathSector=Ni.pathSector;be.pathAnnulus=Ni.pathAnnulus;var Xs=j6();be.isLeftAnchor=Xs.isLeftAnchor;be.isCenterAnchor=Xs.isCenterAnchor;be.isRightAnchor=Xs.isRightAnchor;be.isTopAnchor=Xs.isTopAnchor;be.isMiddleAnchor=Xs.isMiddleAnchor;be.isBottomAnchor=Xs.isBottomAnchor;var Js=t7();be.segmentsIntersect=Js.segmentsIntersect;be.segmentDistance=Js.segmentDistance;be.getTextLocation=Js.getTextLocation;be.clearLocationCache=Js.clearLocationCache;be.getVisibleSegment=Js.getVisibleSegment;be.findPointOnPath=Js.findPointOnPath;var b0=Lt();be.extendFlat=b0.extendFlat;be.extendDeep=b0.extendDeep;be.extendDeepAll=b0.extendDeepAll;be.extendDeepNoArrays=b0.extendDeepNoArrays;var hm=So();be.log=hm.log;be.warn=hm.warn;be.error=hm.error;var fV=Hs();be.counterRegex=fV.counter;var dm=p0();be.throttle=dm.throttle;be.throttleDone=dm.done;be.clearThrottle=dm.clear;var hi=Lf();be.getGraphDiv=hi.getGraphDiv;be.isPlotDiv=hi.isPlotDiv;be.removeElement=hi.removeElement;be.addStyleRule=hi.addStyleRule;be.addRelatedStyleRule=hi.addRelatedStyleRule;be.deleteRelatedStyleRule=hi.deleteRelatedStyleRule;be.setStyleOnHover=hi.setStyleOnHover;be.getFullTransformMatrix=hi.getFullTransformMatrix;be.getElementTransformMatrix=hi.getElementTransformMatrix;be.getElementAndAncestors=hi.getElementAndAncestors;be.equalDomRects=hi.equalDomRects;be.clearResponsive=i7();be.preserveDrawingBuffer=s7();be.makeTraceGroups=f7();be._=v7();be.notifier=Wp();be.filterUnique=d7();be.filterVisible=m7();be.pushUnique=Jp();be.increment=g7();be.cleanNumber=x7();be.ensureNumber=function(r){return k7(r)?(r=Number(r),r>S7||r=r?!1:k7(e)&&e>=0&&e%1===0};be.noop=r0();be.identity=v0();be.repeat=function(e,r){for(var t=new Array(r),a=0;at?Math.max(t,Math.min(r,e)):Math.max(r,Math.min(t,e))};be.bBoxIntersect=function(e,r,t){return t=t||0,e.left<=r.right+t&&r.left<=e.right+t&&e.top<=r.bottom+t&&r.top<=e.bottom+t};be.simpleMap=function(e,r,t,a,n){for(var i=e.length,l=new Array(i),o=0;o=Math.pow(2,t)?n>10?(be.warn("randstr failed uniqueness"),l):e(r,t,a,(n||0)+1):l};be.OptionControl=function(e,r){e||(e={}),r||(r="opt");var t={};return t.optionList=[],t._newoption=function(a){a[r]=e,t[a.name]=a,t.optionList.push(a)},t["_"+r]=e,t};be.smooth=function(e,r){if(r=Math.round(r)||0,r<2)return e;var t=e.length,a=2*t,n=2*r-1,i=new Array(n),l=new Array(t),o,s,u,f;for(o=0;o=a&&(u-=a*Math.floor(u/a)),u<0?u=-1-u:u>=t&&(u=a-1-u),f+=e[u]*i[s];l[o]=f}return l};be.syncOrAsync=function(e,r,t){var a,n;function i(){return be.syncOrAsync(e,r,t)}for(;e.length;)if(n=e.splice(0,1)[0],a=n(r),a&&a.then)return a.then(i);return t&&t(r)};be.stripTrailingSlash=function(e){return e.substr(-1)==="/"?e.substr(0,e.length-1):e};be.noneOrAll=function(e,r,t){if(e){var a=!1,n=!0,i,l;for(i=0;i0?n:0})};be.fillArray=function(e,r,t,a){if(a=a||be.identity,be.isArrayOrTypedArray(e))for(var n=0;nhV.test(window.navigator.userAgent);var dV=/Firefox\/(\d+)\.\d+/;be.getFirefoxVersion=function(){var e=dV.exec(window.navigator.userAgent);if(e&&e.length===2){var r=parseInt(e[1]);if(!isNaN(r))return r}return null};be.isD3Selection=function(e){return e instanceof If.selection};be.ensureSingle=function(e,r,t,a){var n=e.select(r+(t?"."+t:""));if(n.size())return n;var i=e.append(r);return t&&i.classed(t,!0),a&&i.call(a),i};be.ensureSingleById=function(e,r,t,a){var n=e.select(r+"#"+t);if(n.size())return n;var i=e.append(r).attr("id",t);return a&&i.call(a),i};be.objectFromPath=function(e,r){for(var t=e.split("."),a,n=a={},i=0;i1?n+l[1]:"";if(i&&(l.length>1||o.length>4||t))for(;a.test(o);)o=o.replace(a,"$1"+i+"$2");return o+s};be.TEMPLATE_STRING_REGEX=/%{([^\s%{}:]*)([:|\|][^}]*)?}/g;var E7=/^\w*$/;be.templateString=function(e,r){var t={};return e.replace(be.TEMPLATE_STRING_REGEX,function(a,n){var i;return E7.test(n)?i=r[n]:(t[n]=t[n]||be.nestedProperty(r,n).get,i=t[n](!0)),i!==void 0?i:""})};var yV={max:10,count:0,name:"hovertemplate"};be.hovertemplateString=function(){return pm.apply(yV,arguments)};var gV={max:10,count:0,name:"texttemplate"};be.texttemplateString=function(){return pm.apply(gV,arguments)};var bV=/^(\S+)([\*\/])(-?\d+(\.\d+)?)$/;function xV(e){var r=e.match(bV);return r?{key:r[1],op:r[2],number:Number(r[3])}:{key:e,op:null,number:null}}var _V={max:10,count:0,name:"texttemplate",parseMultDiv:!0};be.texttemplateStringForShapes=function(){return pm.apply(_V,arguments)};var T7=/^[:|\|]/;function pm(e,r,t){var a=this,n=arguments;return r||(r={}),e.replace(be.TEMPLATE_STRING_REGEX,function(i,l,o){var s=l==="xother"||l==="yother",u=l==="_xother"||l==="_yother",f=l==="_xother_"||l==="_yother_",c=l==="xother_"||l==="yother_",h=s||u||c||f,d=l;(u||f)&&(d=d.substring(1)),(c||f)&&(d=d.substring(0,d.length-1));var p=null,y=null;if(a.parseMultDiv){var g=xV(d);d=g.key,p=g.op,y=g.number}var x;if(h){if(x=r[d],x===void 0)return""}else{var _,M;for(M=3;M=g0&&l<=M7,u=o>=g0&&o<=M7;if(s&&(a=10*a+l-g0),u&&(n=10*n+o-g0),!s||!u){if(a!==n)return a-n;if(l!==o)return l-o}}return n-a};var Zs=2e9;be.seedPseudoRandom=function(){Zs=2e9};be.pseudoRandom=function(){var e=Zs;return Zs=(69069*Zs+1)%4294967296,Math.abs(Zs-e)<429496729?be.pseudoRandom():Zs/4294967296};be.fillText=function(e,r,t){var a=Array.isArray(t)?function(l){t.push(l)}:function(l){t.text=l},n=be.extractOption(e,r,"htx","hovertext");if(be.isValidTextValue(n))return a(n);var i=be.extractOption(e,r,"tx","text");if(be.isValidTextValue(i))return a(i)};be.isValidTextValue=function(e){return e||e===0};be.formatPercent=function(e,r){r=r||0;for(var t=(Math.round(100*e*Math.pow(10,r))*Math.pow(.1,r)).toFixed(r)+"%",a=0;a1&&(u=1):u=0,be.strTranslate(n-u*(t+l),i-u*(a+o))+be.strScale(u)+(s?"rotate("+s+(r?"":" "+t+" "+a)+")":"")};be.setTransormAndDisplay=function(e,r){e.attr("transform",be.getTextTransform(r)),e.style("display",r.scale?null:"none")};be.ensureUniformFontSize=function(e,r){var t=be.extendFlat({},r);return t.size=Math.max(r.size,e._fullLayout.uniformtext.minsize||0),t};be.join2=function(e,r,t){var a=e.length;return a>1?e.slice(0,-1).join(r)+t+e[a-1]:e.join(r)};be.bigFont=function(e){return Math.round(1.2*e)};var A7=be.getFirefoxVersion(),wV=A7!==null&&A7<86;be.getPositionFromD3Event=function(){return wV?[If.event.layerX,If.event.layerY]:[If.event.offsetX,If.event.offsetY]}});var N7=G(()=>{"use strict";var TV=ze(),R7={"X,X div":'direction:ltr;font-family:"Open Sans",verdana,arial,sans-serif;margin:0;padding:0;',"X input,X button":'font-family:"Open Sans",verdana,arial,sans-serif;',"X input:focus,X button:focus":"outline:none;","X a":"text-decoration:none;","X a:hover":"text-decoration:none;","X .crisp":"shape-rendering:crispEdges;","X .user-select-none":"-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none;","X svg a":"fill:#447adb;","X svg a:hover":"fill:#3c6dc5;","X .main-svg":"position:absolute;top:0;left:0;pointer-events:none;","X .main-svg .draglayer":"pointer-events:all;","X .cursor-default":"cursor:default;","X .cursor-pointer":"cursor:pointer;","X .cursor-crosshair":"cursor:crosshair;","X .cursor-move":"cursor:move;","X .cursor-col-resize":"cursor:col-resize;","X .cursor-row-resize":"cursor:row-resize;","X .cursor-ns-resize":"cursor:ns-resize;","X .cursor-ew-resize":"cursor:ew-resize;","X .cursor-sw-resize":"cursor:sw-resize;","X .cursor-s-resize":"cursor:s-resize;","X .cursor-se-resize":"cursor:se-resize;","X .cursor-w-resize":"cursor:w-resize;","X .cursor-e-resize":"cursor:e-resize;","X .cursor-nw-resize":"cursor:nw-resize;","X .cursor-n-resize":"cursor:n-resize;","X .cursor-ne-resize":"cursor:ne-resize;","X .cursor-grab":"cursor:-webkit-grab;cursor:grab;","X .modebar":"position:absolute;top:2px;right:2px;","X .ease-bg":"-webkit-transition:background-color .3s ease 0s;-moz-transition:background-color .3s ease 0s;-ms-transition:background-color .3s ease 0s;-o-transition:background-color .3s ease 0s;transition:background-color .3s ease 0s;","X .modebar--hover>:not(.watermark)":"opacity:0;-webkit-transition:opacity .3s ease 0s;-moz-transition:opacity .3s ease 0s;-ms-transition:opacity .3s ease 0s;-o-transition:opacity .3s ease 0s;transition:opacity .3s ease 0s;","X:hover .modebar--hover .modebar-group":"opacity:1;","X:focus-within .modebar--hover .modebar-group":"opacity:1;","X .modebar-group":"float:left;display:inline-block;box-sizing:border-box;padding-left:8px;position:relative;vertical-align:middle;white-space:nowrap;","X .modebar-group a":"display:grid;place-content:center;","X .modebar-btn":"position:relative;font-size:16px;padding:3px 4px;height:22px;cursor:pointer;line-height:normal;box-sizing:border-box;border:none;background:rgba(0,0,0,0);","X .modebar-btn svg":"position:relative;","X .modebar-btn:focus-visible":"outline:1px solid #000;outline-offset:1px;border-radius:3px;","X .modebar.vertical":"display:flex;flex-direction:column;flex-wrap:wrap;align-content:flex-end;max-height:100%;","X .modebar.vertical svg":"top:-1px;","X .modebar.vertical .modebar-group":"display:block;float:none;padding-left:0px;padding-bottom:8px;","X .modebar.vertical .modebar-group .modebar-btn":"display:block;text-align:center;","X [data-title]:before,X [data-title]:after":"position:absolute;-webkit-transform:translate3d(0, 0, 0);-moz-transform:translate3d(0, 0, 0);-ms-transform:translate3d(0, 0, 0);-o-transform:translate3d(0, 0, 0);transform:translate3d(0, 0, 0);display:none;opacity:0;z-index:1001;pointer-events:none;top:110%;right:50%;","X [data-title]:hover:before,X [data-title]:hover:after":"display:block;opacity:1;","X [data-title]:before":'content:"";position:absolute;background:rgba(0,0,0,0);border:6px solid rgba(0,0,0,0);z-index:1002;margin-top:-12px;border-bottom-color:#69738a;margin-right:-6px;',"X [data-title]:after":"content:attr(data-title);background:#69738a;color:#fff;padding:8px 10px;font-size:12px;line-height:12px;white-space:nowrap;margin-right:-18px;border-radius:2px;","X .vertical [data-title]:before,X .vertical [data-title]:after":"top:0%;right:200%;","X .vertical [data-title]:before":"border:6px solid rgba(0,0,0,0);border-left-color:#69738a;margin-top:8px;margin-right:-30px;",Y:'font-family:"Open Sans",verdana,arial,sans-serif;position:fixed;top:50px;right:20px;z-index:10000;font-size:10pt;max-width:180px;',"Y p":"margin:0;","Y .notifier-note":"min-width:180px;max-width:250px;border:1px solid #fff;z-index:3000;margin:0;background-color:#8c97af;background-color:rgba(140,151,175,.9);color:#fff;padding:10px;overflow-wrap:break-word;word-wrap:break-word;-ms-hyphens:auto;-webkit-hyphens:auto;hyphens:auto;","Y .notifier-close":"color:#fff;opacity:.8;float:right;padding:0 5px;background:none;border:none;font-size:20px;font-weight:bold;line-height:20px;","Y .notifier-close:hover":"color:#444;text-decoration:none;cursor:pointer;"};for(mm in R7)z7=mm.replace(/^,/," ,").replace(/X/g,".js-plotly-plot .plotly").replace(/Y/g,".plotly-notifier"),TV.addStyleRule(z7,R7[mm]);var z7,mm});var ym=G((i0e,F7)=>{F7.exports=!0});var bm=G((l0e,I7)=>{"use strict";var MV=ym(),gm;typeof window.matchMedia=="function"?gm=!window.matchMedia("(hover: none)").matches:gm=MV;I7.exports=gm});var w0=G((o0e,xm)=>{"use strict";var Ks=typeof Reflect=="object"?Reflect:null,H7=Ks&&typeof Ks.apply=="function"?Ks.apply:function(r,t,a){return Function.prototype.apply.call(r,t,a)},x0;Ks&&typeof Ks.ownKeys=="function"?x0=Ks.ownKeys:Object.getOwnPropertySymbols?x0=function(r){return Object.getOwnPropertyNames(r).concat(Object.getOwnPropertySymbols(r))}:x0=function(r){return Object.getOwnPropertyNames(r)};function AV(e){console&&console.warn&&console.warn(e)}var O7=Number.isNaN||function(r){return r!==r};function xt(){xt.init.call(this)}xm.exports=xt;xm.exports.once=qV;xt.EventEmitter=xt;xt.prototype._events=void 0;xt.prototype._eventsCount=0;xt.prototype._maxListeners=void 0;var B7=10;function _0(e){if(typeof e!="function")throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof e)}Object.defineProperty(xt,"defaultMaxListeners",{enumerable:!0,get:function(){return B7},set:function(e){if(typeof e!="number"||e<0||O7(e))throw new RangeError('The value of "defaultMaxListeners" is out of range. It must be a non-negative number. Received '+e+".");B7=e}});xt.init=function(){(this._events===void 0||this._events===Object.getPrototypeOf(this)._events)&&(this._events=Object.create(null),this._eventsCount=0),this._maxListeners=this._maxListeners||void 0};xt.prototype.setMaxListeners=function(r){if(typeof r!="number"||r<0||O7(r))throw new RangeError('The value of "n" is out of range. It must be a non-negative number. Received '+r+".");return this._maxListeners=r,this};function Y7(e){return e._maxListeners===void 0?xt.defaultMaxListeners:e._maxListeners}xt.prototype.getMaxListeners=function(){return Y7(this)};xt.prototype.emit=function(r){for(var t=[],a=1;a0&&(l=t[0]),l instanceof Error)throw l;var o=new Error("Unhandled error."+(l?" ("+l.message+")":""));throw o.context=l,o}var s=i[r];if(s===void 0)return!1;if(typeof s=="function")H7(s,this,t);else for(var u=s.length,f=Z7(s,u),a=0;a0&&l.length>n&&!l.warned){l.warned=!0;var o=new Error("Possible EventEmitter memory leak detected. "+l.length+" "+String(r)+" listeners added. Use emitter.setMaxListeners() to increase limit");o.name="MaxListenersExceededWarning",o.emitter=e,o.type=r,o.count=l.length,AV(o)}return e}xt.prototype.addListener=function(r,t){return U7(this,r,t,!1)};xt.prototype.on=xt.prototype.addListener;xt.prototype.prependListener=function(r,t){return U7(this,r,t,!0)};function kV(){if(!this.fired)return this.target.removeListener(this.type,this.wrapFn),this.fired=!0,arguments.length===0?this.listener.call(this.target):this.listener.apply(this.target,arguments)}function G7(e,r,t){var a={fired:!1,wrapFn:void 0,target:e,type:r,listener:t},n=kV.bind(a);return n.listener=t,a.wrapFn=n,n}xt.prototype.once=function(r,t){return _0(t),this.on(r,G7(this,r,t)),this};xt.prototype.prependOnceListener=function(r,t){return _0(t),this.prependListener(r,G7(this,r,t)),this};xt.prototype.removeListener=function(r,t){var a,n,i,l,o;if(_0(t),n=this._events,n===void 0)return this;if(a=n[r],a===void 0)return this;if(a===t||a.listener===t)--this._eventsCount===0?this._events=Object.create(null):(delete n[r],n.removeListener&&this.emit("removeListener",r,a.listener||t));else if(typeof a!="function"){for(i=-1,l=a.length-1;l>=0;l--)if(a[l]===t||a[l].listener===t){o=a[l].listener,i=l;break}if(i<0)return this;i===0?a.shift():CV(a,i),a.length===1&&(n[r]=a[0]),n.removeListener!==void 0&&this.emit("removeListener",r,o||t)}return this};xt.prototype.off=xt.prototype.removeListener;xt.prototype.removeAllListeners=function(r){var t,a,n;if(a=this._events,a===void 0)return this;if(a.removeListener===void 0)return arguments.length===0?(this._events=Object.create(null),this._eventsCount=0):a[r]!==void 0&&(--this._eventsCount===0?this._events=Object.create(null):delete a[r]),this;if(arguments.length===0){var i=Object.keys(a),l;for(n=0;n=0;n--)this.removeListener(r,t[n]);return this};function V7(e,r,t){var a=e._events;if(a===void 0)return[];var n=a[r];return n===void 0?[]:typeof n=="function"?t?[n.listener||n]:[n]:t?SV(n):Z7(n,n.length)}xt.prototype.listeners=function(r){return V7(this,r,!0)};xt.prototype.rawListeners=function(r){return V7(this,r,!1)};xt.listenerCount=function(e,r){return typeof e.listenerCount=="function"?e.listenerCount(r):W7.call(e,r)};xt.prototype.listenerCount=W7;function W7(e){var r=this._events;if(r!==void 0){var t=r[e];if(typeof t=="function")return 1;if(t!==void 0)return t.length}return 0}xt.prototype.eventNames=function(){return this._eventsCount>0?x0(this._events):[]};function Z7(e,r){for(var t=new Array(r),a=0;a{"use strict";var _m=w0().EventEmitter,DV={init:function(e){if(e._ev instanceof _m)return e;var r=new _m,t=new _m;return e._ev=r,e._internalEv=t,e.on=r.on.bind(r),e.once=r.once.bind(r),e.removeListener=r.removeListener.bind(r),e.removeAllListeners=r.removeAllListeners.bind(r),e._internalOn=t.on.bind(t),e._internalOnce=t.once.bind(t),e._removeInternalListener=t.removeListener.bind(t),e._removeAllInternalListeners=t.removeAllListeners.bind(t),e.emit=function(a,n){r.emit(a,n),t.emit(a,n)},typeof e.addEventListener=="function"&&e.addEventListener("wheel",()=>{},{passive:!0}),e},triggerHandler:function(e,r,t){var a,n=e._ev;if(!n)return;var i=n._events[r];if(!i)return;function l(s){if(s.listener){if(n.removeListener(r,s.listener),!s.fired)return s.fired=!0,s.listener.apply(n,[t])}else return s.apply(n,[t])}i=Array.isArray(i)?i:[i];var o;for(o=0;o{"use strict";var K7=ze(),EV=Co().dfltConfig;function PV(e,r){for(var t=[],a,n=0;nEV.queueLength&&(e.undoQueue.queue.shift(),e.undoQueue.index--)};ol.startSequence=function(e){e.undoQueue=e.undoQueue||{index:0,queue:[],sequence:!1},e.undoQueue.sequence=!0,e.undoQueue.beginSequence=!0};ol.stopSequence=function(e){e.undoQueue=e.undoQueue||{index:0,queue:[],sequence:!1},e.undoQueue.sequence=!1,e.undoQueue.beginSequence=!1};ol.undo=function(r){var t,a;if(!(r.undoQueue===void 0||isNaN(r.undoQueue.index)||r.undoQueue.index<=0)){for(r.undoQueue.index--,t=r.undoQueue.queue[r.undoQueue.index],r.undoQueue.inSequence=!0,a=0;a=r.undoQueue.queue.length)){for(t=r.undoQueue.queue[r.undoQueue.index],r.undoQueue.inSequence=!0,a=0;a{"use strict";j7.exports={_isLinkedToArray:"frames_entry",group:{valType:"string"},name:{valType:"string"},traces:{valType:"any"},baseframe:{valType:"string"},data:{valType:"any"},layout:{valType:"any"}}});var js=G(ta=>{"use strict";var zn=gr(),Bf=ze(),M0=ui(),Tm=Ys(),RV=wm(),zV=Df(),NV=Co().configAttributes,ew=fi(),di=Bf.extendDeepAll,Qs=Bf.isPlainObject,FV=Bf.isArrayOrTypedArray,A0=Bf.nestedProperty,IV=Bf.valObjectMeta,Mm="_isSubplotObj",k0="_isLinkedToArray",HV="_arrayAttrRegexps",tw="_deprecated",Am=[Mm,k0,HV,tw];ta.IS_SUBPLOT_OBJ=Mm;ta.IS_LINKED_TO_ARRAY=k0;ta.DEPRECATED=tw;ta.UNDERSCORE_ATTRS=Am;ta.get=function(){var e={};return zn.allTypes.forEach(function(r){e[r]=OV(r)}),{defs:{valObjects:IV,metaKeys:Am.concat(["description","role","editType","impliedEdits"]),editType:{traces:ew.traces,layout:ew.layout},impliedEdits:{}},traces:e,layout:YV(),frames:UV(),animation:$s(zV),config:$s(NV)}};ta.crawl=function(e,r,t,a){var n=t||0;a=a||"",Object.keys(e).forEach(function(i){var l=e[i];if(Am.indexOf(i)===-1){var o=(a?a+".":"")+i;r(l,i,e,n,o),!ta.isValObject(l)&&Qs(l)&&i!=="impliedEdits"&&ta.crawl(l,r,n+1,o)}})};ta.isValObject=function(e){return e&&e.valType!==void 0};ta.findArrayAttributes=function(e){var r=[],t=[],a=[],n,i;function l(s,u,f,c){t=t.slice(0,c).concat([u]),a=a.slice(0,c).concat([s&&s._isLinkedToArray]);var h=s&&(s.valType==="data_array"||s.arrayOk===!0)&&!(t[c-1]==="colorbar"&&(u==="ticktext"||u==="tickvals"));h&&o(n,0,"")}function o(s,u,f){var c=s[t[u]],h=f+t[u];if(u===t.length-1)FV(c)&&r.push(i+h);else if(a[u]){if(Array.isArray(c))for(var d=0;d=i.length)return!1;if(e.dimensions===2){if(t++,r.length===t)return e;var l=r[t];if(!T0(l))return!1;e=i[n][l]}else e=i[n]}else e=i}}return e}function T0(e){return e===Math.round(e)&&e>=0}function OV(e){var r,t;r=zn.modules[e]._module,t=r.basePlotModule;var a={};a.type=null;var n=di({},M0),i=di({},r.attributes);ta.crawl(i,function(s,u,f,c,h){A0(n,h).set(void 0),s===void 0&&A0(i,h).set(void 0)}),di(a,n),zn.traceIs(e,"noOpacity")&&delete a.opacity,zn.traceIs(e,"showLegend")||(delete a.showlegend,delete a.legendgroup),zn.traceIs(e,"noHover")&&(delete a.hoverinfo,delete a.hoverlabel),r.selectPoints||delete a.selectedpoints,di(a,i),t.attributes&&di(a,t.attributes),a.type=e;var l={meta:r.meta||{},categories:r.categories||{},animatable:!!r.animatable,type:e,attributes:$s(a)};if(r.layoutAttributes){var o={};di(o,r.layoutAttributes),l.layoutAttributes=$s(o)}return r.animatable||ta.crawl(l,function(s){ta.isValObject(s)&&"anim"in s&&delete s.anim}),l}function YV(){var e={},r,t;di(e,Tm);for(r in zn.subplotsRegistry)if(t=zn.subplotsRegistry[r],!!t.layoutAttributes)if(Array.isArray(t.attr))for(var a=0;a{"use strict";var eu=ze(),XV=ui(),Il="templateitemname",km={name:{valType:"string",editType:"none"}};km[Il]={valType:"string",editType:"calc"};Po.templatedArray=function(e,r){return r._isLinkedToArray=e,r.name=km.name,r[Il]=km[Il],r};Po.traceTemplater=function(e){var r={},t,a;for(t in e)a=e[t],Array.isArray(a)&&a.length&&(r[t]=0);function n(i){t=eu.coerce(i,{},XV,"type");var l={type:t,_template:null};if(t in r){a=e[t];var o=r[t]%a.length;r[t]++,l._template=a[o]}return l}return{newTrace:n}};Po.newContainer=function(e,r,t){var a=e._template,n=a&&(a[r]||t&&a[t]);eu.isPlainObject(n)||(n=null);var i=e[r]={_template:n};return i};Po.arrayTemplater=function(e,r,t){var a=e._template,n=a&&a[iw(r)],i=a&&a[r];(!Array.isArray(i)||!i.length)&&(i=[]);var l={};function o(u){var f={name:u.name,_input:u},c=f[Il]=u[Il];if(!nw(c))return f._template=n,f;for(var h=0;h=a&&(t._input||{})._templateitemname;i&&(n=a);var l=r+"["+n+"]",o;function s(){o={},i&&(o[l]={},o[l][Il]=i)}s();function u(d,p){o[d]=p}function f(d,p){i?eu.nestedProperty(o[l],d).set(p):o[l+"."+d]=p}function c(){var d=o;return s(),d}function h(d,p){d&&f(d,p);var y=c();for(var g in y)eu.nestedProperty(e,g).set(y[g])}return{modifyBase:u,modifyItem:f,getUpdateObj:c,applyUpdate:h}}});var ka=G((h0e,lw)=>{"use strict";var Of=Hs().counter;lw.exports={idRegex:{x:Of("x","( domain)?"),y:Of("y","( domain)?")},attrRegex:Of("[xy]axis"),xAxisMatch:Of("xaxis"),yAxisMatch:Of("yaxis"),AX_ID_PATTERN:/^[xyz][0-9]*( domain)?$/,AX_NAME_PATTERN:/^[xyz]axis[0-9]*$/,SUBPLOT_PATTERN:/^x([0-9]*)y([0-9]*)$/,HOUR_PATTERN:"hour",WEEKDAY_PATTERN:"day of week",MINDRAG:8,MINZOOM:20,DRAGGERSIZE:20,REDRAWDELAY:50,DFLTRANGEX:[-1,6],DFLTRANGEY:[-1,4],traceLayerClasses:["imagelayer","heatmaplayer","contourcarpetlayer","contourlayer","funnellayer","waterfalllayer","barlayer","carpetlayer","violinlayer","boxlayer","ohlclayer","scattercarpetlayer","scatterlayer"],clipOnAxisFalseQuery:[".scatterlayer",".barlayer",".funnellayer",".waterfalllayer"],layerValue2layerClass:{"above traces":"above","below traces":"below"},zindexSeparator:"z"}});var aa=G(un=>{"use strict";var JV=gr(),Cm=ka();un.id2name=function(r){if(!(typeof r!="string"||!r.match(Cm.AX_ID_PATTERN))){var t=r.split(" ")[0].substr(1);return t==="1"&&(t=""),r.charAt(0)+"axis"+t}};un.name2id=function(r){if(r.match(Cm.AX_NAME_PATTERN)){var t=r.substr(5);return t==="1"&&(t=""),r.charAt(0)+t}};un.cleanId=function(r,t,a){var n=/( domain)$/.test(r);if(!(typeof r!="string"||!r.match(Cm.AX_ID_PATTERN))&&!(t&&r.charAt(0)!==t)&&!(n&&!a)){var i=r.split(" ")[0].substr(1).replace(/^0+/,"");return i==="1"&&(i=""),r.charAt(0)+i+(n&&a?" domain":"")}};un.list=function(e,r,t){var a=e._fullLayout;if(!a)return[];var n=un.listIds(e,r),i=new Array(n.length),l;for(l=0;la?1:-1:+(e.substr(1)||1)-+(r.substr(1)||1)};un.ref2id=function(e){return/^[xyz]/.test(e)?e.split(" ")[0]:!1};function ow(e,r){if(r&&r.length){for(var t=0;t{"use strict";function KV(e){var r=e._fullLayout._zoomlayer;r&&r.selectAll(".outline-controllers").remove()}function QV(e){var r=e._fullLayout._zoomlayer;r&&r.selectAll(".select-outline").remove(),e._fullLayout._outlining=!1}sw.exports={clearOutlineControllers:KV,clearOutline:QV}});var C0=G((m0e,uw)=>{"use strict";uw.exports={scattermode:{valType:"enumerated",values:["group","overlay"],dflt:"overlay",editType:"calc"},scattergap:{valType:"number",min:0,max:1,editType:"calc"}}});var L0=G(q0=>{"use strict";var S0=gr(),y0e=ka().SUBPLOT_PATTERN;q0.getSubplotCalcData=function(e,r,t){var a=S0.subplotsRegistry[r];if(!a)return[];for(var n=a.attr,i=[],l=0;l{"use strict";var $V=gr(),ru=ze();Ro.manageCommandObserver=function(e,r,t,a){var n={},i=!0;r&&r._commandObserver&&(n=r._commandObserver),n.cache||(n.cache={}),n.lookupTable={};var l=Ro.hasSimpleAPICommandBindings(e,t,n.lookupTable);if(r&&r._commandObserver){if(l)return n;if(r._commandObserver.remove)return r._commandObserver.remove(),r._commandObserver=null,n}if(l){fw(e,l,n.cache),n.check=function(){if(i){var f=fw(e,l,n.cache);return f.changed&&a&&n.lookupTable[f.value]!==void 0&&(n.disable(),Promise.resolve(a({value:f.value,type:l.type,prop:l.prop,traces:l.traces,index:n.lookupTable[f.value]})).then(n.enable,n.enable)),f.changed}};for(var o=["plotly_relayout","plotly_redraw","plotly_restyle","plotly_update","plotly_animatingframe","plotly_afterplot"],s=0;s0?".":"")+n;ru.isPlainObject(i)?Sm(i,r,l,a+1):r(l,n,i)}})}});var fa=G((x0e,Cw)=>{"use strict";var xw=Rr(),eW=zs().timeFormatLocale,rW=zp().formatLocale,Yf=zr(),tW=Np(),Jr=gr(),_w=js(),aW=bt(),cr=ze(),ww=Lr(),dw=Ct().BADNUM,fn=aa(),nW=Hl().clearOutline,iW=C0(),qm=Df(),lW=wm(),oW=L0().getModuleCalcData,pw=cr.relinkPrivateKeys,zo=cr._,$e=Cw.exports={};cr.extendFlat($e,Jr);$e.attributes=ui();$e.attributes.type.values=$e.allTypes;$e.fontAttrs=ya();$e.layoutAttributes=Ys();var E0=hw();$e.executeAPICommand=E0.executeAPICommand;$e.computeAPICommandBindings=E0.computeAPICommandBindings;$e.manageCommandObserver=E0.manageCommandObserver;$e.hasSimpleAPICommandBindings=E0.hasSimpleAPICommandBindings;$e.redrawText=function(e){return e=cr.getGraphDiv(e),new Promise(function(r){setTimeout(function(){e._fullLayout&&(Jr.getComponentMethod("annotations","draw")(e),Jr.getComponentMethod("legend","draw")(e),Jr.getComponentMethod("colorbar","draw")(e),r($e.previousPromises(e)))},300)})};$e.resize=function(e){e=cr.getGraphDiv(e);var r,t=new Promise(function(a,n){(!e||cr.isHidden(e))&&n(new Error("Resize must be passed a displayed plot div element.")),e._redrawTimer&&clearTimeout(e._redrawTimer),e._resolveResize&&(r=e._resolveResize),e._resolveResize=a,e._redrawTimer=setTimeout(function(){if(!e.layout||e.layout.width&&e.layout.height||cr.isHidden(e)){a(e);return}delete e.layout.width,delete e.layout.height;var i=e.changed;e.autoplay=!0,Jr.call("relayout",e,{autosize:!0}).then(function(){e.changed=i,e._resolveResize===a&&(delete e._resolveResize,a(e))})},100)});return r&&r(t),t};$e.previousPromises=function(e){if((e._promises||[]).length)return Promise.all(e._promises).then(function(){e._promises=[]})};$e.addLinks=function(e){if(!(!e._context.showLink&&!e._context.showSources)){var r=e._fullLayout,t=cr.ensureSingle(r._paper,"text","js-plot-link-container",function(s){s.style({"font-family":'"Open Sans", Arial, sans-serif',"font-size":"12px",fill:ww.defaultLine,"pointer-events":"all"}).each(function(){var u=xw.select(this);u.append("tspan").classed("js-link-to-tool",!0),u.append("tspan").classed("js-link-spacer",!0),u.append("tspan").classed("js-sourcelinks",!0)})}),a=t.node(),n={y:r._paper.attr("height")-9};document.body.contains(a)&&a.getComputedTextLength()>=r.width-20?(n["text-anchor"]="start",n.x=5):(n["text-anchor"]="end",n.x=r._paper.attr("width")-7),t.attr(n);var i=t.select(".js-link-to-tool"),l=t.select(".js-link-spacer"),o=t.select(".js-sourcelinks");e._context.showSources&&e._context.showSources(e),e._context.showLink&&sW(e,i),l.text(i.text()&&o.text()?" - ":"")}};function sW(e,r){r.text("");var t=r.append("a").attr({"xlink:xlink:href":"#",class:"link--impt link--embedview","font-weight":"bold"}).text(e._context.linkText+" \xBB");if(e._context.sendData)t.on("click",function(){$e.sendDataToCloud(e)});else{var a=window.location.pathname.split("/"),n=window.location.search;t.attr({"xlink:xlink:show":"new","xlink:xlink:href":"/"+a[2].split(".")[0]+"/"+a[1]+n})}}$e.sendDataToCloud=function(e){var r=(window.PLOTLYENV||{}).BASE_URL||e._context.plotlyServerURL;if(r){e.emit("plotly_beforeexport");var t=xw.select(e).append("div").attr("id","hiddenform").style("display","none"),a=t.append("form").attr({action:r+"/external",method:"post",target:"_blank"}),n=a.append("input").attr({type:"text",name:"data"});return n.node().value=$e.graphJson(e,!1,"keepdata"),a.node().submit(),t.remove(),e.emit("plotly_afterexport"),!1}};var uW=["days","shortDays","months","shortMonths","periods","dateTime","date","time","decimal","thousands","grouping","currency"],fW=["year","month","dayMonth","dayMonthYear"];$e.supplyDefaults=function(e,r){var t=r&&r.skipUpdateCalc,a=e._fullLayout||{};if(a._skipDefaults){delete a._skipDefaults;return}var n=e._fullLayout={},i=e.layout||{},l=e._fullData||[],o=e._fullData=[],s=e.data||[],u=e.calcdata||[],f=e._context||{},c;e._transitionData||$e.createTransitionData(e),n._dfltTitle={plot:zo(e,"Click to enter Plot title"),subtitle:zo(e,"Click to enter Plot subtitle"),x:zo(e,"Click to enter X axis title"),y:zo(e,"Click to enter Y axis title"),colorbar:zo(e,"Click to enter Colorscale title"),annotation:zo(e,"new text")},n._traceWord=zo(e,"trace");var h=mw(e,uW);if(n._mapboxAccessToken=f.mapboxAccessToken,a._initialAutoSizeIsDone){var d=a.width,p=a.height;$e.supplyLayoutGlobalDefaults(i,n,h),i.width||(n.width=d),i.height||(n.height=p),$e.sanitizeMargins(n)}else{$e.supplyLayoutGlobalDefaults(i,n,h);var y=!i.width||!i.height,g=n.autosize,x=f.autosizable,_=y&&(g||x);_?$e.plotAutoSize(e,i,n):y&&$e.sanitizeMargins(n),!g&&y&&(i.width=n.width,i.height=n.height)}n._d3locale=hW(h,n.separators),n._extraFormat=mw(e,fW),n._initialAutoSizeIsDone=!0,n._dataLength=s.length,n._modules=[],n._visibleModules=[],n._basePlotModules=[];var M=n._subplots=vW(),b=n._splomAxes={x:{},y:{}},w=n._splomSubplots={};n._splomGridDflt={},n._scatterStackOpts={},n._firstScatter={},n._alignmentOpts={},n._colorAxes={},n._requestRangeslider={},n._traceUids=cW(l,s),$e.supplyDataDefaults(s,o,i,n);var k=Object.keys(b.x),A=Object.keys(b.y);if(k.length>1&&A.length>1){for(Jr.getComponentMethod("grid","sizeDefaults")(i,n),c=0;c15&&A.length>15&&n.shapes.length===0&&n.images.length===0,$e.linkSubplots(o,n,l,a),$e.cleanPlot(o,n,l,a);var z=!!(a._has&&a._has("cartesian")),F=!!(n._has&&n._has("cartesian")),H=z,W=F;H&&!W?a._bgLayer.remove():W&&!H&&(n._shouldCreateBgLayer=!0),a._zoomlayer&&!e._dragging&&nW({_fullLayout:a}),dW(o,n),pw(n,a),Jr.getComponentMethod("colorscale","crossTraceDefaults")(o,n),n._preGUI||(n._preGUI={}),n._tracePreGUI||(n._tracePreGUI={});var Z=n._tracePreGUI,Y={},B;for(B in Z)Y[B]="old";for(c=0;c0){var f=1-2*i;l=Math.round(f*l),o=Math.round(f*o)}}var c=$e.layoutAttributes.width.min,h=$e.layoutAttributes.height.min;l1,p=!t.height&&Math.abs(a.height-o)>1;(p||d)&&(d&&(a.width=l),p&&(a.height=o)),r._initialAutoSize||(r._initialAutoSize={width:l,height:o}),$e.sanitizeMargins(a)};$e.supplyLayoutModuleDefaults=function(e,r,t,a){var n=Jr.componentsRegistry,i=r._basePlotModules,l,o,s,u=Jr.subplotsRegistry.cartesian;for(l in n)s=n[l],s.includeBasePlot&&s.includeBasePlot(e,r);i.length||i.push(u),r._has("cartesian")&&(Jr.getComponentMethod("grid","contentDefaults")(e,r),u.finalizeSubplots(e,r));for(var f in r._subplots)r._subplots[f].sort(cr.subplotSort);for(o=0;o1&&(t.l/=g,t.r/=g)}if(h){var x=(t.t+t.b)/h;x>1&&(t.t/=x,t.b/=x)}var _=t.xl!==void 0?t.xl:t.x,M=t.xr!==void 0?t.xr:t.x,b=t.yt!==void 0?t.yt:t.y,w=t.yb!==void 0?t.yb:t.y;d[r]={l:{val:_,size:t.l+y},r:{val:M,size:t.r+y},b:{val:w,size:t.b+y},t:{val:b,size:t.t+y}},p[r]=1}if(!a._replotting)return $e.doAutoMargin(e)}};function mW(e){if("_redrawFromAutoMarginCount"in e._fullLayout)return!1;var r=fn.list(e,"",!0);for(var t in r)if(r[t].autoshift||r[t].shift)return!0;return!1}$e.doAutoMargin=function(e){var r=e._fullLayout,t=r.width,a=r.height;r._size||(r._size={}),Tw(r);var n=r._size,i=r.margin,l={t:0,b:0,l:0,r:0},o=cr.extendFlat({},n),s=i.l,u=i.r,f=i.t,c=i.b,h=r._pushmargin,d=r._pushmarginIds,p=r.minreducedwidth,y=r.minreducedheight;if(i.autoexpand!==!1){for(var g in h)d[g]||delete h[g];var x=e._fullLayout._reservedMargin;for(var _ in x)for(var M in x[_]){var b=x[_][M];l[M]=Math.max(l[M],b)}h.base={l:{val:0,size:s},r:{val:1,size:u},t:{val:1,size:f},b:{val:0,size:c}};for(var w in l){var k=0;for(var A in h)A!=="base"&&Yf(h[A][w].size)&&(k=h[A][w].size>k?h[A][w].size:k);var q=Math.max(0,i[w]-k);l[w]=Math.max(0,l[w]-q)}for(var D in h){var E=h[D].l||{},R=h[D].b||{},z=E.val,F=E.size,H=R.val,W=R.size,Z=t-l.r-l.l,Y=a-l.t-l.b;for(var B in h){if(Yf(F)&&h[B].r){var U=h[B].r.val,K=h[B].r.size;if(U>z){var Q=(F*U+(K-Z)*z)/(U-z),ae=(K*(1-z)+(F-Z)*(1-U))/(U-z);Q+ae>s+u&&(s=Q,u=ae)}}if(Yf(W)&&h[B].t){var fe=h[B].t.val,oe=h[B].t.size;if(fe>H){var ce=(W*fe+(oe-Y)*H)/(fe-H),$=(oe*(1-H)+(W-Y)*(1-fe))/(fe-H);ce+$>c+f&&(c=ce,f=$)}}}}}var Te=cr.constrain(t-i.l-i.r,Mw,p),ue=cr.constrain(a-i.t-i.b,Aw,y),me=Math.max(0,t-Te),ie=Math.max(0,a-ue);if(me){var de=(s+u)/me;de>1&&(s/=de,u/=de)}if(ie){var O=(c+f)/ie;O>1&&(c/=O,f/=O)}if(n.l=Math.round(s)+l.l,n.r=Math.round(u)+l.r,n.t=Math.round(f)+l.t,n.b=Math.round(c)+l.b,n.p=Math.round(i.pad),n.w=Math.round(t)-n.l-n.r,n.h=Math.round(a)-n.t-n.b,!r._replotting&&($e.didMarginChange(o,n)||mW(e))){"_redrawFromAutoMarginCount"in r?r._redrawFromAutoMarginCount++:r._redrawFromAutoMarginCount=1;var j=3*(1+Object.keys(d).length);if(r._redrawFromAutoMarginCount1)return!0}return!1};$e.graphJson=function(e,r,t,a,n,i){(n&&r&&!e._fullData||n&&!r&&!e._fullLayout)&&$e.supplyDefaults(e);var l=n?e._fullData:e.data,o=n?e._fullLayout:e.layout,s=(e._transitionData||{})._frames;function u(h,d){if(typeof h=="function")return d?"_function_":null;if(cr.isPlainObject(h)){var p={},y;return Object.keys(h).sort().forEach(function(M){if(["_","["].indexOf(M.charAt(0))===-1){if(typeof h[M]=="function"){d&&(p[M]="_function");return}if(t==="keepdata"){if(M.substr(M.length-3)==="src")return}else if(t==="keepstream"){if(y=h[M+"src"],typeof y=="string"&&y.indexOf(":")>0&&!cr.isPlainObject(h.stream))return}else if(t!=="keepall"&&(y=h[M+"src"],typeof y=="string"&&y.indexOf(":")>0))return;p[M]=u(h[M],d)}}),p}var g=Array.isArray(h),x=cr.isTypedArray(h);if((g||x)&&h.dtype&&h.shape){var _=h.bdata;return u({dtype:h.dtype,shape:h.shape,bdata:cr.isArrayBuffer(_)?tW.encode(_):_},d)}return g?h.map(function(M){return u(M,d)}):x?cr.simpleMap(h,cr.identity):cr.isJSDate(h)?cr.ms2DateTimeLocal(+h):h}var f={data:(l||[]).map(function(h){var d=u(h);return r&&delete d.fit,d})};if(!r&&(f.layout=u(o),n)){var c=o._size;f.layout.computed={margin:{b:c.b,l:c.l,r:c.r,t:c.t}}}return s&&(f.frames=u(s)),i&&(f.config=u(e._context,!0)),a==="object"?f:JSON.stringify(f)};$e.modifyFrames=function(e,r){var t,a,n,i=e._transitionData._frames,l=e._transitionData._frameHash;for(t=0;t0&&(e._transitioningWithDuration=!0),e._transitionData._interruptCallbacks.push(function(){a=!0}),t.redraw&&e._transitionData._interruptCallbacks.push(function(){return Jr.call("redraw",e)}),e._transitionData._interruptCallbacks.push(function(){e.emit("plotly_transitioninterrupted",[])});var h=0,d=0;function p(){return h++,function(){d++,!a&&d===h&&o(c)}}t.runFn(p),setTimeout(p())})}function o(c){if(e._transitionData)return i(e._transitionData._interruptCallbacks),Promise.resolve().then(function(){if(t.redraw)return Jr.call("redraw",e)}).then(function(){e._transitioning=!1,e._transitioningWithDuration=!1,e.emit("plotly_transitioned",[])}).then(c)}function s(){if(e._transitionData)return e._transitioning=!1,n(e._transitionData._interruptCallbacks)}var u=[$e.previousPromises,s,t.prepareFn,$e.rehover,$e.reselect,l],f=cr.syncOrAsync(u,e);return(!f||!f.then)&&(f=Promise.resolve()),f.then(function(){return e})}$e.doCalcdata=function(e,r){var t=fn.list(e),a=e._fullData,n=e._fullLayout,i,l,o,s,u=new Array(a.length),f=(e.calcdata||[]).slice();for(e.calcdata=u,n._numBoxes=0,n._numViolins=0,n._violinScaleGroupStats={},e._hmpixcount=0,e._hmlumcount=0,n._piecolormap={},n._sunburstcolormap={},n._treemapcolormap={},n._iciclecolormap={},n._funnelareacolormap={},o=0;o=0;s--)if(w[s].enabled){i._indexToPoints=w[s]._indexToPoints;break}l&&l.calc&&(b=l.calc(e,i))}(!Array.isArray(b)||!b[0])&&(b=[{x:dw,y:dw}]),b[0].t||(b[0].t={}),b[0].trace=i,u[_]=b}}for(gw(t,a,n),o=0;o{"use strict";No.xmlns="http://www.w3.org/2000/xmlns/";No.svg="http://www.w3.org/2000/svg";No.xlink="http://www.w3.org/1999/xlink";No.svgAttrs={xmlns:No.svg,"xmlns:xlink":No.xlink}});var Ea=G((w0e,Sw)=>{"use strict";Sw.exports={FROM_BL:{left:0,center:.5,right:1,bottom:0,middle:.5,top:1},FROM_TL:{left:0,center:.5,right:1,bottom:1,middle:.5,top:0},FROM_BR:{left:1,center:.5,right:0,bottom:0,middle:.5,top:1},LINE_SPACING:1.3,CAP_SHIFT:.7,MID_SHIFT:.35,OPPOSITE_SIDE:{left:"right",right:"left",top:"bottom",bottom:"top"}}});var Ca=G(Nn=>{"use strict";var Kt=Rr(),sl=ze(),xW=sl.strTranslate,Lm=Bl(),_W=Ea().LINE_SPACING,wW=/([^$]*)([$]+[^$]*[$]+)([^$]*)/;Nn.convertToTspans=function(e,r,t){var a=e.text(),n=!e.attr("data-notex")&&r&&r._context.typesetMath&&typeof MathJax!="undefined"&&a.match(wW),i=Kt.select(e.node().parentNode);if(i.empty())return;var l=e.attr("class")?e.attr("class").split(" ")[0]:"text";l+="-math",i.selectAll("svg."+l).remove(),i.selectAll("g."+l+"-group").remove(),e.style("display",null).attr({"data-unformatted":a,"data-math":"N"});function o(){i.empty()||(l=e.attr("class")+"-math",i.select("svg."+l).remove()),e.text("").style("white-space","pre");var s=zW(e.node(),a);s&&e.style("pointer-events","all"),Nn.positionText(e),t&&t.call(e)}return n?(r&&r._promises||[]).push(new Promise(function(s){e.style("display","none");var u=parseInt(e.node().style.fontSize,10),f={fontSize:u};kW(n[2],f,function(c,h,d){i.selectAll("svg."+l).remove(),i.selectAll("g."+l+"-group").remove();var p=c&&c.select("svg");if(!p||!p.node()){o(),s();return}var y=i.append("g").classed(l+"-group",!0).attr({"pointer-events":"none","data-unformatted":a,"data-math":"Y"});y.node().appendChild(p.node()),h&&h.node()&&p.node().insertBefore(h.node().cloneNode(!0),p.node().firstChild);var g=d.width,x=d.height;p.attr({class:l,height:x,preserveAspectRatio:"xMinYMin meet"}).style({overflow:"visible","pointer-events":"none"});var _=e.node().style.fill||"black",M=p.select("g");M.attr({fill:_,stroke:_});var b=M.node().getBoundingClientRect(),w=b.width,k=b.height;(w>g||k>x)&&(p.style("overflow","hidden"),b=p.node().getBoundingClientRect(),w=b.width,k=b.height);var A=+e.attr("x"),q=+e.attr("y"),D=u||e.node().getBoundingClientRect().height,E=-D/4;if(l[0]==="y")y.attr({transform:"rotate("+[-90,A,q]+")"+xW(-w/2,E-k/2)});else if(l[0]==="l")q=E-k/2;else if(l[0]==="a"&&l.indexOf("atitle")!==0)A=0,q=E;else{var R=e.attr("text-anchor");A=A-w*(R==="middle"?.5:R==="end"?1:0),q=q+E-k/2}p.attr({x:A,y:q}),t&&t.call(e,y),s(y)})})):o(),e};var TW=/(<|<|<)/g,MW=/(>|>|>)/g;function AW(e){return e.replace(TW,"\\lt ").replace(MW,"\\gt ")}var qw=[["$","$"],["\\(","\\)"]];function kW(e,r,t){var a=parseInt((MathJax.version||"").split(".")[0]);if(a!==2&&a!==3){sl.warn("No MathJax version:",MathJax.version);return}var n,i,l,o,s=function(){return i=sl.extendDeepAll({},MathJax.Hub.config),l=MathJax.Hub.processSectionDelay,MathJax.Hub.processSectionDelay!==void 0&&(MathJax.Hub.processSectionDelay=0),MathJax.Hub.Config({messageStyle:"none",tex2jax:{inlineMath:qw},displayAlign:"left"})},u=function(){i=sl.extendDeepAll({},MathJax.config),MathJax.config.tex||(MathJax.config.tex={}),MathJax.config.tex.inlineMath=qw},f=function(){if(n=MathJax.Hub.config.menuSettings.renderer,n!=="SVG")return MathJax.Hub.setRenderer("SVG")},c=function(){n=MathJax.config.startup.output,n!=="svg"&&(MathJax.config.startup.output="svg")},h=function(){var _="math-output-"+sl.randstr({},64);o=Kt.select("body").append("div").attr({id:_}).style({visibility:"hidden",position:"absolute","font-size":r.fontSize+"px"}).text(AW(e));var M=o.node();return a===2?MathJax.Hub.Typeset(M):MathJax.typeset([M])},d=function(){var _=o.select(a===2?".MathJax_SVG":".MathJax"),M=!_.empty()&&o.select("svg").node();if(!M)sl.log("There was an error in the tex syntax.",e),t();else{var b=M.getBoundingClientRect(),w;a===2?w=Kt.select("body").select("#MathJax_SVG_glyphs"):w=_.select("defs"),t(_,w,b)}o.remove()},p=function(){if(n!=="SVG")return MathJax.Hub.setRenderer(n)},y=function(){n!=="svg"&&(MathJax.config.startup.output=n)},g=function(){return l!==void 0&&(MathJax.Hub.processSectionDelay=l),MathJax.Hub.Config(i)},x=function(){MathJax.config=i};a===2?MathJax.Hub.Queue(s,f,h,d,p,g):a===3&&(u(),c(),MathJax.startup.defaultReady(),MathJax.startup.promise.then(function(){h(),d(),y(),x()}))}var Pw={sup:"font-size:70%",sub:"font-size:70%",s:"text-decoration:line-through",u:"text-decoration:underline",b:"font-weight:bold",i:"font-style:italic",a:"cursor:pointer",span:"",em:"font-style:italic;font-weight:bold"},CW={sub:"0.3em",sup:"-0.6em"},SW={sub:"-0.21em",sup:"0.42em"},Lw="\u200B",Dw=["http:","https:","mailto:","",void 0,":"],Rw=Nn.NEWLINES=/(\r\n?|\n)/g,Em=/(<[^<>]*>)/,Pm=/<(\/?)([^ >]*)(\s+(.*))?>/i,qW=/ /i;Nn.BR_TAG_ALL=/ /gi;var zw=/(^|[\s"'])style\s*=\s*("([^"]*);?"|'([^']*);?')/i,Nw=/(^|[\s"'])href\s*=\s*("([^"]*)"|'([^']*)')/i,Fw=/(^|[\s"'])target\s*=\s*("([^"\s]*)"|'([^'\s]*)')/i,LW=/(^|[\s"'])popup\s*=\s*("([\w=,]*)"|'([\w=,]*)')/i;function Fo(e,r){if(!e)return null;var t=e.match(r),a=t&&(t[3]||t[4]);return a&&P0(a)}var DW=/(^|;)\s*color:/;Nn.plainText=function(e,r){r=r||{};for(var t=r.len!==void 0&&r.len!==-1?r.len:1/0,a=r.allowedTags!==void 0?r.allowedTags:["br"],n="...",i=n.length,l=e.split(Em),o=[],s="",u=0,f=0;fi?o.push(c.substr(0,y-i)+n):o.push(c.substr(0,y));break}s=""}}return o.join("")};var EW={mu:"\u03BC",amp:"&",lt:"<",gt:">",nbsp:"\xA0",times:"\xD7",plusmn:"\xB1",deg:"\xB0"},PW=/&(#\d+|#x[\da-fA-F]+|[a-z]+);/g;function P0(e){return e.replace(PW,function(r,t){var a;return t.charAt(0)==="#"?a=RW(t.charAt(1)==="x"?parseInt(t.substr(2),16):parseInt(t.substr(1),10)):a=EW[t],a||r})}Nn.convertEntities=P0;function RW(e){if(!(e>1114111)){var r=String.fromCodePoint;if(r)return r(e);var t=String.fromCharCode;return e<=65535?t(e):t((e>>10)+55232,e%1024+56320)}}function zW(e,r){r=r.replace(Rw," ");var t=!1,a=[],n,i=-1;function l(){i++;var k=document.createElementNS(Lm.svg,"tspan");Kt.select(k).attr({class:"line",dy:i*_W+"em"}),e.appendChild(k),n=k;var A=a;if(a=[{node:k}],A.length>1)for(var q=1;q.",r);return}var A=a.pop();k!==A.type&&sl.log("Start tag <"+A.type+"> doesnt match end tag <"+k+">. Pretending it did match.",r),n=a[a.length-1].node}var f=qW.test(r);f?l():(n=e,a=[{node:e}]);for(var c=r.split(Em),h=0;h{"use strict";var NW=Rr(),z0=Pn(),Gf=zr(),R0=ze(),Hw=Lr(),FW=Ao().isValid;function IW(e,r,t){var a=r?R0.nestedProperty(e,r).get()||{}:e,n=a[t||"color"];n&&n._inputArray&&(n=n._inputArray);var i=!1;if(R0.isArrayOrTypedArray(n)){for(var l=0;l=0;a--,n++){var i=e[a];t[n]=[1-i[0],i[1]]}return t}function Vw(e,r){r=r||{};for(var t=e.domain,a=e.range,n=a.length,i=new Array(n),l=0;l{"use strict";var Zw=jp(),BW=Zw.FORMAT_LINK,OW=Zw.DATE_FORMAT_LINK;function YW(e,r){return{valType:"string",dflt:"",editType:"none",description:(r?Rm:Xw)("hover text",e)+["By default the values are formatted using "+(r?"generic number format":"`"+e+"axis.hoverformat`")+"."].join(" ")}}function Rm(e,r){return["Sets the "+e+" formatting rule"+(r?"for `"+r+"` ":""),"using d3 formatting mini-languages","which are very similar to those in Python. For numbers, see: "+BW+"."].join(" ")}function Xw(e,r){return Rm(e,r)+[" And for dates see: "+OW+".","We add two items to d3's date formatter:","*%h* for half of the year as a decimal number as well as","*%{n}f* for fractional seconds","with n digits. For example, *2016-10-13 09:15:23.456* with tickformat","*%H~%M~%S.%2f* would display *09~15~23.46*"].join(" ")}Jw.exports={axisHoverFormat:YW,descriptionOnlyNumbers:Rm,descriptionWithDates:Xw}});var pi=G((C0e,v9)=>{"use strict";var Kw=ya(),tu=Ri(),c9=rl().dash,Nm=Lt().extendFlat,Qw=bt().templatedArray,k0e=ci().templateFormatStringDescription,$w=Ol().descriptionWithDates,UW=Ct().ONEDAY,Fi=ka(),GW=Fi.HOUR_PATTERN,VW=Fi.WEEKDAY_PATTERN,zm={valType:"enumerated",values:["auto","linear","array"],editType:"ticks",impliedEdits:{tick0:void 0,dtick:void 0}},WW=Nm({},zm,{values:zm.values.slice().concat(["sync"])});function jw(e){return{valType:"integer",min:0,dflt:e?5:0,editType:"ticks"}}var e9={valType:"any",editType:"ticks",impliedEdits:{tickmode:"linear"}},r9={valType:"any",editType:"ticks",impliedEdits:{tickmode:"linear"}},t9={valType:"data_array",editType:"ticks"},a9={valType:"enumerated",values:["outside","inside",""],editType:"ticks"};function n9(e){var r={valType:"number",min:0,editType:"ticks"};return e||(r.dflt=5),r}function i9(e){var r={valType:"number",min:0,editType:"ticks"};return e||(r.dflt=1),r}var l9={valType:"color",dflt:tu.defaultLine,editType:"ticks"},o9={valType:"color",dflt:tu.lightLine,editType:"ticks"};function s9(e){var r={valType:"number",min:0,editType:"ticks"};return e||(r.dflt=1),r}var u9=Nm({},c9,{editType:"ticks"}),f9={valType:"boolean",editType:"ticks"};v9.exports={visible:{valType:"boolean",editType:"plot"},color:{valType:"color",dflt:tu.defaultLine,editType:"ticks"},title:{text:{valType:"string",editType:"ticks"},font:Kw({editType:"ticks"}),standoff:{valType:"number",min:0,editType:"ticks"},editType:"ticks"},type:{valType:"enumerated",values:["-","linear","log","date","category","multicategory"],dflt:"-",editType:"calc",_noTemplating:!0},autotypenumbers:{valType:"enumerated",values:["convert types","strict"],dflt:"convert types",editType:"calc"},autorange:{valType:"enumerated",values:[!0,!1,"reversed","min reversed","max reversed","min","max"],dflt:!0,editType:"axrange",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},autorangeoptions:{minallowed:{valType:"any",editType:"plot",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},maxallowed:{valType:"any",editType:"plot",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},clipmin:{valType:"any",editType:"plot",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},clipmax:{valType:"any",editType:"plot",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},include:{valType:"any",arrayOk:!0,editType:"plot",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},editType:"plot"},rangemode:{valType:"enumerated",values:["normal","tozero","nonnegative"],dflt:"normal",editType:"plot"},range:{valType:"info_array",items:[{valType:"any",editType:"axrange",impliedEdits:{"^autorange":!1},anim:!0},{valType:"any",editType:"axrange",impliedEdits:{"^autorange":!1},anim:!0}],editType:"axrange",impliedEdits:{autorange:!1},anim:!0},minallowed:{valType:"any",editType:"plot",impliedEdits:{"^autorange":!1}},maxallowed:{valType:"any",editType:"plot",impliedEdits:{"^autorange":!1}},fixedrange:{valType:"boolean",dflt:!1,editType:"calc"},modebardisable:{valType:"flaglist",flags:["autoscale","zoominout"],extras:["none"],dflt:"none",editType:"modebar"},insiderange:{valType:"info_array",items:[{valType:"any",editType:"plot"},{valType:"any",editType:"plot"}],editType:"plot"},scaleanchor:{valType:"enumerated",values:[Fi.idRegex.x.toString(),Fi.idRegex.y.toString(),!1],editType:"plot"},scaleratio:{valType:"number",min:0,dflt:1,editType:"plot"},constrain:{valType:"enumerated",values:["range","domain"],editType:"plot"},constraintoward:{valType:"enumerated",values:["left","center","right","top","middle","bottom"],editType:"plot"},matches:{valType:"enumerated",values:[Fi.idRegex.x.toString(),Fi.idRegex.y.toString()],editType:"calc"},rangebreaks:Qw("rangebreak",{enabled:{valType:"boolean",dflt:!0,editType:"calc"},bounds:{valType:"info_array",items:[{valType:"any",editType:"calc"},{valType:"any",editType:"calc"}],editType:"calc"},pattern:{valType:"enumerated",values:[VW,GW,""],editType:"calc"},values:{valType:"info_array",freeLength:!0,editType:"calc",items:{valType:"any",editType:"calc"}},dvalue:{valType:"number",editType:"calc",min:0,dflt:UW},editType:"calc"}),tickmode:WW,nticks:jw(),tick0:e9,dtick:r9,ticklabelstep:{valType:"integer",min:1,dflt:1,editType:"ticks"},tickvals:t9,ticktext:{valType:"data_array",editType:"ticks"},ticks:a9,tickson:{valType:"enumerated",values:["labels","boundaries"],dflt:"labels",editType:"ticks"},ticklabelmode:{valType:"enumerated",values:["instant","period"],dflt:"instant",editType:"ticks"},ticklabelposition:{valType:"enumerated",values:["outside","inside","outside top","inside top","outside left","inside left","outside right","inside right","outside bottom","inside bottom"],dflt:"outside",editType:"calc"},ticklabeloverflow:{valType:"enumerated",values:["allow","hide past div","hide past domain"],editType:"calc"},ticklabelshift:{valType:"integer",dflt:0,editType:"ticks"},ticklabelstandoff:{valType:"integer",dflt:0,editType:"ticks"},ticklabelindex:{valType:"integer",arrayOk:!0,editType:"calc"},mirror:{valType:"enumerated",values:[!0,"ticks",!1,"all","allticks"],dflt:!1,editType:"ticks+layoutstyle"},ticklen:n9(),tickwidth:i9(),tickcolor:l9,showticklabels:{valType:"boolean",dflt:!0,editType:"ticks"},labelalias:{valType:"any",dflt:!1,editType:"ticks"},automargin:{valType:"flaglist",flags:["height","width","left","right","top","bottom"],extras:[!0,!1],dflt:!1,editType:"ticks"},showspikes:{valType:"boolean",dflt:!1,editType:"modebar"},spikecolor:{valType:"color",dflt:null,editType:"none"},spikethickness:{valType:"number",dflt:3,editType:"none"},spikedash:Nm({},c9,{dflt:"dash",editType:"none"}),spikemode:{valType:"flaglist",flags:["toaxis","across","marker"],dflt:"toaxis",editType:"none"},spikesnap:{valType:"enumerated",values:["data","cursor","hovered data"],dflt:"hovered data",editType:"none"},tickfont:Kw({editType:"ticks"}),tickangle:{valType:"angle",dflt:"auto",editType:"ticks"},autotickangles:{valType:"info_array",freeLength:!0,items:{valType:"angle"},dflt:[0,30,90],editType:"ticks"},tickprefix:{valType:"string",dflt:"",editType:"ticks"},showtickprefix:{valType:"enumerated",values:["all","first","last","none"],dflt:"all",editType:"ticks"},ticksuffix:{valType:"string",dflt:"",editType:"ticks"},showticksuffix:{valType:"enumerated",values:["all","first","last","none"],dflt:"all",editType:"ticks"},showexponent:{valType:"enumerated",values:["all","first","last","none"],dflt:"all",editType:"ticks"},exponentformat:{valType:"enumerated",values:["none","e","E","power","SI","B","SI extended"],dflt:"B",editType:"ticks"},minexponent:{valType:"number",dflt:3,min:0,editType:"ticks"},separatethousands:{valType:"boolean",dflt:!1,editType:"ticks"},tickformat:{valType:"string",dflt:"",editType:"ticks",description:$w("tick label")},tickformatstops:Qw("tickformatstop",{enabled:{valType:"boolean",dflt:!0,editType:"ticks"},dtickrange:{valType:"info_array",items:[{valType:"any",editType:"ticks"},{valType:"any",editType:"ticks"}],editType:"ticks"},value:{valType:"string",dflt:"",editType:"ticks"},editType:"ticks"}),hoverformat:{valType:"string",dflt:"",editType:"none",description:$w("hover text")},unifiedhovertitle:{text:{valType:"string",dflt:"",editType:"none"},editType:"none"},showline:{valType:"boolean",dflt:!1,editType:"ticks+layoutstyle"},linecolor:{valType:"color",dflt:tu.defaultLine,editType:"layoutstyle"},linewidth:{valType:"number",min:0,dflt:1,editType:"ticks+layoutstyle"},showgrid:f9,gridcolor:o9,gridwidth:s9(),griddash:u9,zeroline:{valType:"boolean",editType:"ticks"},zerolinecolor:{valType:"color",dflt:tu.defaultLine,editType:"ticks"},zerolinelayer:{valType:"enumerated",values:["above traces","below traces"],dflt:"below traces",editType:"plot"},zerolinewidth:{valType:"number",dflt:1,editType:"ticks"},showdividers:{valType:"boolean",dflt:!0,editType:"ticks"},dividercolor:{valType:"color",dflt:tu.defaultLine,editType:"ticks"},dividerwidth:{valType:"number",dflt:1,editType:"ticks"},anchor:{valType:"enumerated",values:["free",Fi.idRegex.x.toString(),Fi.idRegex.y.toString()],editType:"plot"},side:{valType:"enumerated",values:["top","bottom","left","right"],editType:"plot"},overlaying:{valType:"enumerated",values:["free",Fi.idRegex.x.toString(),Fi.idRegex.y.toString()],editType:"plot"},minor:{tickmode:zm,nticks:jw("minor"),tick0:e9,dtick:r9,tickvals:t9,ticks:a9,ticklen:n9("minor"),tickwidth:i9("minor"),tickcolor:l9,gridcolor:o9,gridwidth:s9("minor"),griddash:u9,showgrid:f9,editType:"ticks"},minorloglabels:{valType:"enumerated",values:["small digits","complete","none"],dflt:"small digits",editType:"calc"},layer:{valType:"enumerated",values:["above traces","below traces"],dflt:"above traces",editType:"plot"},domain:{valType:"info_array",items:[{valType:"number",min:0,max:1,editType:"plot"},{valType:"number",min:0,max:1,editType:"plot"}],dflt:[0,1],editType:"plot"},position:{valType:"number",min:0,max:1,dflt:0,editType:"plot"},autoshift:{valType:"boolean",dflt:!1,editType:"plot"},shift:{valType:"number",editType:"plot"},categoryorder:{valType:"enumerated",values:["trace","category ascending","category descending","array","total ascending","total descending","min ascending","min descending","max ascending","max descending","sum ascending","sum descending","mean ascending","mean descending","geometric mean ascending","geometric mean descending","median ascending","median descending"],dflt:"trace",editType:"calc"},categoryarray:{valType:"data_array",editType:"calc"},uirevision:{valType:"any",editType:"none"},editType:"calc"}});var N0=G((S0e,p9)=>{"use strict";var _t=pi(),h9=ya(),d9=Lt().extendFlat,ZW=fi().overrideAll;p9.exports=ZW({orientation:{valType:"enumerated",values:["h","v"],dflt:"v"},thicknessmode:{valType:"enumerated",values:["fraction","pixels"],dflt:"pixels"},thickness:{valType:"number",min:0,dflt:30},lenmode:{valType:"enumerated",values:["fraction","pixels"],dflt:"fraction"},len:{valType:"number",min:0,dflt:1},x:{valType:"number"},xref:{valType:"enumerated",dflt:"paper",values:["container","paper"],editType:"layoutstyle"},xanchor:{valType:"enumerated",values:["left","center","right"]},xpad:{valType:"number",min:0,dflt:10},y:{valType:"number"},yref:{valType:"enumerated",dflt:"paper",values:["container","paper"],editType:"layoutstyle"},yanchor:{valType:"enumerated",values:["top","middle","bottom"]},ypad:{valType:"number",min:0,dflt:10},outlinecolor:_t.linecolor,outlinewidth:_t.linewidth,bordercolor:_t.linecolor,borderwidth:{valType:"number",min:0,dflt:0},bgcolor:{valType:"color",dflt:"rgba(0,0,0,0)"},tickmode:_t.minor.tickmode,nticks:_t.nticks,tick0:_t.tick0,dtick:_t.dtick,tickvals:_t.tickvals,ticktext:_t.ticktext,ticks:d9({},_t.ticks,{dflt:""}),ticklabeloverflow:d9({},_t.ticklabeloverflow,{}),ticklabelposition:{valType:"enumerated",values:["outside","inside","outside top","inside top","outside left","inside left","outside right","inside right","outside bottom","inside bottom"],dflt:"outside"},ticklen:_t.ticklen,tickwidth:_t.tickwidth,tickcolor:_t.tickcolor,ticklabelstep:_t.ticklabelstep,showticklabels:_t.showticklabels,labelalias:_t.labelalias,tickfont:h9({}),tickangle:_t.tickangle,tickformat:_t.tickformat,tickformatstops:_t.tickformatstops,tickprefix:_t.tickprefix,showtickprefix:_t.showtickprefix,ticksuffix:_t.ticksuffix,showticksuffix:_t.showticksuffix,separatethousands:_t.separatethousands,exponentformat:_t.exponentformat,minexponent:_t.minexponent,showexponent:_t.showexponent,title:{text:{valType:"string"},font:h9({}),side:{valType:"enumerated",values:["right","top","bottom"]}}},"colorbars","from-root")});var au=G((L0e,y9)=>{"use strict";var XW=N0(),JW=Hs().counter,KW=lm(),m9=Ao().scales,q0e=KW(m9);function F0(e){return"`"+e+"`"}y9.exports=function(r,t){r=r||"",t=t||{};var a=t.cLetter||"c",n="onlyIfNumerical"in t?t.onlyIfNumerical:!!r,i="noScale"in t?t.noScale:r==="marker.line",l="showScaleDflt"in t?t.showScaleDflt:a==="z",o=typeof t.colorscaleDflt=="string"?m9[t.colorscaleDflt]:null,s=t.editTypeOverride||"",u=r?r+".":"",f,c;"colorAttr"in t?(f=t.colorAttr,c=t.colorAttr):(f={z:"z",c:"color"}[a],c="in "+F0(u+f));var h=n?" Has an effect only if "+c+" is set to a numerical array.":"",d=a+"auto",p=a+"min",y=a+"max",g=a+"mid",x=F0(u+d),_=F0(u+p),M=F0(u+y),b=_+" and "+M,w={};w[p]=w[y]=void 0;var k={};k[d]=!1;var A={};return f==="color"&&(A.color={valType:"color",arrayOk:!0,editType:s||"style"},t.anim&&(A.color.anim=!0)),A[d]={valType:"boolean",dflt:!0,editType:"calc",impliedEdits:w},A[p]={valType:"number",dflt:null,editType:s||"plot",impliedEdits:k},A[y]={valType:"number",dflt:null,editType:s||"plot",impliedEdits:k},A[g]={valType:"number",dflt:null,editType:"calc",impliedEdits:w},A.colorscale={valType:"colorscale",editType:"calc",dflt:o,impliedEdits:{autocolorscale:!1}},A.autocolorscale={valType:"boolean",dflt:t.autoColorDflt!==!1,editType:"calc",impliedEdits:{colorscale:void 0}},A.reversescale={valType:"boolean",dflt:!1,editType:"plot"},i||(A.showscale={valType:"boolean",dflt:l,editType:"calc"},A.colorbar=XW),t.noColorAxis||(A.coloraxis={valType:"subplotid",regex:JW("coloraxis"),dflt:null,editType:"calc"}),A}});var Im=G((D0e,g9)=>{"use strict";var QW=Lt().extendFlat,$W=au(),Fm=Ao().scales;g9.exports={editType:"calc",colorscale:{editType:"calc",sequential:{valType:"colorscale",dflt:Fm.Reds,editType:"calc"},sequentialminus:{valType:"colorscale",dflt:Fm.Blues,editType:"calc"},diverging:{valType:"colorscale",dflt:Fm.RdBu,editType:"calc"}},coloraxis:QW({_isSubplotObj:!0,editType:"calc"},$W("",{colorAttr:"corresponding trace color array(s)",noColorAxis:!0,showScaleDflt:!0}))}});var Hm=G((E0e,b9)=>{"use strict";var jW=ze();b9.exports=function(r){return jW.isPlainObject(r.colorbar)}});var Ym=G(Om=>{"use strict";var Bm=zr(),x9=ze(),_9=Ct(),eZ=_9.ONEDAY,rZ=_9.ONEWEEK;Om.dtick=function(e,r){var t=r==="log",a=r==="date",n=r==="category",i=a?eZ:1;if(!e)return i;if(Bm(e))return e=Number(e),e<=0?i:n?Math.max(1,Math.round(e)):a?Math.max(.1,e):e;if(typeof e!="string"||!(a||t))return i;var l=e.charAt(0),o=e.substr(1);return o=Bm(o)?Number(o):0,o<=0||!(a&&l==="M"&&o===Math.round(o)||t&&l==="L"||t&&l==="D"&&(o===1||o===2))?i:e};Om.tick0=function(e,r,t,a){if(r==="date")return x9.cleanDate(e,x9.dateTick0(t,a%rZ===0?1:0));if(!(a==="D1"||a==="D2"))return Bm(e)?Number(e):0}});var Um=G((R0e,T9)=>{"use strict";var w9=Ym(),tZ=ze().isArrayOrTypedArray,aZ=$a().isTypedArraySpec,nZ=$a().decodeTypedArraySpec;T9.exports=function(r,t,a,n,i){i||(i={});var l=i.isMinor,o=l?r.minor||{}:r,s=l?t.minor:t,u=l?"minor.":"";function f(_){var M=o[_];return aZ(M)&&(M=nZ(M)),M!==void 0?M:(s._template||{})[_]}var c=f("tick0"),h=f("dtick"),d=f("tickvals"),p=tZ(d)?"array":h?"linear":"auto",y=a(u+"tickmode",p);if(y==="auto"||y==="sync")a(u+"nticks");else if(y==="linear"){var g=s.dtick=w9.dtick(h,n);s.tick0=w9.tick0(c,n,t.calendar,g)}else if(n!=="multicategory"){var x=a(u+"tickvals");x===void 0?s.tickmode="auto":l||a("ticktext")}}});var Vm=G((z0e,A9)=>{"use strict";var Gm=ze(),M9=pi();A9.exports=function(r,t,a,n){var i=n.isMinor,l=i?r.minor||{}:r,o=i?t.minor:t,s=i?M9.minor:M9,u=i?"minor.":"",f=Gm.coerce2(l,o,s,"ticklen",i?(t.ticklen||5)*.6:void 0),c=Gm.coerce2(l,o,s,"tickwidth",i?t.tickwidth||1:void 0),h=Gm.coerce2(l,o,s,"tickcolor",(i?t.tickcolor:void 0)||o.color),d=a(u+"ticks",!i&&n.outerTicks||f||c||h?"outside":"");d||(delete o.ticklen,delete o.tickwidth,delete o.tickcolor)}});var Wm=G((N0e,k9)=>{"use strict";k9.exports=function(r){var t=["showexponent","showtickprefix","showticksuffix"],a=t.filter(function(i){return r[i]!==void 0}),n=function(i){return r[i]===r[a[0]]};if(a.every(n)||a.length===1)return r[a[0]]}});var mi=G((F0e,C9)=>{"use strict";var I0=ze(),iZ=bt();C9.exports=function(r,t,a){var n=a.name,i=a.inclusionAttr||"visible",l=t[n],o=I0.isArrayOrTypedArray(r[n])?r[n]:[],s=t[n]=[],u=iZ.arrayTemplater(t,n,i),f,c;for(f=0;f{"use strict";var Zm=ze(),lZ=Lr().contrast,S9=pi(),oZ=Wm(),sZ=mi();q9.exports=function(r,t,a,n,i){i||(i={});var l=a("labelalias");Zm.isPlainObject(l)||delete t.labelalias;var o=oZ(r),s=a("showticklabels");if(s){i.noTicklabelshift||a("ticklabelshift"),i.noTicklabelstandoff||a("ticklabelstandoff");var u=i.font||{},f=t.color,c=t.ticklabelposition||"",h=c.indexOf("inside")!==-1?lZ(i.bgColor):f&&f!==S9.color.dflt?f:u.color;if(Zm.coerceFont(a,"tickfont",u,{overrideDflt:{color:h}}),!i.noTicklabelstep&&n!=="multicategory"&&n!=="log"&&a("ticklabelstep"),!i.noAng){var d=a("tickangle");!i.noAutotickangles&&d==="auto"&&a("autotickangles")}if(n!=="category"){var p=a("tickformat");sZ(r,t,{name:"tickformatstops",inclusionAttr:"enabled",handleItemDefaults:uZ}),t.tickformatstops.length||delete t.tickformatstops,!i.noExp&&!p&&n!=="date"&&(a("showexponent",o),a("exponentformat"),a("minexponent"),a("separatethousands"))}!i.noMinorloglabels&&n==="log"&&a("minorloglabels")}};function uZ(e,r){function t(n,i){return Zm.coerce(e,r,S9.tickformatstops,n,i)}var a=t("enabled");a&&(t("dtickrange"),t("value"))}});var Jm=G((H0e,L9)=>{"use strict";var fZ=Wm();L9.exports=function(r,t,a,n,i){i||(i={});var l=i.tickSuffixDflt,o=fZ(r),s=a("tickprefix");s&&a("showtickprefix",o);var u=a("ticksuffix",l);u&&a("showticksuffix",o)}});var Km=G((B0e,D9)=>{"use strict";var Yl=ze(),cZ=bt(),vZ=Um(),hZ=Vm(),dZ=Xm(),pZ=Jm(),mZ=N0();D9.exports=function(r,t,a){var n=cZ.newContainer(t,"colorbar"),i=r.colorbar||{};function l(R,z){return Yl.coerce(i,n,mZ,R,z)}var o=a.margin||{t:0,b:0,l:0,r:0},s=a.width-o.l-o.r,u=a.height-o.t-o.b,f=l("orientation"),c=f==="v",h=l("thicknessmode");l("thickness",h==="fraction"?30/(c?s:u):30);var d=l("lenmode");l("len",d==="fraction"?1:c?u:s);var p=l("yref"),y=l("xref"),g=p==="paper",x=y==="paper",_,M,b,w="left";c?(b="middle",w=x?"left":"right",_=x?1.02:1,M=.5):(b=g?"bottom":"top",w="center",_=.5,M=g?1.02:1),Yl.coerce(i,n,{x:{valType:"number",min:x?-2:0,max:x?3:1,dflt:_}},"x"),Yl.coerce(i,n,{y:{valType:"number",min:g?-2:0,max:g?3:1,dflt:M}},"y"),l("xanchor",w),l("xpad"),l("yanchor",b),l("ypad"),Yl.noneOrAll(i,n,["x","y"]),l("outlinecolor"),l("outlinewidth"),l("bordercolor"),l("borderwidth"),l("bgcolor");var k=Yl.coerce(i,n,{ticklabelposition:{valType:"enumerated",dflt:"outside",values:c?["outside","inside","outside top","inside top","outside bottom","inside bottom"]:["outside","inside","outside left","inside left","outside right","inside right"]}},"ticklabelposition");l("ticklabeloverflow",k.indexOf("inside")!==-1?"hide past domain":"hide past div"),vZ(i,n,l,"linear");var A=a.font,q={noAutotickangles:!0,noTicklabelshift:!0,noTicklabelstandoff:!0,outerTicks:!1,font:A};k.indexOf("inside")!==-1&&(q.bgColor="black"),pZ(i,n,l,"linear",q),dZ(i,n,l,"linear",q),hZ(i,n,l,"linear",q),l("title.text",a._dfltTitle.colorbar);var D=n.showticklabels?n.tickfont:A,E=Yl.extendFlat({},A,{family:D.family,size:Yl.bigFont(D.size)});Yl.coerceFont(l,"title.font",E),l("title.side",c?"top":"right")}});var Io=G((O0e,R9)=>{"use strict";var E9=zr(),$m=ze(),yZ=Hm(),gZ=Km(),P9=Ao().isValid,bZ=gr().traceIs;function Qm(e,r){var t=r.slice(0,r.length-1);return r?$m.nestedProperty(e,t).get()||{}:e}R9.exports=function e(r,t,a,n,i){var l=i.prefix,o=i.cLetter,s="_module"in t,u=Qm(r,l),f=Qm(t,l),c=Qm(t._template||{},l)||{},h=function(){return delete r.coloraxis,delete t.coloraxis,e(r,t,a,n,i)};if(s){var d=a._colorAxes||{},p=n(l+"coloraxis");if(p){var y=bZ(t,"contour")&&$m.nestedProperty(t,"contours.coloring").get()||"heatmap",g=d[p];g?(g[2].push(h),g[0]!==y&&(g[0]=!1,$m.warn(["Ignoring coloraxis:",p,"setting","as it is linked to incompatible colorscales."].join(" ")))):d[p]=[y,t,[h]];return}}var x=u[o+"min"],_=u[o+"max"],M=E9(x)&&E9(_)&&x<_,b=n(l+o+"auto",!M);b?n(l+o+"mid"):(n(l+o+"min"),n(l+o+"max"));var w=u.colorscale,k=c.colorscale,A;if(w!==void 0&&(A=!P9(w)),k!==void 0&&(A=!P9(k)),n(l+"autocolorscale",A),n(l+"colorscale"),n(l+"reversescale"),l!=="marker.line."){var q;l&&s&&(q=yZ(u));var D=n(l+"showscale",q);D&&(l&&c&&(f._template=c),gZ(u,f,a))}}});var I9=G((Y0e,F9)=>{"use strict";var z9=ze(),xZ=bt(),N9=Im(),_Z=Io();F9.exports=function(r,t){function a(c,h){return z9.coerce(r,t,N9,c,h)}a("colorscale.sequential"),a("colorscale.sequentialminus"),a("colorscale.diverging");var n=t._colorAxes,i,l;function o(c,h){return z9.coerce(i,l,N9.coloraxis,c,h)}for(var s in n){var u=n[s];if(u[0])i=r[s]||{},l=xZ.newContainer(t,s,"coloraxis"),l._name=s,_Z(i,l,t,o,{prefix:"",cLetter:"c"});else{for(var f=0;f{"use strict";var wZ=ze(),TZ=gn().hasColorscale,MZ=gn().extractOpts;H9.exports=function(r,t){function a(f,c){var h=f["_"+c];h!==void 0&&(f[c]=h)}function n(f,c){var h=c.container?wZ.nestedProperty(f,c.container).get():f;if(h)if(h.coloraxis)h._colorAx=t[h.coloraxis];else{var d=MZ(h),p=d.auto;(p||d.min===void 0)&&a(h,c.min),(p||d.max===void 0)&&a(h,c.max),d.autocolorscale&&a(h,"colorscale")}}for(var i=0;i{"use strict";var O9=zr(),jm=ze(),AZ=gn().extractOpts;Y9.exports=function(r,t,a){var n=r._fullLayout,i=a.vals,l=a.containerStr,o=l?jm.nestedProperty(t,l).get():t,s=AZ(o),u=s.auto!==!1,f=s.min,c=s.max,h=s.mid,d=function(){return jm.aggNums(Math.min,null,i)},p=function(){return jm.aggNums(Math.max,null,i)};if(f===void 0?f=d():u&&(o._colorAx&&O9(f)?f=Math.min(f,d()):f=d()),c===void 0?c=p():u&&(o._colorAx&&O9(c)?c=Math.max(c,p()):c=p()),u&&h!==void 0&&(c-h>h-f?f=h-(c-h):c-h=0?y=n.colorscale.sequential:y=n.colorscale.sequentialminus,s._sync("colorscale",y)}}});var lu=G((V0e,U9)=>{"use strict";var H0=Ao(),iu=gn();U9.exports={moduleType:"component",name:"colorscale",attributes:au(),layoutAttributes:Im(),supplyLayoutDefaults:I9(),handleDefaults:Io(),crossTraceDefaults:B9(),calc:nu(),scales:H0.scales,defaultScale:H0.defaultScale,getScale:H0.get,isValidScale:H0.isValid,hasColorscale:iu.hasColorscale,extractOpts:iu.extractOpts,extractScale:iu.extractScale,flipScale:iu.flipScale,makeColorScaleFunc:iu.makeColorScaleFunc,makeColorScaleFuncFromTrace:iu.makeColorScaleFuncFromTrace}});var bn=G((W0e,V9)=>{"use strict";var G9=ze(),kZ=$a().isTypedArraySpec;V9.exports={hasLines:function(e){return e.visible&&e.mode&&e.mode.indexOf("lines")!==-1},hasMarkers:function(e){return e.visible&&(e.mode&&e.mode.indexOf("markers")!==-1||e.type==="splom")},hasText:function(e){return e.visible&&e.mode&&e.mode.indexOf("text")!==-1},isBubble:function(e){var r=e.marker;return G9.isPlainObject(r)&&(G9.isArrayOrTypedArray(r.size)||kZ(r.size))}}});var Z9=G((Z0e,W9)=>{"use strict";var CZ=zr();W9.exports=function(r,t){t||(t=2);var a=r.marker,n=a.sizeref||1,i=a.sizemin||0,l=a.sizemode==="area"?function(o){return Math.sqrt(o/n)}:function(o){return o/n};return function(o){var s=l(o/t);return CZ(s)&&s>0?Math.max(s,i):0}}});var ul=G(Pa=>{"use strict";var X9=ze();Pa.getSubplot=function(e){return e.subplot||e.xaxis+e.yaxis||e.geo};Pa.isTraceInSubplots=function(e,r){if(e.type==="splom"){for(var t=e.xaxes||[],a=e.yaxes||[],n=0;n=0&&t.index{Q9.exports=EZ;var ey={a:7,c:6,h:1,l:2,m:2,q:4,s:4,t:2,v:1,z:0},DZ=/([astvzqmhlc])([^astvzqmhlc]*)/ig;function EZ(e){var r=[];return e.replace(DZ,function(t,a,n){var i=a.toLowerCase();for(n=RZ(n),i=="m"&&n.length>2&&(r.push([a].concat(n.splice(0,2))),i="l",a=a=="m"?"l":"L");;){if(n.length==ey[i])return n.unshift(a),r.push(n);if(n.length{"use strict";var zZ=ry(),Je=function(e,r){return r?Math.round(e*(r=Math.pow(10,r)))/r:Math.round(e)},Ar="M0,0Z",$9=Math.sqrt(2),Ul=Math.sqrt(3),ty=Math.PI,ay=Math.cos,ny=Math.sin;a8.exports={circle:{n:0,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2),n="M"+a+",0A"+a+","+a+" 0 1,1 0,-"+a+"A"+a+","+a+" 0 0,1 "+a+",0Z";return t?Cr(r,t,n):n}},square:{n:1,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2);return Cr(r,t,"M"+a+","+a+"H-"+a+"V-"+a+"H"+a+"Z")}},diamond:{n:2,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.3,2);return Cr(r,t,"M"+a+",0L0,"+a+"L-"+a+",0L0,-"+a+"Z")}},cross:{n:3,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*.4,2),n=Je(e*1.2,2);return Cr(r,t,"M"+n+","+a+"H"+a+"V"+n+"H-"+a+"V"+a+"H-"+n+"V-"+a+"H-"+a+"V-"+n+"H"+a+"V-"+a+"H"+n+"Z")}},x:{n:4,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*.8/$9,2),n="l"+a+","+a,i="l"+a+",-"+a,l="l-"+a+",-"+a,o="l-"+a+","+a;return Cr(r,t,"M0,"+a+n+i+l+i+l+o+l+o+n+o+n+"Z")}},"triangle-up":{n:5,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*2/Ul,2),n=Je(e/2,2),i=Je(e,2);return Cr(r,t,"M-"+a+","+n+"H"+a+"L0,-"+i+"Z")}},"triangle-down":{n:6,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*2/Ul,2),n=Je(e/2,2),i=Je(e,2);return Cr(r,t,"M-"+a+",-"+n+"H"+a+"L0,"+i+"Z")}},"triangle-left":{n:7,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*2/Ul,2),n=Je(e/2,2),i=Je(e,2);return Cr(r,t,"M"+n+",-"+a+"V"+a+"L-"+i+",0Z")}},"triangle-right":{n:8,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*2/Ul,2),n=Je(e/2,2),i=Je(e,2);return Cr(r,t,"M-"+n+",-"+a+"V"+a+"L"+i+",0Z")}},"triangle-ne":{n:9,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*.6,2),n=Je(e*1.2,2);return Cr(r,t,"M-"+n+",-"+a+"H"+a+"V"+n+"Z")}},"triangle-se":{n:10,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*.6,2),n=Je(e*1.2,2);return Cr(r,t,"M"+a+",-"+n+"V"+a+"H-"+n+"Z")}},"triangle-sw":{n:11,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*.6,2),n=Je(e*1.2,2);return Cr(r,t,"M"+n+","+a+"H-"+a+"V-"+n+"Z")}},"triangle-nw":{n:12,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*.6,2),n=Je(e*1.2,2);return Cr(r,t,"M-"+a+","+n+"V-"+a+"H"+n+"Z")}},pentagon:{n:13,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*.951,2),n=Je(e*.588,2),i=Je(-e,2),l=Je(e*-.309,2),o=Je(e*.809,2);return Cr(r,t,"M"+a+","+l+"L"+n+","+o+"H-"+n+"L-"+a+","+l+"L0,"+i+"Z")}},hexagon:{n:14,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2),n=Je(e/2,2),i=Je(e*Ul/2,2);return Cr(r,t,"M"+i+",-"+n+"V"+n+"L0,"+a+"L-"+i+","+n+"V-"+n+"L0,-"+a+"Z")}},hexagon2:{n:15,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2),n=Je(e/2,2),i=Je(e*Ul/2,2);return Cr(r,t,"M-"+n+","+i+"H"+n+"L"+a+",0L"+n+",-"+i+"H-"+n+"L-"+a+",0Z")}},octagon:{n:16,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*.924,2),n=Je(e*.383,2);return Cr(r,t,"M-"+n+",-"+a+"H"+n+"L"+a+",-"+n+"V"+n+"L"+n+","+a+"H-"+n+"L-"+a+","+n+"V-"+n+"Z")}},star:{n:17,f:function(e,r,t){if(kr(r))return Ar;var a=e*1.4,n=Je(a*.225,2),i=Je(a*.951,2),l=Je(a*.363,2),o=Je(a*.588,2),s=Je(-a,2),u=Je(a*-.309,2),f=Je(a*.118,2),c=Je(a*.809,2),h=Je(a*.382,2);return Cr(r,t,"M"+n+","+u+"H"+i+"L"+l+","+f+"L"+o+","+c+"L0,"+h+"L-"+o+","+c+"L-"+l+","+f+"L-"+i+","+u+"H-"+n+"L0,"+s+"Z")}},hexagram:{n:18,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*.66,2),n=Je(e*.38,2),i=Je(e*.76,2);return Cr(r,t,"M-"+i+",0l-"+n+",-"+a+"h"+i+"l"+n+",-"+a+"l"+n+","+a+"h"+i+"l-"+n+","+a+"l"+n+","+a+"h-"+i+"l-"+n+","+a+"l-"+n+",-"+a+"h-"+i+"Z")}},"star-triangle-up":{n:19,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*Ul*.8,2),n=Je(e*.8,2),i=Je(e*1.6,2),l=Je(e*4,2),o="A "+l+","+l+" 0 0 1 ";return Cr(r,t,"M-"+a+","+n+o+a+","+n+o+"0,-"+i+o+"-"+a+","+n+"Z")}},"star-triangle-down":{n:20,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*Ul*.8,2),n=Je(e*.8,2),i=Je(e*1.6,2),l=Je(e*4,2),o="A "+l+","+l+" 0 0 1 ";return Cr(r,t,"M"+a+",-"+n+o+"-"+a+",-"+n+o+"0,"+i+o+a+",-"+n+"Z")}},"star-square":{n:21,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.1,2),n=Je(e*2,2),i="A "+n+","+n+" 0 0 1 ";return Cr(r,t,"M-"+a+",-"+a+i+"-"+a+","+a+i+a+","+a+i+a+",-"+a+i+"-"+a+",-"+a+"Z")}},"star-diamond":{n:22,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.4,2),n=Je(e*1.9,2),i="A "+n+","+n+" 0 0 1 ";return Cr(r,t,"M-"+a+",0"+i+"0,"+a+i+a+",0"+i+"0,-"+a+i+"-"+a+",0Z")}},"diamond-tall":{n:23,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*.7,2),n=Je(e*1.4,2);return Cr(r,t,"M0,"+n+"L"+a+",0L0,-"+n+"L-"+a+",0Z")}},"diamond-wide":{n:24,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.4,2),n=Je(e*.7,2);return Cr(r,t,"M0,"+n+"L"+a+",0L0,-"+n+"L-"+a+",0Z")}},hourglass:{n:25,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2);return Cr(r,t,"M"+a+","+a+"H-"+a+"L"+a+",-"+a+"H-"+a+"Z")},noDot:!0},bowtie:{n:26,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2);return Cr(r,t,"M"+a+","+a+"V-"+a+"L-"+a+","+a+"V-"+a+"Z")},noDot:!0},"circle-cross":{n:27,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2);return Cr(r,t,"M0,"+a+"V-"+a+"M"+a+",0H-"+a+"M"+a+",0A"+a+","+a+" 0 1,1 0,-"+a+"A"+a+","+a+" 0 0,1 "+a+",0Z")},needLine:!0,noDot:!0},"circle-x":{n:28,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2),n=Je(e/$9,2);return Cr(r,t,"M"+n+","+n+"L-"+n+",-"+n+"M"+n+",-"+n+"L-"+n+","+n+"M"+a+",0A"+a+","+a+" 0 1,1 0,-"+a+"A"+a+","+a+" 0 0,1 "+a+",0Z")},needLine:!0,noDot:!0},"square-cross":{n:29,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2);return Cr(r,t,"M0,"+a+"V-"+a+"M"+a+",0H-"+a+"M"+a+","+a+"H-"+a+"V-"+a+"H"+a+"Z")},needLine:!0,noDot:!0},"square-x":{n:30,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2);return Cr(r,t,"M"+a+","+a+"L-"+a+",-"+a+"M"+a+",-"+a+"L-"+a+","+a+"M"+a+","+a+"H-"+a+"V-"+a+"H"+a+"Z")},needLine:!0,noDot:!0},"diamond-cross":{n:31,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.3,2);return Cr(r,t,"M"+a+",0L0,"+a+"L-"+a+",0L0,-"+a+"ZM0,-"+a+"V"+a+"M-"+a+",0H"+a)},needLine:!0,noDot:!0},"diamond-x":{n:32,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.3,2),n=Je(e*.65,2);return Cr(r,t,"M"+a+",0L0,"+a+"L-"+a+",0L0,-"+a+"ZM-"+n+",-"+n+"L"+n+","+n+"M-"+n+","+n+"L"+n+",-"+n)},needLine:!0,noDot:!0},"cross-thin":{n:33,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.4,2);return Cr(r,t,"M0,"+a+"V-"+a+"M"+a+",0H-"+a)},needLine:!0,noDot:!0,noFill:!0},"x-thin":{n:34,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2);return Cr(r,t,"M"+a+","+a+"L-"+a+",-"+a+"M"+a+",-"+a+"L-"+a+","+a)},needLine:!0,noDot:!0,noFill:!0},asterisk:{n:35,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.2,2),n=Je(e*.85,2);return Cr(r,t,"M0,"+a+"V-"+a+"M"+a+",0H-"+a+"M"+n+","+n+"L-"+n+",-"+n+"M"+n+",-"+n+"L-"+n+","+n)},needLine:!0,noDot:!0,noFill:!0},hash:{n:36,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e/2,2),n=Je(e,2);return Cr(r,t,"M"+a+","+n+"V-"+n+"M"+(a-n)+",-"+n+"V"+n+"M"+n+","+a+"H-"+n+"M-"+n+","+(a-n)+"H"+n)},needLine:!0,noFill:!0},"y-up":{n:37,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.2,2),n=Je(e*1.6,2),i=Je(e*.8,2);return Cr(r,t,"M-"+a+","+i+"L0,0M"+a+","+i+"L0,0M0,-"+n+"L0,0")},needLine:!0,noDot:!0,noFill:!0},"y-down":{n:38,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.2,2),n=Je(e*1.6,2),i=Je(e*.8,2);return Cr(r,t,"M-"+a+",-"+i+"L0,0M"+a+",-"+i+"L0,0M0,"+n+"L0,0")},needLine:!0,noDot:!0,noFill:!0},"y-left":{n:39,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.2,2),n=Je(e*1.6,2),i=Je(e*.8,2);return Cr(r,t,"M"+i+","+a+"L0,0M"+i+",-"+a+"L0,0M-"+n+",0L0,0")},needLine:!0,noDot:!0,noFill:!0},"y-right":{n:40,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.2,2),n=Je(e*1.6,2),i=Je(e*.8,2);return Cr(r,t,"M-"+i+","+a+"L0,0M-"+i+",-"+a+"L0,0M"+n+",0L0,0")},needLine:!0,noDot:!0,noFill:!0},"line-ew":{n:41,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.4,2);return Cr(r,t,"M"+a+",0H-"+a)},needLine:!0,noDot:!0,noFill:!0},"line-ns":{n:42,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*1.4,2);return Cr(r,t,"M0,"+a+"V-"+a)},needLine:!0,noDot:!0,noFill:!0},"line-ne":{n:43,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2);return Cr(r,t,"M"+a+",-"+a+"L-"+a+","+a)},needLine:!0,noDot:!0,noFill:!0},"line-nw":{n:44,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2);return Cr(r,t,"M"+a+","+a+"L-"+a+",-"+a)},needLine:!0,noDot:!0,noFill:!0},"arrow-up":{n:45,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2),n=Je(e*2,2);return Cr(r,t,"M0,0L-"+a+","+n+"H"+a+"Z")},backoff:1,noDot:!0},"arrow-down":{n:46,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2),n=Je(e*2,2);return Cr(r,t,"M0,0L-"+a+",-"+n+"H"+a+"Z")},noDot:!0},"arrow-left":{n:47,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*2,2),n=Je(e,2);return Cr(r,t,"M0,0L"+a+",-"+n+"V"+n+"Z")},noDot:!0},"arrow-right":{n:48,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*2,2),n=Je(e,2);return Cr(r,t,"M0,0L-"+a+",-"+n+"V"+n+"Z")},noDot:!0},"arrow-bar-up":{n:49,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2),n=Je(e*2,2);return Cr(r,t,"M-"+a+",0H"+a+"M0,0L-"+a+","+n+"H"+a+"Z")},backoff:1,needLine:!0,noDot:!0},"arrow-bar-down":{n:50,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e,2),n=Je(e*2,2);return Cr(r,t,"M-"+a+",0H"+a+"M0,0L-"+a+",-"+n+"H"+a+"Z")},needLine:!0,noDot:!0},"arrow-bar-left":{n:51,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*2,2),n=Je(e,2);return Cr(r,t,"M0,-"+n+"V"+n+"M0,0L"+a+",-"+n+"V"+n+"Z")},needLine:!0,noDot:!0},"arrow-bar-right":{n:52,f:function(e,r,t){if(kr(r))return Ar;var a=Je(e*2,2),n=Je(e,2);return Cr(r,t,"M0,-"+n+"V"+n+"M0,0L-"+a+",-"+n+"V"+n+"Z")},needLine:!0,noDot:!0},arrow:{n:53,f:function(e,r,t){if(kr(r))return Ar;var a=ty/2.5,n=2*e*ay(a),i=2*e*ny(a);return Cr(r,t,"M0,0L"+-n+","+i+"L"+n+","+i+"Z")},backoff:.9,noDot:!0},"arrow-wide":{n:54,f:function(e,r,t){if(kr(r))return Ar;var a=ty/4,n=2*e*ay(a),i=2*e*ny(a);return Cr(r,t,"M0,0L"+-n+","+i+"A "+2*e+","+2*e+" 0 0 1 "+n+","+i+"Z")},backoff:.4,noDot:!0}};function kr(e){return e===null}var j9,e8,r8,t8;function Cr(e,r,t){if((!e||e%360===0)&&!r)return t;if(r8===e&&t8===r&&j9===t)return e8;r8=e,t8=r,j9=t;function a(g,x){var _=ay(g),M=ny(g),b=x[0],w=x[1]+(r||0);return[b*_-w*M,b*M+w*_]}for(var n=e/180*ty,i=0,l=0,o=zZ(t),s="",u=0;u{"use strict";var ca=Rr(),ct=ze(),NZ=ct.numberFormat,Yo=zr(),fy=Pn(),O0=gr(),Sa=Lr(),FZ=lu(),Wf=ct.strTranslate,Y0=Ca(),IZ=Bl(),HZ=Ea(),BZ=HZ.LINE_SPACING,d8=jv().DESELECTDIM,OZ=bn(),YZ=Z9(),UZ=ul().appendArrayPointValue,er=M8.exports={};er.font=function(e,r){var t=r.variant,a=r.style,n=r.weight,i=r.color,l=r.size,o=r.family,s=r.shadow,u=r.lineposition,f=r.textcase;o&&e.style("font-family",o),l+1&&e.style("font-size",l+"px"),i&&e.call(Sa.fill,i),n&&e.style("font-weight",n),a&&e.style("font-style",a),t&&e.style("font-variant",t),f&&e.style("text-transform",iy(VZ(f))),s&&e.style("text-shadow",s==="auto"?Y0.makeTextShadow(Sa.contrast(i)):iy(s)),u&&e.style("text-decoration-line",iy(WZ(u)))};function iy(e){return e==="none"?void 0:e}var GZ={normal:"none",lower:"lowercase",upper:"uppercase","word caps":"capitalize"};function VZ(e){return GZ[e]}function WZ(e){return e.replace("under","underline").replace("over","overline").replace("through","line-through").split("+").join(" ")}er.setPosition=function(e,r,t){e.attr("x",r).attr("y",t)};er.setSize=function(e,r,t){e.attr("width",r).attr("height",t)};er.setRect=function(e,r,t,a,n){e.call(er.setPosition,r,t).call(er.setSize,a,n)};er.translatePoint=function(e,r,t,a){var n=t.c2p(e.x),i=a.c2p(e.y);if(Yo(n)&&Yo(i)&&r.node())r.node().nodeName==="text"?r.attr("x",n).attr("y",i):r.attr("transform",Wf(n,i));else return!1;return!0};er.translatePoints=function(e,r,t){e.each(function(a){var n=ca.select(this);er.translatePoint(a,n,r,t)})};er.hideOutsideRangePoint=function(e,r,t,a,n,i){r.attr("display",t.isPtWithinRange(e,n)&&a.isPtWithinRange(e,i)?null:"none")};er.hideOutsideRangePoints=function(e,r){if(r._hasClipOnAxisFalse){var t=r.xaxis,a=r.yaxis;e.each(function(n){var i=n[0].trace,l=i.xcalendar,o=i.ycalendar,s=O0.traceIs(i,"bar-like")?".bartext":".point,.textpoint";e.selectAll(s).each(function(u){er.hideOutsideRangePoint(u,ca.select(this),t,a,l,o)})})}};er.crispRound=function(e,r,t){return!r||!Yo(r)?t||0:e._context.staticPlot?r:r<1?1:Math.round(r)};er.singleLineStyle=function(e,r,t,a,n){r.style("fill","none");var i=(((e||[])[0]||{}).trace||{}).line||{},l=t||i.width||0,o=n||i.dash||"";Sa.stroke(r,a||i.color),er.dashLine(r,o,l)};er.lineGroupStyle=function(e,r,t,a){e.style("fill","none").each(function(n){var i=(((n||[])[0]||{}).trace||{}).line||{},l=r||i.width||0,o=a||i.dash||"";ca.select(this).call(Sa.stroke,t||i.color).call(er.dashLine,o,l)})};er.dashLine=function(e,r,t){t=+t||0,r=er.dashStyle(r,t),e.style({"stroke-dasharray":r,"stroke-width":t+"px"})};er.dashStyle=function(e,r){r=+r||1;var t=Math.max(r,3);return e==="solid"?e="":e==="dot"?e=t+"px,"+t+"px":e==="dash"?e=3*t+"px,"+3*t+"px":e==="longdash"?e=5*t+"px,"+5*t+"px":e==="dashdot"?e=3*t+"px,"+t+"px,"+t+"px,"+t+"px":e==="longdashdot"&&(e=5*t+"px,"+2*t+"px,"+t+"px,"+2*t+"px"),e};function p8(e,r,t,a){var n=r.fillpattern,i=r.fillgradient,l=er.getPatternAttr,o=n&&(l(n.shape,0,"")||l(n.path,0,""));if(o){var s=l(n.bgcolor,0,null),u=l(n.fgcolor,0,null),f=n.fgopacity,c=l(n.size,0,8),h=l(n.solidity,0,.3),d=r.uid;er.pattern(e,"point",t,d,o,c,h,void 0,n.fillmode,s,u,f)}else if(i&&i.type!=="none"){var p=i.type,y="scatterfill-"+r.uid;if(a&&(y="legendfill-"+r.uid),!a&&(i.start!==void 0||i.stop!==void 0)){var g,x;p==="horizontal"?(g={x:i.start,y:0},x={x:i.stop,y:0}):p==="vertical"&&(g={x:0,y:i.start},x={x:0,y:i.stop}),g.x=r._xA.c2p(g.x===void 0?r._extremes.x.min[0].val:g.x,!0),g.y=r._yA.c2p(g.y===void 0?r._extremes.y.min[0].val:g.y,!0),x.x=r._xA.c2p(x.x===void 0?r._extremes.x.max[0].val:x.x,!0),x.y=r._yA.c2p(x.y===void 0?r._extremes.y.max[0].val:x.y,!0),e.call(g8,t,y,"linear",i.colorscale,"fill",g,x,!0,!1)}else p==="horizontal"&&(p=p+"reversed"),e.call(er.gradient,t,y,p,i.colorscale,"fill")}else r.fillcolor&&e.call(Sa.fill,r.fillcolor)}er.singleFillStyle=function(e,r){var t=ca.select(e.node()),a=t.data(),n=((a[0]||[])[0]||{}).trace||{};p8(e,n,r,!1)};er.fillGroupStyle=function(e,r,t){e.style("stroke-width",0).each(function(a){var n=ca.select(this);a[0].trace&&p8(n,a[0].trace,r,t)})};var i8=n8();er.symbolNames=[];er.symbolFuncs=[];er.symbolBackOffs=[];er.symbolNeedLines={};er.symbolNoDot={};er.symbolNoFill={};er.symbolList=[];Object.keys(i8).forEach(function(e){var r=i8[e],t=r.n;er.symbolList.push(t,String(t),e,t+100,String(t+100),e+"-open"),er.symbolNames[t]=e,er.symbolFuncs[t]=r.f,er.symbolBackOffs[t]=r.backoff||0,r.needLine&&(er.symbolNeedLines[t]=!0),r.noDot?er.symbolNoDot[t]=!0:er.symbolList.push(t+200,String(t+200),e+"-dot",t+300,String(t+300),e+"-open-dot"),r.noFill&&(er.symbolNoFill[t]=!0)});var ZZ=er.symbolNames.length,XZ="M0,0.5L0.5,0L0,-0.5L-0.5,0Z";er.symbolNumber=function(e){if(Yo(e))e=+e;else if(typeof e=="string"){var r=0;e.indexOf("-open")>0&&(r=100,e=e.replace("-open","")),e.indexOf("-dot")>0&&(r+=200,e=e.replace("-dot","")),e=er.symbolNames.indexOf(e),e>=0&&(e+=r)}return e%100>=ZZ||e>=400?0:Math.floor(Math.max(e,0))};function m8(e,r,t,a){var n=e%100;return er.symbolFuncs[n](r,t,a)+(e>=200?XZ:"")}var l8=NZ("~f"),y8={radial:{type:"radial"},radialreversed:{type:"radial",reversed:!0},horizontal:{type:"linear",start:{x:1,y:0},stop:{x:0,y:0}},horizontalreversed:{type:"linear",start:{x:1,y:0},stop:{x:0,y:0},reversed:!0},vertical:{type:"linear",start:{x:0,y:1},stop:{x:0,y:0}},verticalreversed:{type:"linear",start:{x:0,y:1},stop:{x:0,y:0},reversed:!0}};er.gradient=function(e,r,t,a,n,i){var l=y8[a];return g8(e,r,t,l.type,n,i,l.start,l.stop,!1,l.reversed)};function g8(e,r,t,a,n,i,l,o,s,u){var f=n.length,c;a==="linear"?c={node:"linearGradient",attrs:{x1:l.x,y1:l.y,x2:o.x,y2:o.y,gradientUnits:s?"userSpaceOnUse":"objectBoundingBox"},reversed:u}:a==="radial"&&(c={node:"radialGradient",reversed:u});for(var h=new Array(f),d=0;d=0&&e.i===void 0&&(e.i=i.i),r.style("opacity",a.selectedOpacityFn?a.selectedOpacityFn(e):e.mo===void 0?l.opacity:e.mo),a.ms2mrc){var s;e.ms==="various"||l.size==="various"?s=3:s=a.ms2mrc(e.ms),e.mrc=s,a.selectedSizeFn&&(s=e.mrc=a.selectedSizeFn(e));var u=er.symbolNumber(e.mx||l.symbol)||0;e.om=u%200>=100;var f=hy(e,t),c=vy(e,t);r.attr("d",m8(u,s,f,c))}var h=!1,d,p,y;if(e.so)y=o.outlierwidth,p=o.outliercolor,d=l.outliercolor;else{var g=(o||{}).width;y=(e.mlw+1||g+1||(e.trace?(e.trace.marker.line||{}).width:0)+1)-1||0,"mlc"in e?p=e.mlcc=a.lineScale(e.mlc):ct.isArrayOrTypedArray(o.color)?p=Sa.defaultLine:p=o.color,ct.isArrayOrTypedArray(l.color)&&(d=Sa.defaultLine,h=!0),"mc"in e?d=e.mcc=a.markerScale(e.mc):d=l.color||l.colors||"rgba(0,0,0,0)",a.selectedColorFn&&(d=a.selectedColorFn(e))}if(e.om)r.call(Sa.stroke,d).style({"stroke-width":(y||1)+"px",fill:"none"});else{r.style("stroke-width",(e.isBlank?0:y)+"px");var x=l.gradient,_=e.mgt;_?h=!0:_=x&&x.type,ct.isArrayOrTypedArray(_)&&(_=_[0],y8[_]||(_=0));var M=l.pattern,b=er.getPatternAttr,w=M&&(b(M.shape,e.i,"")||b(M.path,e.i,""));if(_&&_!=="none"){var k=e.mgc;k?h=!0:k=x.color;var A=t.uid;h&&(A+="-"+e.i),er.gradient(r,n,A,_,[[0,k],[1,d]],"fill")}else if(w){var q=!1,D=M.fgcolor;!D&&i&&i.color&&(D=i.color,q=!0);var E=b(D,e.i,i&&i.color||null),R=b(M.bgcolor,e.i,null),z=M.fgopacity,F=b(M.size,e.i,8),H=b(M.solidity,e.i,.3);q=q||e.mcc||ct.isArrayOrTypedArray(M.shape)||ct.isArrayOrTypedArray(M.path)||ct.isArrayOrTypedArray(M.bgcolor)||ct.isArrayOrTypedArray(M.fgcolor)||ct.isArrayOrTypedArray(M.size)||ct.isArrayOrTypedArray(M.solidity);var W=t.uid;q&&(W+="-"+e.i),er.pattern(r,"point",n,W,w,F,H,e.mcc,M.fillmode,R,E,z)}else ct.isArrayOrTypedArray(d)?Sa.fill(r,d[e.i]):Sa.fill(r,d);y&&Sa.stroke(r,p)}};er.makePointStyleFns=function(e){var r={},t=e.marker;return r.markerScale=er.tryColorscale(t,""),r.lineScale=er.tryColorscale(t,"line"),O0.traceIs(e,"symbols")&&(r.ms2mrc=OZ.isBubble(e)?YZ(e):function(){return(t.size||6)/2}),e.selectedpoints&&ct.extendFlat(r,er.makeSelectedPointStyleFns(e)),r};er.makeSelectedPointStyleFns=function(e){var r={},t=e.selected||{},a=e.unselected||{},n=e.marker||{},i=t.marker||{},l=a.marker||{},o=n.opacity,s=i.opacity,u=l.opacity,f=s!==void 0,c=u!==void 0;(ct.isArrayOrTypedArray(o)||f||c)&&(r.selectedOpacityFn=function(b){var w=b.mo===void 0?n.opacity:b.mo;return b.selected?f?s:w:c?u:d8*w});var h=n.color,d=i.color,p=l.color;(d||p)&&(r.selectedColorFn=function(b){var w=b.mcc||h;return b.selected?d||w:p||w});var y=n.size,g=i.size,x=l.size,_=g!==void 0,M=x!==void 0;return O0.traceIs(e,"symbols")&&(_||M)&&(r.selectedSizeFn=function(b){var w=b.mrc||y/2;return b.selected?_?g/2:w:M?x/2:w}),r};er.makeSelectedTextStyleFns=function(e){var r={},t=e.selected||{},a=e.unselected||{},n=e.textfont||{},i=t.textfont||{},l=a.textfont||{},o=n.color,s=i.color,u=l.color;return r.selectedTextColorFn=function(f){var c=f.tc||o;return f.selected?s||c:u||(s?c:Sa.addOpacity(c,d8))},r};er.selectedPointStyle=function(e,r){if(!(!e.size()||!r.selectedpoints)){var t=er.makeSelectedPointStyleFns(r),a=r.marker||{},n=[];t.selectedOpacityFn&&n.push(function(i,l){i.style("opacity",t.selectedOpacityFn(l))}),t.selectedColorFn&&n.push(function(i,l){Sa.fill(i,t.selectedColorFn(l))}),t.selectedSizeFn&&n.push(function(i,l){var o=l.mx||a.symbol||0,s=t.selectedSizeFn(l);i.attr("d",m8(er.symbolNumber(o),s,hy(l,r),vy(l,r))),l.mrc2=s}),n.length&&e.each(function(i){for(var l=ca.select(this),o=0;o0?t:0}er.textPointStyle=function(e,r,t){if(e.size()){var a;if(r.selectedpoints){var n=er.makeSelectedTextStyleFns(r);a=n.selectedTextColorFn}var i=r.texttemplate,l=t._fullLayout;e.each(function(o){var s=ca.select(this),u=i?ct.extractOption(o,r,"txt","texttemplate"):ct.extractOption(o,r,"tx","text");if(!u&&u!==0){s.remove();return}if(i){var f=r._module.formatLabels,c=f?f(o,r,l):{},h={};UZ(h,r,o.i);var d=r._meta||{};u=ct.texttemplateString(u,c,l._d3locale,h,o,d)}var p=o.tp||r.textposition,y=x8(o,r),g=a?a(o):o.tc||r.textfont.color;s.call(er.font,{family:o.tf||r.textfont.family,weight:o.tw||r.textfont.weight,style:o.ty||r.textfont.style,variant:o.tv||r.textfont.variant,textcase:o.tC||r.textfont.textcase,lineposition:o.tE||r.textfont.lineposition,shadow:o.tS||r.textfont.shadow,size:y,color:g}).text(u).call(Y0.convertToTspans,t).call(b8,p,y,o.mrc)})}};er.selectedTextStyle=function(e,r){if(!(!e.size()||!r.selectedpoints)){var t=er.makeSelectedTextStyleFns(r);e.each(function(a){var n=ca.select(this),i=t.selectedTextColorFn(a),l=a.tp||r.textposition,o=x8(a,r);Sa.fill(n,i);var s=O0.traceIs(r,"bar-like");b8(n,l,o,a.mrc2||a.mrc,s)})}};var o8=.5;er.smoothopen=function(e,r){if(e.length<3)return"M"+e.join("L");var t="M"+e[0],a=[],n;for(n=1;n=s||b>=f&&b<=s)&&(w<=c&&w>=u||w>=c&&w<=u)&&(e=[b,w])}return e}er.applyBackoff=T8;er.makeTester=function(){var e=ct.ensureSingleById(ca.select("body"),"svg","js-plotly-tester",function(t){t.attr(IZ.svgAttrs).style({position:"absolute",left:"-10000px",top:"-10000px",width:"9000px",height:"9000px","z-index":"1"})}),r=ct.ensureSingle(e,"path","js-reference-point",function(t){t.attr("d","M0,0H1V1H0Z").style({"stroke-width":0,fill:"black"})});er.tester=e,er.testref=r};er.savedBBoxes={};var oy=0,QZ=1e4;er.bBox=function(e,r,t){t||(t=s8(e));var a;if(t){if(a=er.savedBBoxes[t],a)return ct.extendFlat({},a)}else if(e.childNodes.length===1){var n=e.childNodes[0];if(t=s8(n),t){var i=+n.getAttribute("x")||0,l=+n.getAttribute("y")||0,o=n.getAttribute("transform");if(!o){var s=er.bBox(n,!1,t);return i&&(s.left+=i,s.right+=i),l&&(s.top+=l,s.bottom+=l),s}if(t+="~"+i+"~"+l+"~"+o,a=er.savedBBoxes[t],a)return ct.extendFlat({},a)}}var u,f;r?u=e:(f=er.tester.node(),u=e.cloneNode(!0),f.appendChild(u)),ca.select(u).attr("transform",null).call(Y0.positionText,0,0);var c=u.getBoundingClientRect(),h=er.testref.node().getBoundingClientRect();r||f.removeChild(u);var d={height:c.height,width:c.width,left:c.left-h.left,top:c.top-h.top,right:c.right-h.left,bottom:c.bottom-h.top};return oy>=QZ&&(er.savedBBoxes={},oy=0),t&&(er.savedBBoxes[t]=d),oy++,ct.extendFlat({},d)};function s8(e){var r=e.getAttribute("data-unformatted");if(r!==null)return r+e.getAttribute("data-math")+e.getAttribute("text-anchor")+e.getAttribute("style")}er.setClipUrl=function(e,r,t){e.attr("clip-path",cy(r,t))};function cy(e,r){if(!e)return null;var t=r._context,a=t._exportedPlot?"":t._baseUrl||"";return a?"url('"+a+"#"+e+"')":"url(#"+e+")"}er.getTranslate=function(e){var r=/.*\btranslate\((-?\d*\.?\d*)[^-\d]*(-?\d*\.?\d*)[^\d].*/,t=e.attr?"attr":"getAttribute",a=e[t]("transform")||"",n=a.replace(r,function(i,l,o){return[l,o].join(" ")}).split(" ");return{x:+n[0]||0,y:+n[1]||0}};er.setTranslate=function(e,r,t){var a=/(\btranslate\(.*?\);?)/,n=e.attr?"attr":"getAttribute",i=e.attr?"attr":"setAttribute",l=e[n]("transform")||"";return r=r||0,t=t||0,l=l.replace(a,"").trim(),l+=Wf(r,t),l=l.trim(),e[i]("transform",l),l};er.getScale=function(e){var r=/.*\bscale\((\d*\.?\d*)[^\d]*(\d*\.?\d*)[^\d].*/,t=e.attr?"attr":"getAttribute",a=e[t]("transform")||"",n=a.replace(r,function(i,l,o){return[l,o].join(" ")}).split(" ");return{x:+n[0]||1,y:+n[1]||1}};er.setScale=function(e,r,t){var a=/(\bscale\(.*?\);?)/,n=e.attr?"attr":"getAttribute",i=e.attr?"attr":"setAttribute",l=e[n]("transform")||"";return r=r||1,t=t||1,l=l.replace(a,"").trim(),l+="scale("+r+","+t+")",l=l.trim(),e[i]("transform",l),l};var $Z=/\s*sc.*/;er.setPointGroupScale=function(e,r,t){if(r=r||1,t=t||1,!!e){var a=r===1&&t===1?"":"scale("+r+","+t+")";e.each(function(){var n=(this.getAttribute("transform")||"").replace($Z,"");n+=a,n=n.trim(),this.setAttribute("transform",n)})}};var jZ=/translate\([^)]*\)\s*$/;er.setTextPointsScale=function(e,r,t){e&&e.each(function(){var a,n=ca.select(this),i=n.select("text");if(i.node()){var l=parseFloat(i.attr("x")||0),o=parseFloat(i.attr("y")||0),s=(n.attr("transform")||"").match(jZ);r===1&&t===1?a=[]:a=[Wf(l,o),"scale("+r+","+t+")",Wf(-l,-o)],s&&a.push(s),n.attr("transform",a.join(""))}})};function vy(e,r){var t;return e&&(t=e.mf),t===void 0&&(t=r.marker&&r.marker.standoff||0),!r._geo&&!r._xA?-t:t}er.getMarkerStandoff=vy;var Vf=Math.atan2,Ho=Math.cos,su=Math.sin;function u8(e,r){var t=r[0],a=r[1];return[t*Ho(e)-a*su(e),t*su(e)+a*Ho(e)]}var f8,c8,v8,h8,sy,uy;function hy(e,r){var t=e.ma;t===void 0&&(t=r.marker.angle,(!t||ct.isArrayOrTypedArray(t))&&(t=0));var a,n,i=r.marker.angleref;if(i==="previous"||i==="north"){if(r._geo){var l=r._geo.project(e.lonlat);a=l[0],n=l[1]}else{var o=r._xA,s=r._yA;if(o&&s)a=o.c2p(e.x),n=s.c2p(e.y);else return 90}if(r._geo){var u=e.lonlat[0],f=e.lonlat[1],c=r._geo.project([u,f+1e-5]),h=r._geo.project([u+1e-5,f]),d=Vf(h[1]-n,h[0]-a),p=Vf(c[1]-n,c[0]-a),y;if(i==="north")y=t/180*Math.PI;else if(i==="previous"){var g=u/180*Math.PI,x=f/180*Math.PI,_=f8/180*Math.PI,M=c8/180*Math.PI,b=_-g,w=Ho(M)*su(b),k=su(M)*Ho(x)-Ho(M)*su(x)*Ho(b);y=-Vf(w,k)-Math.PI,f8=u,c8=f}var A=u8(d,[Ho(y),0]),q=u8(p,[su(y),0]);t=Vf(A[1]+q[1],A[0]+q[0])/Math.PI*180,i==="previous"&&!(uy===r.uid&&e.i===sy+1)&&(t=null)}if(i==="previous"&&!r._geo)if(uy===r.uid&&e.i===sy+1&&Yo(a)&&Yo(n)){var D=a-v8,E=n-h8,R=r.line&&r.line.shape||"",z=R.slice(R.length-1);z==="h"&&(E=0),z==="v"&&(D=0),t+=Vf(E,D)/Math.PI*180+90}else t=null}return v8=a,h8=n,sy=e.i,uy=r.uid,t}er.getMarkerAngle=hy});var Zf=G(($0e,S8)=>{"use strict";var uu=Rr(),eX=zr(),rX=fa(),dy=gr(),Uo=ze(),A8=Uo.strTranslate,U0=Kr(),G0=Lr(),fu=Ca(),k8=jv(),tX=Ea().OPPOSITE_SIDE,C8=/ [XY][0-9]* /,py=1.6,my=1.6;function aX(e,r,t){var a=e._fullLayout,n=t.propContainer,i=t.propName,l=t.placeholder,o=t.traceIndex,s=t.avoid||{},u=t.attributes,f=t.transform,c=t.containerGroup,h=1,d=n.title,p=(d&&d.text?d.text:"").trim(),y=!1,g=d&&d.font?d.font:{},x=g.family,_=g.size,M=g.color,b=g.weight,w=g.style,k=g.variant,A=g.textcase,q=g.lineposition,D=g.shadow,E=t.subtitlePropName,R=!!E,z=t.subtitlePlaceholder,F=(n.title||{}).subtitle||{text:"",font:{}},H=(F.text||"").trim(),W=!1,Z=1,Y=F.font,B=Y.family,U=Y.size,K=Y.color,Q=Y.weight,ae=Y.style,fe=Y.variant,oe=Y.textcase,ce=Y.lineposition,$=Y.shadow,Te;i==="title.text"?Te="titleText":i.indexOf("axis")!==-1?Te="axisTitleText":i.indexOf("colorbar")!==-1&&(Te="colorbarTitleText");var ue=e._context.edits[Te];function me(Ue,Oe){return Ue===void 0||Oe===void 0?!1:Ue.replace(C8," % ")===Oe.replace(C8," % ")}p===""?h=0:me(p,l)&&(ue||(p=""),h=.2,y=!0),R&&(H===""?Z=0:me(H,z)&&(ue||(H=""),Z=.2,W=!0)),t._meta?p=Uo.templateString(p,t._meta):a._meta&&(p=Uo.templateString(p,a._meta));var ie=p||H||ue,de;c||(c=Uo.ensureSingle(a._infolayer,"g","g-"+r),de=a._hColorbarMoveTitle);var O=c.selectAll("text."+r).data(ie?[0]:[]);O.enter().append("text"),O.text(p).attr("class",r),O.exit().remove();var j=null,V=r+"-subtitle",pe=H||ue;if(R&&(j=c.selectAll("text."+V).data(pe?[0]:[]),j.enter().append("text"),j.text(H).attr("class",V),j.exit().remove()),!ie)return c;function we(Ue,Oe){Uo.syncOrAsync([ge,Pe],{title:Ue,subtitle:Oe})}function ge(Ue){var Oe=Ue.title,Le=Ue.subtitle,Ie;!f&&de&&(f={}),f?(Ie="",f.rotate&&(Ie+="rotate("+[f.rotate,u.x,u.y]+")"),(f.offset||de)&&(Ie+=A8(0,(f.offset||0)-(de||0)))):Ie=null,Oe.attr("transform",Ie);function Be(se){if(se){var Se=uu.select(se.node().parentNode).select("."+V);if(!Se.empty()){var He=se.node().getBBox();if(He.height){var Ze=He.y+He.height+py*U;Se.attr("y",Ze)}}}}if(Oe.style("opacity",h*G0.opacity(M)).call(U0.font,{color:G0.rgb(M),size:uu.round(_,2),family:x,weight:b,style:w,variant:k,textcase:A,shadow:D,lineposition:q}).attr(u).call(fu.convertToTspans,e,Be),Le&&!Le.empty()){var le=c.select("."+r+"-math-group"),Me=Oe.node().getBBox(),We=le.node()?le.node().getBBox():void 0,sr=We?We.y+We.height+py*U:Me.y+Me.height+my*U,lr=Uo.extendFlat({},u,{y:sr});Le.attr("transform",Ie),Le.style("opacity",Z*G0.opacity(K)).call(U0.font,{color:G0.rgb(K),size:uu.round(U,2),family:B,weight:Q,style:ae,variant:fe,textcase:oe,shadow:$,lineposition:ce}).attr(lr).call(fu.convertToTspans,e)}return rX.previousPromises(e)}function Pe(Ue){var Oe=Ue.title,Le=uu.select(Oe.node().parentNode);if(s&&s.selection&&s.side&&p){Le.attr("transform",null);var Ie=tX[s.side],Be=s.side==="left"||s.side==="top"?-1:1,le=eX(s.pad)?s.pad:2,Me=U0.bBox(Le.node()),We={t:0,b:0,l:0,r:0},sr=e._fullLayout._reservedMargin;for(var lr in sr)for(var se in sr[lr]){var Se=sr[lr][se];We[se]=Math.max(We[se],Se)}var He={left:We.l,top:We.t,right:a.width-We.r,bottom:a.height-We.b},Ze=s.maxShift||Be*(He[s.side]-Me[s.side]),Ye=0;if(Ze<0)Ye=Ze;else{var Xe=s.offsetLeft||0,Qe=s.offsetTop||0;Me.left-=Xe,Me.right-=Xe,Me.top-=Qe,Me.bottom-=Qe,s.selection.each(function(){var Ke=U0.bBox(this);Uo.bBoxIntersect(Me,Ke,le)&&(Ye=Math.max(Ye,Be*(Ke[s.side]-Me[Ie])+le))}),Ye=Math.min(Ze,Ye),n._titleScoot=Math.abs(Ye)}if(Ye>0||Ze<0){var hr={left:[-Ye,0],right:[Ye,0],top:[0,-Ye],bottom:[0,Ye]}[s.side];Le.attr("transform",A8(hr[0],hr[1]))}}}O.call(we,j);function Ne(Ue,Oe){Ue.text(Oe).on("mouseover.opacity",function(){uu.select(this).transition().duration(k8.SHOW_PLACEHOLDER).style("opacity",1)}).on("mouseout.opacity",function(){uu.select(this).transition().duration(k8.HIDE_PLACEHOLDER).style("opacity",0)})}if(ue&&(p?O.on(".opacity",null):(Ne(O,l),y=!0),O.call(fu.makeEditable,{gd:e}).on("edit",function(Ue){o!==void 0?dy.call("_guiRestyle",e,i,Ue,o):dy.call("_guiRelayout",e,i,Ue)}).on("cancel",function(){this.text(this.attr("data-unformatted")).call(we)}).on("input",function(Ue){this.text(Ue||" ").call(fu.positionText,u.x,u.y)}),R)){if(R&&!p){var Ee=O.node().getBBox(),Fe=Ee.y+Ee.height+my*U;j.attr("y",Fe)}H?j.on(".opacity",null):(Ne(j,z),W=!0),j.call(fu.makeEditable,{gd:e}).on("edit",function(Ue){dy.call("_guiRelayout",e,"title.subtitle.text",Ue)}).on("cancel",function(){this.text(this.attr("data-unformatted")).call(we)}).on("input",function(Ue){this.text(Ue||" ").call(fu.positionText,j.attr("x"),j.attr("y"))})}return O.classed("js-placeholder",y),j&&!j.empty()&&j.classed("js-placeholder",W),c}S8.exports={draw:aX,SUBTITLE_PADDING_EM:my,SUBTITLE_PADDING_MATHJAX_EM:py}});var $0=G((j0e,P8)=>{"use strict";var nX=Rr(),iX=zs().utcFormat,pt=ze(),lX=pt.numberFormat,Ii=zr(),Gl=pt.cleanNumber,oX=pt.ms2DateTime,q8=pt.dateTime2ms,Hi=pt.ensureNumber,L8=pt.isArrayOrTypedArray,Vl=Ct(),V0=Vl.FP_SAFE,yi=Vl.BADNUM,sX=Vl.LOG_CLIP,uX=Vl.ONEWEEK,W0=Vl.ONEDAY,Z0=Vl.ONEHOUR,D8=Vl.ONEMIN,E8=Vl.ONESEC,X0=aa(),Q0=ka(),J0=Q0.HOUR_PATTERN,K0=Q0.WEEKDAY_PATTERN;function Xf(e){return Math.pow(10,e)}function yy(e){return e!=null}P8.exports=function(r,t){t=t||{};var a=r._id||"x",n=a.charAt(0);function i(b,w){if(b>0)return Math.log(b)/Math.LN10;if(b<=0&&w&&r.range&&r.range.length===2){var k=r.range[0],A=r.range[1];return .5*(k+A-2*sX*Math.abs(k-A))}else return yi}function l(b,w,k,A){if((A||{}).msUTC&&Ii(b))return+b;var q=q8(b,k||r.calendar);if(q===yi)if(Ii(b)){b=+b;var D=Math.floor(pt.mod(b+.05,1)*10),E=Math.round(b-D/10);q=q8(new Date(E))+D/10}else return yi;return q}function o(b,w,k){return oX(b,w,k||r.calendar)}function s(b){return r._categories[Math.round(b)]}function u(b){if(yy(b)){if(r._categoriesMap===void 0&&(r._categoriesMap={}),r._categoriesMap[b]!==void 0)return r._categoriesMap[b];r._categories.push(typeof b=="number"?String(b):b);var w=r._categories.length-1;return r._categoriesMap[b]=w,w}return yi}function f(b,w){for(var k=new Array(w),A=0;Ar.range[1]&&(k=!k);for(var A=k?-1:1,q=A*b,D=0,E=0;Ez)D=E+1;else{D=q<(R+z)/2?E:E+1;break}}var F=r._B[D]||0;return isFinite(F)?p(b,r._m2,F):0},x=function(b){var w=r._rangebreaks.length;if(!w)return y(b,r._m,r._b);for(var k=0,A=0;Ar._rangebreaks[A].pmax&&(k=A+1);return y(b,r._m2,r._B[k])}}r.c2l=r.type==="log"?i:Hi,r.l2c=r.type==="log"?Xf:Hi,r.l2p=g,r.p2l=x,r.c2p=r.type==="log"?function(b,w){return g(i(b,w))}:g,r.p2c=r.type==="log"?function(b){return Xf(x(b))}:x,["linear","-"].indexOf(r.type)!==-1?(r.d2r=r.r2d=r.d2c=r.r2c=r.d2l=r.r2l=Gl,r.c2d=r.c2r=r.l2d=r.l2r=Hi,r.d2p=r.r2p=function(b){return r.l2p(Gl(b))},r.p2d=r.p2r=x,r.cleanPos=Hi):r.type==="log"?(r.d2r=r.d2l=function(b,w){return i(Gl(b),w)},r.r2d=r.r2c=function(b){return Xf(Gl(b))},r.d2c=r.r2l=Gl,r.c2d=r.l2r=Hi,r.c2r=i,r.l2d=Xf,r.d2p=function(b,w){return r.l2p(r.d2r(b,w))},r.p2d=function(b){return Xf(x(b))},r.r2p=function(b){return r.l2p(Gl(b))},r.p2r=x,r.cleanPos=Hi):r.type==="date"?(r.d2r=r.r2d=pt.identity,r.d2c=r.r2c=r.d2l=r.r2l=l,r.c2d=r.c2r=r.l2d=r.l2r=o,r.d2p=r.r2p=function(b,w,k){return r.l2p(l(b,0,k))},r.p2d=r.p2r=function(b,w,k){return o(x(b),w,k)},r.cleanPos=function(b){return pt.cleanDate(b,yi,r.calendar)}):r.type==="category"?(r.d2c=r.d2l=u,r.r2d=r.c2d=r.l2d=s,r.d2r=r.d2l_noadd=h,r.r2c=function(b){var w=d(b);return w!==void 0?w:r.fraction2r(.5)},r.l2r=r.c2r=Hi,r.r2l=d,r.d2p=function(b){return r.l2p(r.r2c(b))},r.p2d=function(b){return s(x(b))},r.r2p=r.d2p,r.p2r=x,r.cleanPos=function(b){return typeof b=="string"&&b!==""?b:Hi(b)}):r.type==="multicategory"&&(r.r2d=r.c2d=r.l2d=s,r.d2r=r.d2l_noadd=h,r.r2c=function(b){var w=h(b);return w!==void 0?w:r.fraction2r(.5)},r.r2c_just_indices=c,r.l2r=r.c2r=Hi,r.r2l=h,r.d2p=function(b){return r.l2p(r.r2c(b))},r.p2d=function(b){return s(x(b))},r.r2p=r.d2p,r.p2r=x,r.cleanPos=function(b){return Array.isArray(b)||typeof b=="string"&&b!==""?b:Hi(b)},r.setupMultiCategory=function(b){var w=r._traceIndices,k,A,q=r._matchGroup;if(q&&r._categories.length===0){for(var D in q)if(D!==a){var E=t[X0.id2name(D)];w=w.concat(E._traceIndices)}}var R=[[0,{}],[0,{}]],z=[];for(k=0;kE[1]&&(A[D?0:1]=k),A[0]===A[1]){var R=r.l2r(w),z=r.l2r(k);if(w!==void 0){var F=R+1;k!==void 0&&(F=Math.min(F,z)),A[D?1:0]=F}if(k!==void 0){var H=z+1;w!==void 0&&(H=Math.max(H,R)),A[D?0:1]=H}}}},r.cleanRange=function(b,w){r._cleanRange(b,w),r.limitRange(b)},r._cleanRange=function(b,w){w||(w={}),b||(b="range");var k=pt.nestedProperty(r,b).get(),A,q;if(r.type==="date"?q=pt.dfltRange(r.calendar):n==="y"?q=Q0.DFLTRANGEY:r._name==="realaxis"?q=[0,1]:q=w.dfltRange||Q0.DFLTRANGEX,q=q.slice(),(r.rangemode==="tozero"||r.rangemode==="nonnegative")&&(q[0]=0),!k||k.length!==2){pt.nestedProperty(r,b).set(q);return}var D=k[0]===null,E=k[1]===null;for(r.type==="date"&&!r.autorange&&(k[0]=pt.cleanDate(k[0],yi,r.calendar),k[1]=pt.cleanDate(k[1],yi,r.calendar)),A=0;A<2;A++)if(r.type==="date"){if(!pt.isDateTime(k[A],r.calendar)){r[b]=q;break}if(r.r2l(k[0])===r.r2l(k[1])){var R=pt.constrain(r.r2l(k[0]),pt.MIN_MS+1e3,pt.MAX_MS-1e3);k[0]=r.l2r(R-1e3),k[1]=r.l2r(R+1e3);break}}else{if(!Ii(k[A]))if(!(D||E)&&Ii(k[1-A]))k[A]=k[1-A]*(A?10:.1);else{r[b]=q;break}if(k[A]<-V0?k[A]=-V0:k[A]>V0&&(k[A]=V0),k[0]===k[1]){var z=Math.max(1,Math.abs(k[0]*1e-6));k[0]-=z,k[1]+=z}}},r.setScale=function(b){var w=t._size;if(r.overlaying){var k=X0.getFromId({_fullLayout:t},r.overlaying);r.domain=k.domain}var A=b&&r._r?"_r":"range",q=r.calendar;r.cleanRange(A);var D=r.r2l(r[A][0],q),E=r.r2l(r[A][1],q),R=n==="y";if(R?(r._offset=w.t+(1-r.domain[1])*w.h,r._length=w.h*(r.domain[1]-r.domain[0]),r._m=r._length/(D-E),r._b=-r._m*E):(r._offset=w.l+r.domain[0]*w.w,r._length=w.w*(r.domain[1]-r.domain[0]),r._m=r._length/(E-D),r._b=-r._m*D),r._rangebreaks=[],r._lBreaks=0,r._m2=0,r._B=[],r.rangebreaks){var z,F;if(r._rangebreaks=r.locateBreaks(Math.min(D,E),Math.max(D,E)),r._rangebreaks.length){for(z=0;zE&&(H=!H),H&&r._rangebreaks.reverse();var W=H?-1:1;for(r._m2=W*r._length/(Math.abs(E-D)-r._lBreaks),r._B.push(-r._m2*(R?E:D)),z=0;zq&&(q+=7,Dq&&(q+=24,D =A&&D=A&&b=$.min&&(ae<$.min&&($.min=ae),fe>$.max&&($.max=fe),oe=!1)}oe&&E.push({min:ae,max:fe})}};for(k=0;k{"use strict";var R8=zr(),gy=ze(),fX=Ct().BADNUM,j0=gy.isArrayOrTypedArray,cX=gy.isDateTime,vX=gy.cleanNumber,z8=Math.round;F8.exports=function(r,t,a){var n=r,i=a.noMultiCategory;if(j0(n)&&!n.length)return"-";if(!i&&yX(n))return"multicategory";if(i&&Array.isArray(n[0])){for(var l=[],o=0;oi*2}function N8(e){return Math.max(1,(e-1)/1e3)}function mX(e,r){for(var t=e.length,a=N8(t),n=0,i=0,l={},o=0;on*2}function yX(e){return j0(e[0])&&j0(e[1])}});var Jf=G((rhe,V8)=>{"use strict";var gX=Rr(),O8=zr(),Wl=ze(),rh=Ct().FP_SAFE,bX=gr(),xX=Kr(),Y8=aa(),_X=Y8.getFromId,wX=Y8.isLinked;V8.exports={applyAutorangeOptions:G8,getAutoRange:by,makePadFn:xy,doAutoRange:MX,findExtremes:AX,concatExtremes:Ty};function by(e,r){var t,a,n=[],i=e._fullLayout,l=xy(i,r,0),o=xy(i,r,1),s=Ty(e,r),u=s.min,f=s.max;if(u.length===0||f.length===0)return Wl.simpleMap(r.range,r.r2l);var c=u[0].val,h=f[0].val;for(t=1;t0&&(E=M-l(k)-o(A),E>b?R/E>w&&(q=k,D=A,w=R/E):R/M>w&&(q={val:k.val,nopad:1},D={val:A.val,nopad:1},w=R/M));function z(Y,B){return Math.max(Y,o(B))}if(c===h){var F=c-1,H=c+1;if(x)if(c===0)n=[0,1];else{var W=(c>0?f:u).reduce(z,0),Z=c/(1-Math.min(.5,W/M));n=c>0?[0,Z]:[Z,0]}else _?n=[Math.max(0,F),Math.max(1,H)]:n=[F,H]}else x?(q.val>=0&&(q={val:0,nopad:1}),D.val<=0&&(D={val:0,nopad:1})):_&&(q.val-w*l(q)<0&&(q={val:0,nopad:1}),D.val<=0&&(D={val:1,nopad:1})),w=(D.val-q.val-I8(r,k.val,A.val))/(M-l(q)-o(D)),n=[q.val-w*l(q),D.val+w*o(D)];return n=G8(n,r),r.limitRange&&r.limitRange(),p&&n.reverse(),Wl.simpleMap(n,r.l2r||Number)}function I8(e,r,t){var a=0;if(e.rangebreaks)for(var n=e.locateBreaks(r,t),i=0;i0?t.ppadplus:t.ppadminus)||t.ppad||0),k=b((e._m>0?t.ppadminus:t.ppadplus)||t.ppad||0),A=b(t.vpadplus||t.vpad),q=b(t.vpadminus||t.vpad);if(!u){if(_=1/0,M=-1/0,s)for(c=0;c0&&(_=h),h>M&&h-rh&&(_=h),h>M&&h=R;c--)E(c);return{min:a,max:n,opts:t}}function _y(e,r,t,a){U8(e,r,t,a,kX)}function wy(e,r,t,a){U8(e,r,t,a,CX)}function U8(e,r,t,a,n){for(var i=a.tozero,l=a.extrapad,o=!0,s=0;s