diff --git a/.claude/docs/module-triage-project-setup.md b/.claude/docs/module-triage-project-setup.md new file mode 100644 index 0000000000..0bfff1ff0d --- /dev/null +++ b/.claude/docs/module-triage-project-setup.md @@ -0,0 +1,196 @@ +# Module Triage Project Setup Guide + +This guide explains how to set up a GitHub project to track and manage module requests as part of the automated triage system. + +## Project Structure + +### Recommended Project Setup + +1. **Create a new GitHub Project** in the MultiQC organization + - Name: "Module Request Triage" + - Description: "Automated tracking and prioritization of MultiQC module requests" + - Template: "Board" view + +2. **Configure Project Columns** + + | Column Name | Purpose | Automation | + | ------------------------- | ------------------------------------------ | --------------------------------------------- | + | **Needs Analysis** | New requests awaiting initial triage | Auto-add new issues with "module: new" label | + | **Low Priority** | Score < 40 | Auto-move when `priority: low` label added | + | **Medium Priority** | Score 40-69 | Auto-move when `priority: medium` label added | + | **High Priority** | Score ≥ 70 | Auto-move when `priority: high` label added | + | **Needs Examples** | Missing example files | Auto-move when `needs-examples` label added | + | **Ready for Development** | Complete requests ready for implementation | Manual move after maintainer review | + | **In Development** | Active module development | Manual move when work begins | + | **Complete** | Finished modules | Auto-move when issue closed | + +3. **Project Automation Rules** + + **Auto-add items:** + + ``` + When: Issue is opened + Filters: Label contains "module: new" + Then: Add to project in "Needs Analysis" + ``` + + **Auto-move by priority:** + + ``` + When: Label is added to item + Filters: Label is "priority: low" + Then: Move to "Low Priority" + ``` + + ``` + When: Label is added to item + Filters: Label is "priority: medium" + Then: Move to "Medium Priority" + ``` + + ``` + When: Label is added to item + Filters: Label is "priority: high" + Then: Move to "High Priority" + ``` + + **Auto-move for missing examples:** + + ``` + When: Label is added to item + Filters: Label is "needs-examples" + Then: Move to "Needs Examples" + ``` + + **Auto-archive completed:** + + ``` + When: Issue is closed + Filters: Label contains "module: new" + Then: Move to "Complete" + ``` + +## Custom Fields (Optional) + +Add these custom fields to track additional metadata: + +- **Priority Score** (Number): Automated score from triage system +- **Tool Popularity** (Number): GitHub stars count +- **Has Examples** (Boolean): Whether example files are provided +- **Last Analysis** (Date): When last triage analysis was performed + +## Labels for Triage System + +Ensure these labels exist in the repository: + +### Priority Labels + +- `priority: high` (Red) - Score ≥ 70 +- `priority: medium` (Yellow) - Score 40-69 +- `priority: low` (Gray) - Score < 40 + +### Status Labels + +- `needs-triage` (Purple) - Awaiting automated analysis +- `needs-examples` (Orange) - Missing example files +- `stale` (Gray) - No activity for 6+ months +- `ready-for-dev` (Green) - Complete and ready for implementation + +### Quality Labels + +- `good-first-module` (Light green) - Good for new contributors +- `complex-module` (Dark blue) - Requires advanced implementation +- `popular-tool` (Gold) - Tool has high GitHub stars + +## Workflow Integration + +The unified `module-requests.yml` workflow handles: + +1. **Individual Analysis**: Immediate analysis of new requests and `@claude analyze-module` commands +2. **Weekly Bulk Triage**: Comprehensive review of all open requests every Monday +3. **Project Synchronization**: Automatic issue tracking and board management +4. **Flexible Execution**: Manual triggers with different modes (analyze-single, triage-all, dry-run) + +## Manual Project Management + +### Maintainer Actions + +1. **Review High Priority** column weekly +2. **Move items to "Ready for Development"** after verification +3. **Assign items in "In Development"** when work begins +4. **Update custom fields** with additional context + +### Community Engagement + +- **Pin important issues** that need community input +- **Add milestone labels** for release planning +- **Use project discussions** for coordination + +## Analytics and Reporting + +The project provides visibility into: + +- **Request volume trends** (new requests per month) +- **Priority distribution** (high/medium/low breakdown) +- **Processing time** (time from request to development) +- **Success rate** (requests that become modules) + +## Integration with Automated Triage + +### Workflow Permissions + +The unified workflow includes all necessary permissions: + +```yaml +permissions: + issues: write # Add labels, post comments + projects: write # Project board management + contents: read # Repository access +``` + +### Environment Variables (if using organization-level project) + +```yaml +env: + PROJECT_ID: "PVT_kwDOAxxxxxxx" # Get from project URL + ORGANIZATION: "MultiQC" +``` + +## Troubleshooting + +### Common Issues + +1. **Items not auto-adding to project** + - Check automation rules are enabled + - Verify label filters match exactly + - Ensure workflow has `projects: write` permission + +2. **Cards not moving between columns** + - Verify automation rules for label-based moves + - Check that labels are being added correctly by workflows + - Manual moves may be needed initially + +3. **Missing priority labels** + - Run the unified workflow manually: `workflow_dispatch` with `triage-all` mode + - Check that `module-requests.yml` is running on new issues + +### Manual Sync + +If automation fails, manually add issues to the project: + +```bash +# Use GitHub CLI to bulk-add issues +gh project item-add PROJECT_NUMBER --url ISSUE_URL +``` + +## Benefits + +This project structure provides: + +- **Visual workflow** for module request lifecycle +- **Automated prioritization** based on objective criteria +- **Clear backlogs** for maintainers and contributors +- **Progress tracking** from request to implementation +- **Community transparency** into development priorities + +The project becomes the single source of truth for module request status and facilitates better coordination between maintainers and contributors. diff --git a/.claude/docs/module-triage-system.md b/.claude/docs/module-triage-system.md new file mode 100644 index 0000000000..6f6f2fe169 --- /dev/null +++ b/.claude/docs/module-triage-system.md @@ -0,0 +1,273 @@ +# MultiQC Module Request Triage System + +This guide explains how MultiQC manages and prioritizes module requests through an automated triage system. + +## Overview + +The triage system helps manage the growing number of module requests by automatically analyzing and prioritizing them based on objective criteria. This ensures the most valuable modules are developed first while giving contributors clear guidance on how to create successful requests. + +## How It Works + +### Automated Analysis + +When you submit a module request or comment `@claude analyze-module` on an existing request, the system automatically: + +1. **Evaluates tool popularity** via GitHub stars, package downloads, and community metrics +2. **Assesses request quality** based on completeness and example files +3. **Calculates a priority score** (0-100) using multiple weighted factors +4. **Assigns priority labels** and moves the issue through the project board +5. **Provides feedback** with specific recommendations for improvement + +### Priority Scoring + +Your request receives a score based on: + +| Category | Max Points | What's Evaluated | +| ------------------------- | ---------- | --------------------------------------------- | +| **Tool Popularity** | 25 | GitHub stars, forks, recent activity | +| **Package Downloads** | 15 | PyPI, Conda, or Bioconductor download metrics | +| **Community Engagement** | 35 | 👍 reactions, comments, duplicate requests | +| **Request Quality** | 20 | Complete information, example files | +| **Technical Feasibility** | 15 | Parseable output, clear metrics | + +**Priority Bands:** + +- 🔴 **High Priority** (70-100): Urgent, high-value module +- 🟡 **Medium Priority** (40-69): Valuable, queued for development +- 🟢 **Low Priority** (20-39): Consider if time permits +- ⚪ **Hold** (<20): Needs more information or community interest + +## Getting Your Request Prioritized + +### Quick Wins + +The fastest ways to increase your priority score: + +1. **Upload example files** (+8 points) - Most critical factor! + - Drag and drop actual files (don't copy/paste) + - Include typical tool output + - Use `.zip` for unsupported file types + +2. **Choose popular tools** (+25 points) + - Tools with >1000 GitHub stars get maximum points + - Active maintenance and recent releases help + +3. **Provide complete information** (+20 points) + - Tool name and homepage + - Clear description + - Expected visualizations + - Use case explanation + +4. **Generate community interest** (+35 points) + - Each 👍 reaction adds 1 point + - Meaningful comments add 2 points each + - Related requests add 5 points each + +### Interactive Help + +Get instant feedback on your request: + +``` +@claude analyze-module +``` + +This triggers a detailed analysis showing: + +- Current priority score breakdown +- Specific recommendations for improvement +- Comparison to similar requests +- Next steps to increase priority + +## Project Board Workflow + +Module requests flow through a structured project board: + +| Column | Description | How to Get Here | +| ------------------------- | ---------------------------- | ---------------------------------- | +| **Needs Analysis** | New requests awaiting triage | Automatic when issue created | +| **Needs Examples** | Missing example files | Any request without uploaded files | +| **Low Priority** | Score < 40 | Incomplete or niche requests | +| **Medium Priority** | Score 40-69 | Good candidates needing refinement | +| **High Priority** | Score ≥ 70 | Ready for development | +| **Ready for Development** | Maintainer-approved | Manual promotion after review | +| **In Development** | Active work | When implementation begins | +| **Complete** | Module merged | When PR is merged | + +## Automated Actions + +### New Request Analysis + +When you open a module request: + +- Instant analysis and priority scoring +- Automatic label assignment +- Project board placement +- Initial feedback comment + +### Weekly Bulk Triage + +Every Monday at 9 AM UTC: + +- All open module requests are re-analyzed +- Priority scores are updated +- Stale requests are identified +- Board positions are synchronized + +### On-Demand Analysis + +Comment `@claude analyze-module` anytime to: + +- Get current priority score +- Receive specific recommendations +- Understand blocking issues +- See comparative analysis + +## Request Quality Guidelines + +### What Makes a Great Request + +✅ **Do:** + +- Upload actual example files from the tool +- Provide the tool's GitHub/GitLab repository URL +- Explain why this module would be valuable +- Suggest which metrics should appear in general stats +- Describe expected visualizations + +❌ **Don't:** + +- Copy/paste file contents (formatting matters!) +- Request modules for unpopular or unmaintained tools +- Submit duplicate requests (search first!) +- Leave fields blank or incomplete + +### Example Files + +Example files are the most critical component: + +- **Quality over quantity**: 2-3 representative examples beat 20 minimal ones +- **Real data**: Use actual tool output, not synthetic examples +- **Varied cases**: Include both typical and edge cases if possible +- **Proper format**: Upload files, don't paste text (whitespace matters!) +- **Reasonable size**: Truncate large files but keep format clear + +## Understanding Analysis Results + +When your request is analyzed, you'll receive a comment like: + +```markdown +Thanks for requesting a new MultiQC module! This is an automated triage review to help prioritise development work. + +| Item | Details | +| ------------------ | -------------------------------------------- | +| **Tool** | FastQC | +| **Repository** | https://github.com/s-andrews/FastQC (⭐ 450) | +| **Priority Score** | 75/100 🔴 **High Priority** | + +
+ +### Score Breakdown + +| Category | Score | Notes | +| ------------------------ | ----- | ------------------------------- | +| 🌟 Tool Popularity | 20/25 | 450 stars, actively maintained | +| 📦 Package Downloads | 12/15 | 50K downloads/month on Bioconda | +| 💬 Community Engagement | 18/35 | 8 👍 reactions, 3 comments | +| ✅ Request Quality | 20/20 | Complete info, example files | +| ⚙️ Technical Feasibility | 5/15 | Complex output format | + +**Total: 75/100** + +### 🔴 High Priority: Ready for Development + +This is an excellent module request. The tool is popular, actively maintained, and the request is complete with example files. + +**What's great:** + +- ✨ Comprehensive example files provided +- ✨ Clear description of expected metrics +- ✨ Widely used tool in the community + +### Next Steps + +No action needed. Follow this issue for progress updates. + +--- + + + +This analysis was performed automatically. Comment `@claude analyze-module` for re-evaluation. +Learn more: [Module Triage System Guide](https://github.com/MultiQC/MultiQC/blob/main/.claude/docs/module-triage-system.md) + + + +
+``` + +## For Contributors + +### Improving Existing Requests + +If your request has low priority, you can improve it: + +1. **Add example files** if missing (biggest impact!) +2. **Explain use case** - why is this module valuable? +3. **Engage community** - share with colleagues who use the tool +4. **Provide context** - link to publications, workflows, or pipelines + +### Contributing Development + +High-priority requests are great for contributors: + +- Clear requirements and examples +- Community-vetted value +- Maintainer support available +- Good for portfolio building + +See [CONTRIBUTING.md](../../.github/CONTRIBUTING.md) for development guidelines. + +## For Maintainers + +### Manual Overrides + +Maintainers can override automated priority: + +1. **Manually add priority labels** to adjust board placement +2. **Move issues** to "Ready for Development" when verified +3. **Close stale requests** with kind explanation +4. **Pin important requests** for visibility + +### System Configuration + +The triage system is implemented through: + +- **Skills**: `.claude/skills/triaging-module-requests/` - Core triage logic +- **Workflows**: `.github/workflows/module-requests.yml` - Automation triggers +- **Commands**: `.claude/commands/new-module.md` - Module generation + +For project board setup, see [module-triage-project-setup.md](./module-triage-project-setup.md). + +## Benefits + +This system provides: + +- **Transparency**: Clear criteria for prioritization +- **Fairness**: Objective, consistent evaluation +- **Efficiency**: Automated processing of high volume +- **Guidance**: Actionable feedback for contributors +- **Tracking**: Visual workflow through project board + +## Feedback and Improvements + +The triage system is continuously evolving. If you have suggestions: + +- Open a discussion in the [Seqera Community Forum](https://community.seqera.io/c/multiqc/6) +- Comment on the [triage system tracking issue](https://github.com/MultiQC/MultiQC/issues/3219) +- Propose changes via pull request + +## Related Resources + +- [Module Creation Guide](../../.claude/skills/creating-multiqc-modules/SKILL.md) +- [Contributing Guidelines](../../.github/CONTRIBUTING.md) +- [Project Board Setup](./module-triage-project-setup.md) +- [Example Module Requests](https://github.com/MultiQC/MultiQC/issues?q=label%3A%22module%3A+new%22+label%3A%22priority%3A+high%22) diff --git a/.claude/skills/implementing-new-modules/SKILL.md b/.claude/skills/implementing-new-modules/SKILL.md new file mode 100644 index 0000000000..6f5bdecede --- /dev/null +++ b/.claude/skills/implementing-new-modules/SKILL.md @@ -0,0 +1,171 @@ +# Implement New MultiQC Module + +## Description + +Create a new MultiQC module from a GitHub issue or feature request. This skill guides you through the complete process of implementing, testing, and submitting a new module for parsing bioinformatics tool output. + +**Use this skill when:** + +- Implementing a module from a `module: new` issue +- User asks to create a module for a specific tool +- Adding support for a new bioinformatics tool output format + +## Prerequisites + +Before starting implementation: + +1. **Understand the tool output format** - Get sample output files or documentation +2. **Check test-data repository** - Look for existing test data in `MultiQC/test-data` +3. **Review similar modules** - Find modules for similar tools as reference + +## Quick Start + +1. **Research the tool** - Understand output format, check for subtools +2. **Create module structure** - Follow multi-subtool pattern if applicable +3. **Implement parser** - Parse output and extract metrics +4. **Add visualizations** - Tables, bar graphs, line plots as appropriate +5. **Register module** - Add entry point and search patterns +6. **Write tests** - Unit tests for parser, integration tests +7. **Run checks** - Linting, type checking, tests +8. **Submit PR** - With clear description and test data reference + +## Module Architecture Decision + +### Single-tool modules + +For tools with one output format (e.g., FastQC, Qualimap): + +``` +multiqc/modules/toolname/ +├── __init__.py +└── toolname.py # MultiqcModule class with all logic +``` + +### Multi-subtool modules + +For tools with multiple subcommands (e.g., samtools, seqkit, picard): + +``` +multiqc/modules/toolname/ +├── __init__.py # Exports MultiqcModule +├── toolname.py # Orchestrator calling submodules +├── subtool1.py # parse_toolname_subtool1() function +├── subtool2.py # parse_toolname_subtool2() function +└── tests/ + ├── __init__.py + └── test_subtool1.py +``` + +**Use multi-subtool pattern when:** + +- Tool has distinct subcommands (e.g., `samtools stats`, `samtools flagstat`) +- Each subcommand has different output format +- Future subcommands are likely to be added + +## Implementation Checklist + +See [implementation-checklist.md](implementation-checklist.md) for detailed steps. + +### Essential Steps + +- [ ] Create module directory structure +- [ ] Implement parser function(s) +- [ ] Add to `search_patterns.yaml` +- [ ] Add entry point in `pyproject.toml` +- [ ] Call `self.add_data_source()` +- [ ] Call `self.add_software_version()` +- [ ] Call `self.write_data_file()` at end +- [ ] Raise `ModuleNoSamplesFound` when no data found +- [ ] Add general stats columns if needed +- [ ] Create detailed section(s) with table/plots +- [ ] Write unit tests + +### Quality Checks + +- [ ] `multiqc -f --strict path/to/test/data/` +- [ ] `pre-commit run` +- [ ] `ruff check multiqc/modules/yourmodule/` +- [ ] `python .github/workflows/code_checks.py` +- [ ] `pytest multiqc/modules/yourmodule/tests/ -v` +- [ ] `pytest tests/test_modules_run.py -k "yourmodule" -v` + +## Code Patterns + +See [code-patterns.md](code-patterns.md) for common patterns including: + +- File discovery and parsing +- Sample name handling +- General stats headers +- Table and plot creation +- Multi-format parsing (tab/space-separated) + +## File Registration + +### search_patterns.yaml + +If the tool has a standardised output filename, search by this as it's fastest: + +```yaml +toolname/subtool: + fn: "*_custom_extension.txt" +``` + +If this is not possible, search the file contents. +Use `contents` with a single string if possible, or the more expensive `contents_re` +if a regular expression is required. + +Specify `num_lines` when searching by content to cap the amount of lines searched in the file. +If the tool saves stdout, add 3 to the number required, in case of the system prepending to the file. + +```yaml +toolname/subtool: + contents_re: "^header_pattern\\s+column1\\s+column2" + num_lines: 1 +``` + +### pyproject.toml + +```toml +[project.entry-points."multiqc.modules.v1"] +toolname = "multiqc.modules.toolname:MultiqcModule" +``` + +## Testing Strategy + +1. **Unit tests** - Test parser functions directly with sample data +2. **Integration tests** - Ensure module runs with test-data repository +3. **Edge cases** - Empty files, malformed input, stdin, Windows paths + +## Common Pitfalls + +1. **Forgetting `add_software_version()`** - Required by linting, even if version is None +2. **Calling `write_data_file()` too early** - Must be at end after all sections added +3. **Raising `UserWarning` instead of `ModuleNoSamplesFound`** +4. **Not handling both tab and space-separated output** +5. **Hardcoding values instead of using dynamic variables such as `f["s_name"]`** +6. **Manually cleaning sample names instead of using core functions like `self.clean_s_name()`** +7. **Using colour scales inappropriately, eg. RdYlGn scale for non-quality metrics** (GC% is not "higher is better") + +## PR Submission + +1. Reference the original issue: `Closes #XXXX` +2. Describe the tool and what metrics are captured +3. Note any configuration options added +4. Reference test data location in MultiQC/test-data +5. Include sample report screenshot if possible + +Give the PR body a brief description, then add a detailed description of the work +done within a
tag. + +## Files in This Skill + +- `SKILL.md` (this file): Overview and workflow +- `implementation-checklist.md`: Detailed step-by-step checklist +- `code-patterns.md`: Common code patterns and examples +- `module-structure.md`: Directory and file structure templates + +## Related Documentation + +- [CLAUDE.md](../../../CLAUDE.md): Repository-specific instructions +- [Contributing Guide](../../../CONTRIBUTING.md): General contribution guidelines +- [Module Development Docs](https://docs.seqera.io/multiqc/development/modules): Official documentation diff --git a/.claude/skills/implementing-new-modules/code-patterns.md b/.claude/skills/implementing-new-modules/code-patterns.md new file mode 100644 index 0000000000..8e658d2661 --- /dev/null +++ b/.claude/skills/implementing-new-modules/code-patterns.md @@ -0,0 +1,322 @@ +# Code Patterns for MultiQC Modules + +## Module Class Structure + +### Single-Tool Module + +```python +"""MultiQC module to parse output from ToolName""" + +import logging +from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound + +log = logging.getLogger(__name__) + + +class MultiqcModule(BaseMultiqcModule): + """ + ToolName provides [description]. + + The module parses output from `toolname command` which produces + [description of output]. + """ + + def __init__(self): + super(MultiqcModule, self).__init__( + name="ToolName", + anchor="toolname", + href="https://example.com/toolname", + info="Brief description of what the tool does.", + doi="10.xxxx/xxxxx", + ) + + # Parse data + self.toolname_data = {} + for f in self.find_log_files("toolname"): + parsed = self.parse_log(f) + # ... process parsed data + + # Filter ignored samples + self.toolname_data = self.ignore_samples(self.toolname_data) + + if len(self.toolname_data) == 0: + raise ModuleNoSamplesFound + + # Add sections and stats + self.add_general_stats() + self.add_sections() + + # Write data file (MUST be at the end) + self.write_data_file(self.toolname_data, "multiqc_toolname") +``` + +### Multi-Subtool Module Orchestrator + +```python +"""MultiQC module to parse output from ToolName""" + +import logging +from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound + +from .subtool1 import parse_toolname_subtool1 +from .subtool2 import parse_toolname_subtool2 + +log = logging.getLogger(__name__) + + +class MultiqcModule(BaseMultiqcModule): + """ + Supported commands: + + - `subtool1` + - `subtool2` + """ + + def __init__(self): + super(MultiqcModule, self).__init__( + name="ToolName", + anchor="toolname", + href="https://example.com/toolname", + info="Brief description of what the tool does.", + doi="10.xxxx/xxxxx", + ) + + n = dict() + + n["subtool1"] = parse_toolname_subtool1(self) + if n["subtool1"] > 0: + log.info(f"Found {n['subtool1']} subtool1 reports") + + n["subtool2"] = parse_toolname_subtool2(self) + if n["subtool2"] > 0: + log.info(f"Found {n['subtool2']} subtool2 reports") + + if sum(n.values()) == 0: + raise ModuleNoSamplesFound +``` + +### Submodule Parser Function + +```python +"""MultiQC submodule to parse output from toolname subtool""" + +import logging +from typing import Dict + +from multiqc import BaseMultiqcModule, config +from multiqc.plots import table, bargraph + +log = logging.getLogger(__name__) + + +def parse_toolname_subtool(module: BaseMultiqcModule) -> int: + """Find toolname subtool logs and parse their data""" + + data: Dict[str, Dict] = {} + + for f in module.find_log_files("toolname/subtool"): + parsed = parse_report() + for s_name, sample_data in parsed.items(): + s_name = module.clean_s_name(s_name, f) # Only needed if not using f['s_name'] + if s_name in data: + log.debug(f"Duplicate sample name found! Overwriting: {s_name}") + module.add_data_source(f, s_name=s_name, section="subtool") + data[s_name] = sample_data + + # Required call - even if version is None + module.add_software_version(None, s_name) + + # Filter ignored samples + data = module.ignore_samples(data) + + if len(data) == 0: + return 0 + + # Add general stats + add_general_stats(module, data) + + # Add detailed section + add_section(module, data) + + # Write data file + module.write_data_file(data, "multiqc_toolname_subtool") + + return len(data) +``` + +## Parsing Patterns + +### Key-Value Pair Parsing + +```python +def parse_key_value_report(file_content: str) -> Dict: + """Parse key: value format output.""" + data = {} + for line in file_content.strip().split("\n"): + if ":" in line: + key, value = line.split(":", 1) + key = key.strip().lower().replace(" ", "_") + value = value.strip() + try: + data[key] = float(value.replace(",", "").rstrip("%")) + except ValueError: + data[key] = value + return data +``` + +### JSON Parsing + +```python +import json + +def parse_json_report(file_content: str) -> Dict: + """Parse JSON format output.""" + try: + return json.loads(file_content) + except json.JSONDecodeError: + return {} +``` + +## General Stats Patterns + +### Standard Headers Definition + +```python +general_stats_headers = { + "read_count": { + "title": "# Reads", + "description": f"Total read count ({config.read_count_desc})", + "scale": "Blues", + "shared_key": "read_count", + }, + "base_count": { + "title": "Total bp", + "description": f"Total bases ({config.base_count_desc})", + "scale": "Greens", + "shared_key": "base_count", + "hidden": True, + }, + "percentage_metric": { + "title": "Metric%", + "description": "Description of metric", + "min": 0, + "max": 100, + "scale": "RdYlGn", # Use for quality (higher=better) + "suffix": "%", + }, + "gc_content": { + "title": "GC%", + "description": "GC content percentage", + "min": 0, + "max": 100, + "scale": "RdYlBu", # Use for middle-is-best metrics + "suffix": "%", + }, +} + +# Get headers with config integration +headers = module.get_general_stats_headers(all_headers=general_stats_headers) + +# Add to general stats table +if headers: + module.general_stats_addcols(data, headers, namespace="toolname") +``` + +## Visualization Patterns + +### Table Plot + +```python +from multiqc.plots import table + +table_headers = { + "column1": { + "title": "Column 1", + "description": "Description", + "format": "{:,.0f}", # Only needed for integers + "scale": "Blues", + }, + # ... more columns + # Headers without data, or data without headers, will be ignored - no conditionals required +} + +module.add_section( + name="Detailed Stats", + anchor="toolname-stats", + description="Detailed statistics from toolname.", + plot=table.plot( + data, + table_headers, + pconfig={ + "id": "toolname-stats-table", + "title": "ToolName: Statistics", + "namespace": "toolname", + }, + ), +) +``` + +### Bar Graph + +```python +from multiqc.plots import bargraph + +# Only needed if data needs to be reshaped. +bargraph_data = { + s_name: { + "Category1": d.get("count1", 0), + "Category2": d.get("count2", 0), + } + for s_name, d in data.items() +} + +module.add_section( + name="Counts", + anchor="toolname-counts", + description="Count data from toolname.", + plot=bargraph.plot( + bargraph_data, + pconfig={ + "id": "toolname-counts-plot", + "title": "ToolName: Counts", + "ylab": "Count", + "cpswitch": False, # Disable count/percentage switch + }, + ), +) +``` + +### Line Graph + +```python +from multiqc.plots import linegraph + +# Data format: {sample: {x1: y1, x2: y2, ...}} +line_data = { + s_name: {pos: val for pos, val in enumerate(d.get("values", []))} + for s_name, d in data.items() +} + +module.add_section( + name="Distribution", + anchor="toolname-dist", + description="Value distribution.", + plot=linegraph.plot( + line_data, + pconfig={ + "id": "toolname-dist-plot", + "title": "ToolName: Distribution", + "xlab": "Position", + "ylab": "Value", + }, + ), +) +``` + +## **init**.py Pattern + +```python +from .toolname import MultiqcModule + +__all__ = ["MultiqcModule"] +``` diff --git a/.claude/skills/implementing-new-modules/implementation-checklist.md b/.claude/skills/implementing-new-modules/implementation-checklist.md new file mode 100644 index 0000000000..b4cab59f97 --- /dev/null +++ b/.claude/skills/implementing-new-modules/implementation-checklist.md @@ -0,0 +1,262 @@ +# Implementation Checklist + +## Phase 1: Research and Planning + +### Understand the Tool + +- [ ] Read tool documentation for output format specification +- [ ] Identify all output files the tool produces +- [ ] Determine if tool has multiple subcommands with different outputs +- [ ] Note any version-specific output format differences + +### Gather Test Data + +- [ ] Check `MultiQC/test-data/data/modules/` for existing test files +- [ ] If not present, obtain sample output files +- [ ] Ensure test data covers: + - [ ] Standard output format + - [ ] Output with all optional columns (e.g., `--all` flag) + - [ ] Edge cases (empty, minimal data) + +### Choose Architecture + +- [ ] Single-tool module (one output format) +- [ ] Multi-subtool module (multiple subcommands) +- [ ] Reference similar existing module for patterns + +## Phase 2: Module Structure + +### Create Directory Structure + +```bash +mkdir -p multiqc/modules/toolname/tests +touch multiqc/modules/toolname/__init__.py +touch multiqc/modules/toolname/toolname.py +touch multiqc/modules/toolname/tests/__init__.py +``` + +### For Multi-subtool Modules + +```bash +touch multiqc/modules/toolname/subtool.py +touch multiqc/modules/toolname/tests/test_subtool.py +``` + +## Phase 3: Parser Implementation + +### Main Module Class (`toolname.py`) + +- [ ] Import `BaseMultiqcModule` and `ModuleNoSamplesFound` +- [ ] Create `MultiqcModule` class with docstring describing supported commands +- [ ] Call `super().__init__()` with: + - [ ] `name` - Display name + - [ ] `anchor` - URL anchor (lowercase, no spaces) + - [ ] `href` - Tool homepage URL + - [ ] `info` - Brief description (starts with capital letter) + - [ ] `doi` - Publication DOI if available + +### Submodule Parser (for multi-subtool) + +- [ ] Create `parse_toolname_subtool(module)` function +- [ ] Return count of samples found (not 0 if successful) + +### Core Parser Logic + +- [ ] Use `module.find_log_files("toolname/subtool")` to discover files +- [ ] Handle both tab-separated and space-separated formats if applicable +- [ ] Use `dict(zip(headers, values))` for clean parsing +- [ ] Convert numeric values appropriately (int vs float) +- [ ] Rename problematic column names (e.g., `Q20(%)` → `Q20_pct`) + +### Sample Name Handling + +- [ ] Use `module.clean_s_name(s_name, f)` for cleanup if not using `f["s_name"]` (ie. coming from file contents) +- [ ] Add to `fn_clean_exts` in `config_defaults.yaml` if tool has a standard extension that needs removing + +### Required Calls + +- [ ] `module.add_data_source(f, s_name=s_name, section="subtool")` +- [ ] `module.add_software_version(version, s_name)` (even if version is None) +- [ ] `module.ignore_samples(data_dict)` to filter ignored samples +- [ ] `module.write_data_file(data, "multiqc_toolname_subtool")` at the END + +### Error Handling + +- [ ] Raise `ModuleNoSamplesFound` when no samples found (NOT `UserWarning`) +- [ ] Use `log.debug()` for skipped files +- [ ] Handle malformed input gracefully, with `log.debug()` messages + +## Phase 4: Visualizations + +### General Stats Table + +- [ ] Define headers dict with appropriate keys +- [ ] Ensure number of headers is apprpriate (not too many) +- [ ] Include for each column: + - [ ] `title` - Short display title + - [ ] `description` - Tooltip text (use `config.read_count_desc` etc. where appropriate) + - [ ] `scale` - Color scale name + - [ ] `shared_key` - For related columns (e.g., `"read_count"`, `"base_count"`) + - [ ] `hidden` - True for less important columns + - [ ] `min`/`max` - For percentage columns + - [ ] `suffix` - Units (e.g., `"%"`, `" bp"`) +- [ ] Check that only keys with non-default values are included +- [ ] Use `module.get_general_stats_headers()` for config integration +- [ ] Call `module.general_stats_addcols(data, headers, namespace="toolname")` + +### Detailed Section + +- [ ] Add table with `table.plot()` for detailed metrics +- [ ] Add bar graphs with `bargraph.plot()` for counts/lengths +- [ ] Add line plots if applicable, or heatmaps, violin, box, scatter plots +- [ ] Use `module.add_section()` with: + - [ ] `name` - Section title + - [ ] `anchor` - Unique anchor ID + - [ ] `description` - Section description + - [ ] `plot` - Plot object + +### Color Scale Guidelines + +Use scales from ColorBrewer: + +- [ ] Sequential + - [ ] OrRd, PuBu, BuPu, Oranges, BuGn, YlOrBr, YlGn, Reds, RdPu, Greens, YlGnBu, Purples, GnBu, Greys, YlOrRd, PuRd, Blues, PuBuGn +- [ ] Diverging + - [ ] Spectral, RdYlGn, RdBu, PiYG, PRGn, RdYlBu, BrBG, RdGy, PuOr +- [ ] Qualitative + - [ ] Set2, Accent, Set1, Set3, Dark2, Paired, Pastel2, Pastel1 + +Use with semantic meaning. For example: + +- [ ] `RdYlGn` - For quality metrics (higher is better) +- [ ] `Blues`, `Greens`, `Purples`, `Oranges` - For neutral counts +- [ ] `RdYlBu` - For metrics where middle is best (e.g., GC%) +- [ ] `Reds`, `OrRd` - For error/warning counts + +## Phase 5: Registration + +### search_patterns.yaml + +```yaml +toolname/subtool: + contents_re: "^expected_header_pattern" + num_lines: 1 +``` + +- [ ] Use `fn` file filename matching where possible +- [ ] Use `contents` for exact match or `contents_re` for regex +- [ ] Set appropriate `num_lines` to check +- [ ] Place alphabetically in file + +### pyproject.toml + +```toml +toolname = "multiqc.modules.toolname:MultiqcModule" +``` + +- [ ] Add entry point alphabetically in `[project.entry-points."multiqc.modules.v1"]` + +### **init**.py + +```python +from .toolname import MultiqcModule + +__all__ = ["MultiqcModule"] +``` + +## Phase 6: Testing + +### Running MultiQC + +- [ ] Run MultiQC on the test data provided, from the MultiQC/test-data repository +- [ ] Use the `--strict` flag in the MultiQC command to find internal linting errors + +### Unit Tests + +- [ ] Create test file with sample data as string constants +- [ ] Test parser with full output format +- [ ] Test parser with minimal output format +- [ ] Test edge cases: + - [ ] Empty file (header only) + - [ ] Invalid format + - [ ] Windows paths + - [ ] Stdin input + - [ ] Fallback sample name + +### Integration Tests + +- [ ] Ensure test data exists in `test-data` repository +- [ ] Run: `pytest tests/test_modules_run.py -k "toolname" -v` +- [ ] Verify both `test_all_modules` and `test_ignore_samples` pass + +## Phase 7: Quality Checks + +### Pre-commit + +```bash +pre-commit run --files multiqc/modules/toolname/* +``` + +### Linting + +```bash +ruff check multiqc/modules/toolname/ +``` + +### Custom Checks + +```bash +python .github/workflows/code_checks.py +``` + +Verifies: + +- [ ] `add_data_source` is called +- [ ] `write_data_file` is called +- [ ] `doi=` is present in module init +- [ ] `add_software_version` is called + +### Type Checking (if configured) + +```bash +mypy multiqc/modules/toolname/ +``` + +## Phase 8: Documentation + +### Module Docstring + +- [ ] Add comprehensive docstring to `MultiqcModule` class +- [ ] List all supported subcommands +- [ ] Document any configuration options +- [ ] Include example command to generate compatible output + +### No Separate Markdown Files + +- Do NOT create separate `.md` documentation files +- All documentation goes in the module class docstring + +## Phase 9: Commit and PR + +### Commit Message Format + +``` +Add [ToolName] module for [description] + +Implements a new MultiQC module to parse output from [tool command]. +[Tool description and purpose]. + +Features: +- [Feature 1] +- [Feature 2] + +Closes #XXXX +``` + +### PR Description + +- [ ] Reference original issue +- [ ] Describe metrics captured +- [ ] Note configuration options if any +- [ ] Reference test data location +- [ ] Include sample report screenshot if possible diff --git a/.claude/skills/implementing-new-modules/module-structure.md b/.claude/skills/implementing-new-modules/module-structure.md new file mode 100644 index 0000000000..d7635cddc6 --- /dev/null +++ b/.claude/skills/implementing-new-modules/module-structure.md @@ -0,0 +1,383 @@ +# Module Structure Templates + +## Single-Tool Module + +### Directory Structure + +``` +multiqc/modules/toolname/ +├── __init__.py +├── toolname.py +└── tests/ + ├── __init__.py + └── test_toolname.py +``` + +### **init**.py + +```python +from .toolname import MultiqcModule + +__all__ = ["MultiqcModule"] +``` + +### toolname.py Template + +```python +"""MultiQC module to parse output from ToolName""" + +import logging +from typing import Dict + +from multiqc import config +from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound +from multiqc.plots import table, bargraph + +log = logging.getLogger(__name__) + + +class MultiqcModule(BaseMultiqcModule): + """ + ToolName is a tool for [description]. + + The module parses [output type] from `toolname [command]`. + + Supported output formats: + - Format 1 + - Format 2 (with `--option`) + """ + + def __init__(self): + super(MultiqcModule, self).__init__( + name="ToolName", + anchor="toolname", + href="https://example.com/toolname", + info="Brief description starting with capital letter.", + doi="10.xxxx/journal.xxxxx", + ) + + self.toolname_data: Dict[str, Dict] = {} + + for f in self.find_log_files("toolname"): + parsed = self._parse_log(f) + for s_name, data in parsed.items(): + s_name = self.clean_s_name(s_name, f) # If needed + if s_name in self.toolname_data: + log.debug(f"Duplicate sample name found! Overwriting: {s_name}") + self.add_data_source(f, s_name=s_name) + self.toolname_data[s_name] = data + self.add_software_version(None, s_name) + + self.toolname_data = self.ignore_samples(self.toolname_data) + + if len(self.toolname_data) == 0: + raise ModuleNoSamplesFound + + self._add_general_stats() + self._add_stats_table() + + self.write_data_file(self.toolname_data, "multiqc_toolname") + + def _parse_log(self, f): + """Parse toolname output file.""" + # Implementation here + pass + + def _add_general_stats(self): + """Add columns to general stats table.""" + headers = { + "metric1": { + "title": "Metric 1", + "description": "Description", + "scale": "Blues", + }, + } + headers = self.get_general_stats_headers(all_headers=headers) + if headers: + self.general_stats_addcols(self.toolname_data, headers) + + def _add_stats_table(self): + """Add detailed stats table section.""" + headers = { + "metric1": {"title": "Metric 1", "description": "Description"}, + } + self.add_section( + name="Statistics", + anchor="toolname-stats", + description="Statistics from toolname.", + plot=table.plot( + self.toolname_data, + headers, + pconfig={"id": "toolname-table", "title": "ToolName: Stats"}, + ), + ) +``` + +--- + +## Multi-Subtool Module + +### Directory Structure + +``` +multiqc/modules/toolname/ +├── __init__.py +├── toolname.py # Orchestrator +├── subtool1.py # First subtool parser +├── subtool2.py # Second subtool parser +└── tests/ + ├── __init__.py + ├── test_subtool1.py + └── test_subtool2.py +``` + +### **init**.py + +```python +from .toolname import MultiqcModule + +__all__ = ["MultiqcModule"] +``` + +### toolname.py (Orchestrator) + +```python +"""MultiQC module to parse output from ToolName""" + +import logging + +from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound + +from .subtool1 import parse_toolname_subtool1 +from .subtool2 import parse_toolname_subtool2 + +log = logging.getLogger(__name__) + + +class MultiqcModule(BaseMultiqcModule): + """ + ToolName is a toolkit for [description]. + + Supported commands: + + - `subtool1` + - `subtool2` + + #### subtool1 + + Description of subtool1 and how to generate output. + + #### subtool2 + + Description of subtool2 and how to generate output. + """ + + def __init__(self): + super(MultiqcModule, self).__init__( + name="ToolName", + anchor="toolname", + href="https://example.com/toolname", + info="Toolkit for [description].", + doi="10.xxxx/journal.xxxxx", + ) + + n = dict() + + n["subtool1"] = parse_toolname_subtool1(self) + if n["subtool1"] > 0: + log.info(f"Found {n['subtool1']} subtool1 reports") + + n["subtool2"] = parse_toolname_subtool2(self) + if n["subtool2"] > 0: + log.info(f"Found {n['subtool2']} subtool2 reports") + + if sum(n.values()) == 0: + raise ModuleNoSamplesFound +``` + +### subtool1.py (Submodule) + +```python +"""MultiQC submodule to parse output from toolname subtool1""" + +import logging +from typing import Dict, Optional + +from multiqc import BaseMultiqcModule, config +from multiqc.plots import table, bargraph + +log = logging.getLogger(__name__) + + +def parse_toolname_subtool1(module: BaseMultiqcModule) -> int: + """Find toolname subtool1 logs and parse their data""" + + data: Dict[str, Dict] = {} + + for f in module.find_log_files("toolname/subtool1"): + parsed = parse_report(f["f"], f["s_name"]) + for s_name, sample_data in parsed.items(): + s_name = module.clean_s_name(s_name, f) + if s_name in data: + log.debug(f"Duplicate sample name found! Overwriting: {s_name}") + module.add_data_source(f, s_name=s_name, section="subtool1") + data[s_name] = sample_data + module.add_software_version(None, s_name) + + data = module.ignore_samples(data) + + if len(data) == 0: + return 0 + + # Add general stats + headers = get_general_stats_headers() + stats_headers = module.get_general_stats_headers(all_headers=headers) + if stats_headers: + module.general_stats_addcols(data, stats_headers, namespace="subtool1") + + # Add section + add_section(module, data) + + # Write data + module.write_data_file(data, "multiqc_toolname_subtool1") + + return len(data) + + +def parse_report(file_content: str, fallback_name: Optional[str] = None) -> Dict[str, Dict]: + """Parse subtool1 output file.""" + parsed_data: Dict[str, Dict] = {} + # Implementation here + return parsed_data + + +def get_general_stats_headers() -> Dict: + """Return general stats header definitions.""" + return { + "metric1": { + "title": "Metric 1", + "description": "Description", + "scale": "Blues", + }, + } + + +def add_section(module: BaseMultiqcModule, data: Dict[str, Dict]): + """Add detailed section to report.""" + headers = { + "metric1": {"title": "Metric 1", "description": "Description"}, + } + module.add_section( + name="Subtool1", + anchor="toolname-subtool1", + description="Output from toolname subtool1.", + plot=table.plot( + data, + headers, + pconfig={ + "id": "toolname-subtool1-table", + "title": "ToolName: Subtool1", + "namespace": "toolname", + }, + ), + ) +``` + +--- + +## Test File Template + +### tests/test_subtool.py + +```python +"""Tests for the toolname subtool module""" + +import pytest + +from multiqc.modules.toolname.subtool import parse_report + + +# Sample output with all columns +SAMPLE_FULL = """header1\theader2\theader3 +value1\tvalue2\tvalue3 +""" + +# Sample with minimal columns +SAMPLE_MINIMAL = """header1\theader2\theader3 +value1\tvalue2\tvalue3 +""" + +# Empty file +SAMPLE_EMPTY = """header1\theader2\theader3 +""" + +# Invalid format +SAMPLE_INVALID = """wrong\theaders +data\there +""" + + +class TestParseReport: + """Tests for parse_report function""" + + def test_parse_full_output(self): + """Test parsing output with all columns""" + result = parse_report(SAMPLE_FULL) + assert len(result) == 1 + # Add specific assertions + + def test_parse_minimal_output(self): + """Test parsing minimal output""" + result = parse_report(SAMPLE_MINIMAL) + assert len(result) == 1 + + def test_parse_empty_file(self): + """Test parsing empty file returns empty dict""" + result = parse_report(SAMPLE_EMPTY) + assert result == {} + + def test_parse_invalid_format(self): + """Test parsing invalid format returns empty dict""" + result = parse_report(SAMPLE_INVALID) + assert result == {} + + def test_fallback_sample_name(self): + """Test fallback sample name for stdin input""" + stdin_data = """header1\theader2\theader3 +-\tvalue2\tvalue3 +""" + result = parse_report(stdin_data, fallback_sample_name="my_sample") + assert "my_sample" in result +``` + +--- + +## Registration Files + +### search_patterns.yaml Entry + +```yaml +# For single-tool module +toolname: + contents: "Expected header or unique string" + num_lines: 1 + +# For multi-subtool module +toolname/subtool1: + contents_re: "^header1\\s+header2\\s+header3" + num_lines: 1 + +toolname/subtool2: + fn: "*subtool2*.txt" +``` + +### pyproject.toml Entry + +```toml +[project.entry-points."multiqc.modules.v1"] +# ... other modules ... +toolname = "multiqc.modules.toolname:MultiqcModule" +# ... other modules ... +``` + +Note: Entry points must be in alphabetical order. diff --git a/.claude/skills/triaging-module-requests/SKILL.md b/.claude/skills/triaging-module-requests/SKILL.md new file mode 100644 index 0000000000..ead237a088 --- /dev/null +++ b/.claude/skills/triaging-module-requests/SKILL.md @@ -0,0 +1,125 @@ +# Triage MultiQC Module Requests + +## Description + +Analyze and prioritize MultiQC module requests through automated triage. Calculate priority scores, assign labels, update project boards, and provide actionable feedback to contributors. + +**Use this skill when:** + +- A new module request issue is opened (labeled `module: new`) +- User comments `@claude analyze-module` on a module request +- Weekly bulk triage is scheduled +- Manual analysis is requested via workflow dispatch + +## Operation Modes + +### analyze-single + +Analyze one specific module request issue. Requires issue number. + +**When to use:** New issues, on-demand analysis requests + +### triage-all + +Analyze all open module requests labeled `module: new`. + +**When to use:** Weekly batch processing, cleanup operations + +### dry-run + +Perform analysis without making any GitHub changes (no labels, comments, or board updates). + +**When to use:** Testing, validation, debugging + +## Quick Start + +1. **Determine the mode** from the workflow context or user request +2. **Fetch issue data** using GitHub CLI (`gh issue view` or `gh issue list`) +3. **Calculate priority score** using [scoring-criteria.md](scoring-criteria.md) +4. **Perform GitHub actions** following [github-actions.md](github-actions.md) +5. **Post analysis** using templates from [analysis-templates.md](analysis-templates.md) + +## Priority Score Overview + +Score is 0-100 based on five weighted categories: + +- **Tool Popularity** (25 pts): GitHub metrics +- **Package Downloads** (15 pts): PyPI/Conda/Bioconda downloads +- **Community Engagement** (35 pts): Reactions, comments, duplicates +- **Request Quality** (20 pts): Completeness, example files +- **Technical Feasibility** (15 pts): Output parseability, metrics clarity + +**Priority Bands:** + +- 🔴 **High** (≥70): `priority: high` label +- 🟡 **Medium** (40-69): `priority: medium` label +- 🟢 **Low** (20-39): `priority: low` label +- ⚪ **Hold** (<20): `needs-triage` label only + +See [scoring-criteria.md](scoring-criteria.md) for detailed rubric. + +## Workflow Integration + +This skill is invoked by `.github/workflows/module-requests.yml` which: + +- Triggers on new issues with `module: new` label +- Responds to `@claude analyze-module` comments +- Runs weekly bulk triage (Mondays at 9 AM UTC) +- Supports manual workflow dispatch + +## GitHub Operations + +Key operations (see [github-actions.md](github-actions.md) for details): + +- Fetch issue metadata and body content +- Extract tool information (name, URL, description) +- Add/update priority labels +- Post analysis comments +- Update project board positions (if configured) + +## Tool Metrics Collection + +Use `scripts/fetch-tool-metrics.js` for reliable API calls: + +- GitHub stars, forks, last commit date +- PyPI download statistics +- Bioconda package data +- Repository activity metrics + +## Analysis Output + +Generate clear, actionable feedback using templates from [analysis-templates.md](analysis-templates.md): + +- Current priority score with breakdown +- Specific improvement recommendations +- Comparison to similar requests +- Next steps for increasing priority + +## Error Handling + +- **Missing tool URL**: Assign low score, request homepage in feedback +- **Private/deleted repository**: Note in analysis, use partial scoring +- **API rate limits**: Implement exponential backoff, cache results +- **Invalid issue format**: Log warning, assign to "Needs Analysis" column + +## Best Practices + +1. **Be specific**: Point to exact fields that need improvement +2. **Be encouraging**: Frame feedback positively, emphasize what's good +3. **Be consistent**: Apply scoring rubric uniformly across all requests +4. **Be transparent**: Show score calculations in analysis comments +5. **Respect rate limits**: Cache API results, batch operations + +## Files in This Skill + +- `SKILL.md` (this file): High-level overview and workflow +- `scoring-criteria.md`: Detailed scoring rubric with examples +- `github-actions.md`: GitHub API operations and CLI commands +- `analysis-templates.md`: Comment templates and feedback patterns +- `scripts/fetch-tool-metrics.js`: Tool metrics collection script + +## Related Documentation + +- [Module Triage System Guide](../../docs/module-triage-system.md) +- [Project Board Setup](../../docs/module-triage-project-setup.md) +- [Workflow Configuration](../../../.github/workflows/module-requests.yml) diff --git a/.claude/skills/triaging-module-requests/analysis-templates.md b/.claude/skills/triaging-module-requests/analysis-templates.md new file mode 100644 index 0000000000..6b21ee2427 --- /dev/null +++ b/.claude/skills/triaging-module-requests/analysis-templates.md @@ -0,0 +1,197 @@ +# Analysis Comment Template + +This document provides a unified template for analysis comments posted to module request issues. + +## Main Template + +Keep all language succinct, technical and to the point. Avoid being overly effusive. + +```markdown +Thanks for requesting a new MultiQC module! This is an automated triage review to help prioritise development work. + +| Item | Details | +| ------------------ | ------------------------------------------------------------------------------------------ | +| **Tool** | [Tool Name] | +| **Repository** | [GitHub URL] (⭐ [Stars if available]) | +| **Priority Score** | [Score]/100 [Priority Band: 🔴 High ≥70 \| 🟡 Medium 40-69 \| 🟢 Low 20-39 \| ⚪ Hold <20] | + +
+ +### Score Breakdown + +| Category | Score | Notes | +| ------------------------ | ----- | ------------ | +| 🌟 Tool Popularity | XX/25 | [Brief note] | +| 📦 Package Downloads | XX/15 | [Brief note] | +| 💬 Community Engagement | XX/35 | [Brief note] | +| ✅ Request Quality | XX/20 | [Brief note] | +| ⚙️ Technical Feasibility | XX/15 | [Brief note] | + +**Total: [Score]/100** + +### [Priority Band]: [Status Message] + +[Customized feedback paragraph, mentioning any missing information from the issue] + +**[Strengths/What's great:]** + +- ✨ [Positive point 1] +- ✨ [Positive point 2] + +**[Areas for improvement/Required actions:]** + +- [Icon: 💡 for suggestions, ⚠️ for important, 🚫 for blocking] [Improvement 1] [(+X points) if applicable] +- [Icon] [Improvement 2] + +### Next Steps + +[Customized action items based on priority: + +- High: No action needed, follow issue for updates +- Medium: Suggestions for improvement, re-evaluation process +- Low: How to add missing information and increase priority +- Hold: Required actions to move forward with examples] + +[Closing sentiment: appreciation/encouragement] + +--- + + +This analysis was performed automatically. Comment @claude analyze-module after making improvements for re-evaluation. +Learn more: Module Triage System Guide + + +
+``` + +## Customization Guidelines + +When using this template, adapt these sections based on priority: + +### High Priority (≥70) + +- **Status:** "Ready for Development" or "Excellent Request" +- **Tone:** Enthusiastic, welcoming, appreciative +- **Focus:** Acknowledge strengths, thank requester +- **Next Steps:** Added to development queue, follow for updates + +### Medium Priority (40-69) + +- **Status:** "Good Candidate" or "Solid Request" +- **Tone:** Positive, helpful, collaborative +- **Focus:** Balance strengths with improvement suggestions +- **Next Steps:** How improvements can increase priority, re-evaluation process + +### Low Priority (20-39) + +- **Status:** "Needs Improvement" or "Has Potential" +- **Tone:** Encouraging, educational, supportive +- **Focus:** What's missing, how to improve +- **Next Steps:** Specific actions to provide missing info, re-evaluation instructions + +### Hold Priority (<20) + +- **Status:** "Critical Information Needed" or "On Hold" +- **Tone:** Patient, instructive, clear +- **Focus:** Blocking issues, required actions with examples +- **Next Steps:** Step-by-step instructions, offer help + +## Re-Analysis Template + +For subsequent analyses after improvements: + +```markdown +## 🔄 Re-Analysis Results + +**Previous Score:** [Old Score]/100 ([Old Priority]) +**Current Score:** [New Score]/100 ([New Priority]) [Trend: ↗️ Improved | ↘️ Decreased | → Unchanged] + +### What Changed + +- [Improvement 1]: +X points +- [Improvement 2]: +X points +- [Change 3]: ±X points + +### Progress + +[Specific praise for improvements made] +[Remaining suggestions if not yet high priority] +``` + +## Special Case Templates + +Add these sections when applicable: + +### Missing Example Files + +```markdown +### 📁 Missing Example Files + +**Important:** Example files are the most critical component of a module request (+8 points). + +**Please provide example files in one of these ways:** + +- ✅ Drag and drop actual files from the tool into this issue +- ✅ Use `.zip` if GitHub doesn't support the file type +- ✅ Submit a PR to the [MultiQC/test-data](https://github.com/MultiQC/test-data) repository +- ✅ Link to files already in the test-data repository + +**Requirements:** + +- Include 2-3 representative examples (typical output) +- ❌ Do NOT copy/paste file contents (formatting and whitespace matter!) + +Without example files, module development is very difficult. This is the #1 way to increase your request's priority. +``` + +### Tool Not Found + +```markdown +### ⚠️ Tool Repository Not Found + +The repository URL provided could not be accessed. This may be because: + +- The repository is private +- The URL is incorrect +- The repository has been deleted + +**Action needed:** Please provide a valid, public repository URL or tool homepage. This is required for assessing tool popularity and maintenance status. +``` + +### Related Requests + +```markdown +### 🔄 Related Requests Found + +This request is related to: + +- #[ISSUE_NUMBER]: [Similar tool/request] + +Consider coordinating with these requesters to combine efforts, increase community engagement, and share use cases. Related requests increase this request's priority (+5 points each). +``` + +### Already Implemented + +```markdown +### ✅ Module May Already Exist + +Our records show that [Tool Name] might already have a MultiQC module: + +- Module: `multiqc.modules.[module_name]` +- Documentation: [Link] + +Please check the [list of existing modules](https://multiqc.info/modules/) before proceeding. If the existing module doesn't meet your needs, please explain what additional functionality you require. +``` + +## General Guidelines + +When using this template: + +1. **Be specific:** Replace all [placeholders] with actual data +2. **Be encouraging:** Always acknowledge what's good first +3. **Be actionable:** Provide concrete next steps, not vague suggestions +4. **Be concise:** Remove sections that aren't relevant +5. **Be accurate:** Double-check all score calculations +6. **Be helpful:** Anticipate questions and provide guidance + +Always end on a constructive note and invite questions. diff --git a/.claude/skills/triaging-module-requests/github-actions.md b/.claude/skills/triaging-module-requests/github-actions.md new file mode 100644 index 0000000000..da9fbf3b98 --- /dev/null +++ b/.claude/skills/triaging-module-requests/github-actions.md @@ -0,0 +1,428 @@ +# GitHub Actions Guide for Module Triage + +This document describes GitHub CLI operations and API interactions for the module triage system. + +## Prerequisites + +Ensure GitHub CLI (`gh`) is authenticated with appropriate permissions: + +- `issues: write` - Add labels, post comments +- `projects: write` - Update project board +- `contents: read` - Access repository data + +## Fetching Issue Data + +### Get Single Issue + +```bash +# Fetch full issue metadata +gh issue view ISSUE_NUMBER --json title,body,labels,reactions,comments,author,createdAt,updatedAt + +# Example output: +{ + "title": "New module for FastQC", + "body": "...", + "labels": [{"name": "module: new"}], + "reactions": {"+1": 5, "heart": 2}, + "comments": [...], + "author": {"login": "username"}, + "createdAt": "2025-10-20T10:00:00Z", + "updatedAt": "2025-10-22T15:30:00Z" +} +``` + +### List All Module Requests + +```bash +# Get all open issues with "module: new" label +gh issue list \ + --label "module: new" \ + --state open \ + --json number,title,labels,reactions,comments,createdAt \ + --limit 100 + +# Filter by additional criteria +gh issue list \ + --label "module: new" \ + --state open \ + --search "NOT label:\"priority: high\"" +``` + +### Search for Related Issues + +```bash +# Find duplicate or related requests +gh issue list \ + --search "TOOL_NAME in:title label:\"module: new\"" \ + --state all \ + --json number,title,state +``` + +## Parsing Issue Body + +Issue bodies usually follow the template in `.github/ISSUE_TEMPLATE/module-request.yml`. Extract fields: + +```bash +# Full body text +BODY=$(gh issue view ISSUE_NUMBER --json body --jq '.body') + +# Extract specific fields using pattern matching +TOOL_NAME=$(echo "$BODY" | sed -n '/### Name of the tool/,/###/p' | sed '1d;$d' | xargs) +TOOL_URL=$(echo "$BODY" | sed -n '/### Tool homepage or repository/,/###/p' | sed '1d;$d' | xargs) +DESCRIPTION=$(echo "$BODY" | sed -n '/### Tool description/,/###/p' | sed '1d;$d' | xargs) +``` + +**Better approach:** Use Claude's natural language understanding to extract structured data from the issue body directly. + +## Checking for Example Files + +Example files can be provided in multiple ways: + +1. Uploaded directly to the issue (attachments) +2. Linked to or submitted in the MultiQC/test-data repository + +```bash +# Check for uploaded files (attachments) +if echo "$BODY" | grep -q "!\[.*\](.*)" || echo "$BODY" | grep -q "\[.*\..*\](.*)" ; then + echo "Has uploaded files" +fi + +# Check for references to test-data repository +if echo "$BODY" | grep -q "github.com/MultiQC/test-data"; then + echo "References test-data repository" +fi +``` + +**Scoring guideline:** Files uploaded to the issue or properly linked to the test-data repository should receive full points. + +## Managing Labels + +### Add Priority Labels + +**IMPORTANT: Always remove existing priority labels before adding new ones to avoid label conflicts.** + +```bash +# Step 1: Remove ALL existing priority labels first +gh issue edit ISSUE_NUMBER \ + --remove-label "waiting: example data" \ + --remove-label "module: prio-hold" \ + --remove-label "module: prio-low" \ + --remove-label "module: prio-medium" \ + --remove-label "module: prio-high" 2>/dev/null || true + +# Step 2: Add the appropriate new priority label +gh issue edit ISSUE_NUMBER --add-label "module: prio-high" +``` + +**Note:** The `2>/dev/null || true` suppresses errors if a label doesn't exist on the issue. + +### Label Reference + +**Priority labels:** + +- `module: prio-high` - Score ≥ 70 +- `module: prio-medium` - Score 40-69 +- `module: prio-low` - Score 20-39 +- `module: prio-hold` - Score <= 20 + +**Status labels:** + +- `waiting: example data` - Missing example files + +### Add Status Labels + +```bash +# Mark as needing examples +gh issue edit ISSUE_NUMBER --add-label "waiting: example data" +# Add priority label +gh issue edit ISSUE_NUMBER --add-label "module: prio-medium" +``` + +## Posting Comments + +### Analysis Comment + +```bash +# Post analysis results +gh issue comment ISSUE_NUMBER --body "$(cat <<'EOF' +## 📊 Module Request Analysis + +[See analysis-templates.md for full template] +EOF +)" +``` + +### Using Heredoc for Multi-line Comments + +```bash +ANALYSIS=$(cat <<'EOF' +Thanks for requesting a new MultiQC module! This is an automated triage review to help prioritise development work. + +| Item | Details | +| ------------------ | -------------------------------------------- | +| **Tool** | FastQC | +| **Repository** | https://github.com/s-andrews/FastQC (⭐ 450) | +| **Priority Score** | 75/100 🔴 **High Priority** | + +
+ +### Score Breakdown + +| Category | Score | Notes | +| ------------------------ | ----- | --------------------------------- | +| 🌟 Tool Popularity | 20/25 | 450 stars, actively maintained | +| 📦 Package Downloads | 12/15 | 50K downloads/month on Bioconda | +| 💬 Community Engagement | 18/35 | 8 👍 reactions, 3 comments | +| ✅ Request Quality | 20/20 | Complete info, example files | +| ⚙️ Technical Feasibility | 5/15 | Complex output format | + +**Total: 75/100** + +### 🔴 High Priority: Ready for Development + +This is an excellent module request. The tool is popular, actively maintained, and the request is complete with example files. + +**What's great:** + +- ✨ Comprehensive example files provided +- ✨ Clear description of expected metrics +- ✨ Widely used tool in the community + +### Next Steps + +No action needed. Follow this issue for progress updates. + +--- + + + +This analysis was performed automatically. Comment `@claude analyze-module` for re-evaluation. +Learn more: [Module Triage System Guide](https://github.com/MultiQC/MultiQC/blob/main/.claude/docs/module-triage-system.md) + + + +
+EOF +) + +gh issue comment ISSUE_NUMBER --body "$ANALYSIS" +``` + +## Project Board Operations + +### Get Project Information + +```bash +# List organization projects +gh project list --owner MultiQC + +# Get project fields and options +gh project field-list PROJECT_NUMBER --owner MultiQC +``` + +### Add Issue to Project + +```bash +# Add issue to project (returns item ID) +ITEM_ID=$(gh project item-add PROJECT_NUMBER \ + --owner MultiQC \ + --url "https://github.com/MultiQC/MultiQC/issues/ISSUE_NUMBER") +``` + +### Update Project Fields + +```bash +# Update custom fields (e.g., priority score) +gh project item-edit \ + --project-id PROJECT_ID \ + --id $ITEM_ID \ + --field-id FIELD_ID \ + --text "75" + +# Move to column (using status field) +gh project item-edit \ + --project-id PROJECT_ID \ + --id $ITEM_ID \ + --field-id STATUS_FIELD_ID \ + --option-id "High Priority" +``` + +**Note:** Project board updates are optional. Labels alone provide sufficient tracking. + +## Batch Operations + +### Triage All Open Requests + +```bash +# Get all module requests +ISSUES=$(gh issue list \ + --label "module: new" \ + --state open \ + --json number \ + --jq '.[].number') + +# Process each +for ISSUE_NUMBER in $ISSUES; do + echo "Analyzing #$ISSUE_NUMBER..." + # [Run analysis logic] +done +``` + +### Update Stale Requests + +```bash +# Find requests with no activity in 180 days +gh issue list \ + --label "module: new" \ + --search "updated:<$(date -v-180d +%Y-%m-%d)" \ + --json number,title +``` + +## Rate Limiting + +GitHub API has rate limits. Check remaining calls: + +```bash +# Check rate limit status +gh api rate_limit + +# Response shows remaining calls per endpoint +{ + "resources": { + "core": { + "limit": 5000, + "remaining": 4823, + "reset": 1640000000 + } + } +} +``` + +**Best practices:** + +- Cache results when processing multiple issues +- Use `--json` queries to minimize API calls +- Implement exponential backoff on errors +- Batch operations when possible + +## Error Handling + +### Issue Not Found + +```bash +if ! gh issue view ISSUE_NUMBER &>/dev/null; then + echo "Issue #$ISSUE_NUMBER not found" + exit 1 +fi +``` + +### Label Already Exists + +```bash +# Safe to add label multiple times (idempotent) +gh issue edit ISSUE_NUMBER --add-label "priority: high" +``` + +### Permission Errors + +```bash +# Check if authenticated +if ! gh auth status &>/dev/null; then + echo "Error: Not authenticated with GitHub CLI" + exit 1 +fi +``` + +## Dry Run Mode + +When operating in dry-run mode, output intended actions without executing: + +```bash +DRY_RUN=true + +if [ "$DRY_RUN" = true ]; then + echo "[DRY RUN] Would add label: priority: high" + echo "[DRY RUN] Would post comment with analysis" +else + gh issue edit ISSUE_NUMBER --add-label "priority: high" + gh issue comment ISSUE_NUMBER --body "$ANALYSIS" +fi +``` + +## Complete Example Workflow + +```bash +#!/bin/bash +set -e + +ISSUE_NUMBER=$1 +DRY_RUN=${2:-false} + +# 1. Fetch issue data +echo "Fetching issue #$ISSUE_NUMBER..." +ISSUE_DATA=$(gh issue view $ISSUE_NUMBER --json title,body,labels,reactions,comments) + +# 2. Extract tool information +TOOL_URL=$(echo "$ISSUE_DATA" | jq -r '.body' | grep -o 'https://github.com/[^/]*/[^/ ]*') + +# 3. Fetch tool metrics +if [ -n "$TOOL_URL" ]; then + REPO=${TOOL_URL#https://github.com/} + STARS=$(gh api repos/$REPO --jq '.stargazers_count') + echo "Tool has $STARS stars" +fi + +# 4. Calculate score +# [Run scoring logic - see scoring-criteria.md] + +# 5. Add labels +if [ "$DRY_RUN" != "true" ]; then + gh issue edit $ISSUE_NUMBER --add-label "priority: high" + echo "Added priority label" +else + echo "[DRY RUN] Would add label: priority: high" +fi + +# 6. Post analysis +if [ "$DRY_RUN" != "true" ]; then + gh issue comment $ISSUE_NUMBER --body "$ANALYSIS" + echo "Posted analysis comment" +else + echo "[DRY RUN] Would post analysis" +fi +``` + +## Testing + +Test GitHub operations in dry-run mode: + +```bash +# Test fetching issue +gh issue view 1234 --json title,body,labels + +# Test label operations (on test issue) +gh issue edit TEST_ISSUE --add-label "priority: high" +gh issue edit TEST_ISSUE --remove-label "priority: high" + +# Test comment formatting +echo "$ANALYSIS" | gh issue comment TEST_ISSUE --body-file - +``` + +## Workflow Integration + +The GitHub Action workflow (`.github/workflows/module-requests.yml`) provides: + +```yaml +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +steps: + - uses: anthropics/claude-code-action@v1 + with: + prompt: | + Use the `triaging-module-requests` skill + Mode: ${{ mode }} + Issue: #${{ issue_number }} +``` + +This ensures Claude has access to `gh` CLI with appropriate permissions. diff --git a/.claude/skills/triaging-module-requests/scoring-criteria.md b/.claude/skills/triaging-module-requests/scoring-criteria.md new file mode 100644 index 0000000000..44bd9a0288 --- /dev/null +++ b/.claude/skills/triaging-module-requests/scoring-criteria.md @@ -0,0 +1,265 @@ +# Scoring Criteria for Module Request Triage + +This document provides the detailed rubric for calculating priority scores (0-100) for MultiQC module requests. + +## Score Categories + +### 1. Tool Popularity (25 points max) + +**GitHub Stars** (primary metric): + +- ≥200 stars: 25 points +- 100-199 stars: 20 points +- 50-99 stars: 15 points +- 25-49 stars: 12 points +- 10-24 stars: 8 points +- 5-9 stars: 5 points +- <5 stars: 2 points + +**Bonus factors** (add up to 3 points): + +- Active maintenance: +2 (commit in last 3 months) +- High fork ratio: +1 (forks/stars > 0.1) + +**Example calculation:** + +- Tool with 75 stars, recent commit, 10 forks: 15 + 2 + 1 = 18/25 + +### 2. Package Downloads (15 points max) + +Check in order of preference: PyPI → Conda → Bioconda → Docker pulls + +**Monthly Downloads:** + +- ≥1M downloads: 15 points +- 500K-999K: 13 points +- 100K-499K: 11 points +- 50K-99K: 9 points +- 10K-49K: 7 points +- 1K-9K: 5 points +- 100-999: 3 points +- <100: 1 point + +**How to check:** + +```bash +# PyPI stats (use scripts/fetch-tool-metrics.js) +node scripts/fetch-tool-metrics.js pypi PACKAGE_NAME + +# Conda downloads (from anaconda.org) +node scripts/fetch-tool-metrics.js conda PACKAGE_NAME +``` + +**Example calculation:** + +- PyPI package with 250K monthly downloads: 11/15 + +### 3. Community Engagement (35 points max) + +**Reactions** (👍 on the issue): + +- Each 👍 reaction: +1 point (max 15 points) +- Cap at 15 to prevent gaming + +**Comments** (substantive, non-bot): + +- Each meaningful comment: +2 points (max 10 points) +- Exclude bot comments and "+1" style comments + +**Duplicate/Related Requests:** + +- Each related open issue: +5 points (max 10 points) +- Search for similar tool names or use cases + +**Example calculation:** + +- 8 👍 reactions = 8 points +- 3 meaningful comments = 6 points +- 1 related request = 5 points +- Total: 19/35 + +### 4. Request Quality (20 points max) + +**Required Fields Completed:** + +- Tool name provided: 2 points +- Tool homepage/repository URL: 3 points +- Tool description: 2 points +- Data/plots suggestions: 2 points +- General stats suggestions: 2 points + +**Example Files:** + +- Example files uploaded (not pasted): 8 points +- Example files pasted as text: 4 points +- No example files: 0 points + +**Additional Context:** + +- Context field completed: 1 point + +**Example calculation:** + +- All required fields: 11 points +- Uploaded example files: 8 points +- Context provided: 1 point +- Total: 20/20 (perfect!) + +### 5. Technical Feasibility (15 points max) + +**Output Format** (assess from examples or description): + +- Structured format (JSON/TSV/CSV): 8 points +- Semi-structured (key-value pairs): 5 points +- Unstructured/text: 2 points +- Unknown/no examples: 0 points + +**Metrics Clarity:** + +- Clear quantitative metrics: 4 points +- Qualitative or unclear metrics: 2 points +- No metrics evident: 0 points + +**Parsing Complexity:** + +- Simple parsing expected: 3 points +- Complex parsing needed: 1 point +- Very difficult/unclear: 0 points + +**Example calculation:** + +- TSV output format: 8 points +- Clear quantitative metrics: 4 points +- Simple parsing: 3 points +- Total: 15/15 + +## Score Calculation Process + +1. **Fetch tool metadata:** + + ```bash + # Use the metrics script + node scripts/fetch-tool-metrics.js github OWNER/REPO + node scripts/fetch-tool-metrics.js pypistats PACKAGE_NAME + ``` + +2. **Parse issue body:** + - Extract tool name, URL, description + - Check for example files (attachments vs. pasted) + - Count filled required fields + +3. **Check community metrics:** + + ```bash + gh issue view ISSUE_NUMBER --json reactions,comments + gh issue list --label "module: new" --search "TOOL_NAME in:title" + ``` + +4. **Calculate each category:** + - Tool Popularity: Use GitHub API results + - Package Downloads: Use PyPI/Conda stats + - Community Engagement: Sum reactions + comments + duplicates + - Request Quality: Count completed fields + example quality + - Technical Feasibility: Assess from examples/description + +5. **Sum total score (0-100)** + +6. **Assign priority band:** + - ≥70: High Priority 🔴 + - 40-69: Medium Priority 🟡 + - 20-39: Low Priority 🟢 + - <20: Hold ⚪ + +## Edge Cases + +### Tool URL Not Provided + +- Assign 0 for Tool Popularity +- Assign 0 for Package Downloads +- Note in feedback: "Please provide tool homepage/repository" + +### Private or Deleted Repository + +- Assign minimum scores for popularity (2 points) +- Note in analysis comment +- Request alternative URL or more information + +### Multiple Package Names + +- Use the package with highest downloads +- Note alternative packages in analysis + +### Tool Without Package + +- Score 0 for Package Downloads +- Don't penalize further; adjust feedback +- GitHub stars become more important + +### Example Files in Comments + +- Check original issue body and all comments +- Give partial credit (6 points vs. 8) if in comments +- Note: "Consider editing issue to add files to main body" + +### Non-Bioinformatics Tools + +- Apply same rubric objectively +- If clearly off-topic, note in analysis +- Don't artificially inflate or deflate score + +## Scoring Examples + +### Example 1: High Priority Request (Score: 88) + +- **Tool:** STAR aligner (GitHub: 250 stars, active) +- **Tool Popularity:** 25 + 2 = 27/25 (capped at 25) +- **Downloads:** 150K/month PyPI = 11/15 +- **Community:** 12 👍, 4 comments, 1 duplicate = 12 + 8 + 5 = 25/35 +- **Quality:** All fields + uploaded files = 20/20 +- **Feasibility:** TSV output, clear metrics = 15/15 +- **Total:** 96/100 (capped at 25 for popularity) = 88/100 → 🔴 High Priority + +### Example 2: Medium Priority Request (Score: 52) + +- **Tool:** NewTool (GitHub: 30 stars, recent activity) +- **Tool Popularity:** 12 + 2 = 14/25 +- **Downloads:** 5K/month Conda = 5/15 +- **Community:** 3 👍, 1 comment, 0 duplicates = 3 + 2 = 5/35 +- **Quality:** All fields + uploaded files = 20/20 +- **Feasibility:** JSON output, clear metrics = 12/15 +- **Total:** 56/100 → 🟡 Medium Priority + +### Example 3: Low Priority Request (Score: 28) + +- **Tool:** CustomScript (no GitHub, no package) +- **Tool Popularity:** 0/25 +- **Downloads:** 0/15 +- **Community:** 1 👍, 0 comments = 1/35 +- **Quality:** Basic fields, no examples = 11/20 +- **Feasibility:** Unknown format = 0/15 +- **Total:** 12/100 → ⚪ Hold (needs more info) + +## Improving Scores + +**Quick wins for requesters:** + +1. Upload example files (+8 points) +2. Complete all required fields (+11 points) +3. Share request for 👍 reactions (+1 per reaction) +4. Use popular, well-maintained tools (determined by tool choice) + +**Feedback should always:** + +- Show current score and breakdown +- Identify missing high-value items +- Provide specific, actionable next steps +- Be encouraging about what's already good + +## Validation + +After scoring, sanity check: + +- Does the priority feel right intuitively? +- Are we comparing apples to apples? +- Is the feedback constructive and specific? +- Would this help a contributor improve their request? diff --git a/.claude/skills/triaging-module-requests/scripts/fetch-tool-metrics.js b/.claude/skills/triaging-module-requests/scripts/fetch-tool-metrics.js new file mode 100755 index 0000000000..53d2788d23 --- /dev/null +++ b/.claude/skills/triaging-module-requests/scripts/fetch-tool-metrics.js @@ -0,0 +1,208 @@ +#!/usr/bin/env node + +/** + * Tool Metrics Fetcher + * + * Fetches popularity and usage metrics for bioinformatics tools from various sources: + * - GitHub: stars, forks, last commit date + * - PyPI: monthly downloads + * - Conda: total downloads (via anaconda.org) + * + * Usage: + * node fetch-tool-metrics.js github owner/repo + * node fetch-tool-metrics.js pypi package-name + * node fetch-tool-metrics.js conda package-name + */ + +const https = require("https"); +const { execSync } = require("child_process"); + +/** + * Make HTTPS request and return parsed JSON + */ +function httpsGet(url, headers = {}) { + return new Promise((resolve, reject) => { + const options = { + headers: { + "User-Agent": "MultiQC-Module-Triage", + ...headers, + }, + }; + + https + .get(url, options, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(data) }); + } catch (e) { + reject(new Error(`Failed to parse JSON: ${e.message}`)); + } + }); + }) + .on("error", reject); + }); +} + +/** + * Fetch GitHub repository metrics + */ +async function fetchGitHubMetrics(repoPath) { + try { + // Try using gh CLI first (respects authentication) + try { + const result = execSync(`gh api repos/${repoPath}`, { encoding: "utf-8" }); + const data = JSON.parse(result); + + return { + source: "github", + repository: repoPath, + stars: data.stargazers_count, + forks: data.forks_count, + lastCommit: data.pushed_at, + archived: data.archived, + url: data.html_url, + }; + } catch (ghError) { + // Fallback to public API if gh CLI fails + console.error("gh CLI failed, trying public API..."); + const { status, data } = await httpsGet(`https://api.github.com/repos/${repoPath}`); + + if (status !== 200) { + throw new Error(`GitHub API returned ${status}`); + } + + return { + source: "github", + repository: repoPath, + stars: data.stargazers_count, + forks: data.forks_count, + lastCommit: data.pushed_at, + archived: data.archived, + url: data.html_url, + }; + } + } catch (error) { + throw new Error(`Failed to fetch GitHub metrics: ${error.message}`); + } +} + +/** + * Fetch PyPI package metrics + */ +async function fetchPyPIMetrics(packageName) { + try { + // Get package info + const { status, data } = await httpsGet(`https://pypi.org/pypi/${packageName}/json`); + + if (status !== 200) { + throw new Error(`PyPI API returned ${status}`); + } + + // Try to get download stats from pypistats.org + let downloads = null; + try { + const statsUrl = `https://pypistats.org/api/packages/${packageName}/recent`; + const statsResponse = await httpsGet(statsUrl); + if (statsResponse.status === 200) { + downloads = { + lastMonth: statsResponse.data.data.last_month, + lastWeek: statsResponse.data.data.last_week, + lastDay: statsResponse.data.data.last_day, + }; + } + } catch (e) { + // Download stats not available + console.error("PyPI download stats not available"); + } + + return { + source: "pypi", + package: packageName, + downloads: downloads, + url: `https://pypi.org/project/${packageName}/`, + }; + } catch (error) { + throw new Error(`Failed to fetch PyPI metrics: ${error.message}`); + } +} + +/** + * Fetch Conda package metrics + */ +async function fetchCondaMetrics(packageName) { + try { + // Try Anaconda.org API + const { status, data } = await httpsGet(`https://api.anaconda.org/package/bioconda/${packageName}`); + + if (status !== 200) { + throw new Error(`Anaconda API returned ${status}`); + } + + // Sum downloads from all files + const totalDownloads = data.files ? data.files.reduce((sum, file) => sum + (file.ndownloads || 0), 0) : 0; + + return { + source: "conda", + package: packageName, + downloads: totalDownloads, + url: `https://anaconda.org/bioconda/${packageName}`, + }; + } catch (error) { + throw new Error(`Failed to fetch Conda metrics: ${error.message}`); + } +} + +/** + * Main function + */ +async function main() { + const args = process.argv.slice(2); + + if (args.length < 2) { + console.error("Usage: fetch-tool-metrics.js "); + console.error("Sources: github, pypi, conda"); + console.error("Examples:"); + console.error(" fetch-tool-metrics.js github nextflow-io/nextflow"); + console.error(" fetch-tool-metrics.js pypi multiqc"); + console.error(" fetch-tool-metrics.js conda fastqc"); + process.exit(1); + } + + const [source, identifier] = args; + + try { + let metrics; + + switch (source.toLowerCase()) { + case "github": + metrics = await fetchGitHubMetrics(identifier); + break; + case "pypi": + metrics = await fetchPyPIMetrics(identifier); + break; + case "conda": + metrics = await fetchCondaMetrics(identifier); + break; + default: + throw new Error(`Unknown source: ${source}. Use github, pypi, or conda`); + } + + console.log(JSON.stringify(metrics, null, 2)); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + main(); +} + +module.exports = { + fetchGitHubMetrics, + fetchPyPIMetrics, + fetchCondaMetrics, +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..26d5f3536d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +**/compiled/*/* linguist-generated=true diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d40f00f20f..7723ce70b6 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,6 +4,12 @@ Hi there! Many thanks for taking an interest in improving MultiQC. I try to manage the required tasks for MultiQC using GitHub issues, you probably came to this page when creating one. Most issues come in two flavours - either reporting a problem or requesting a feature. Please use the template prefilled into new issues when this is the case as it saves time. The most common reason for long-running issues is module requests without any example log files for example. +## Module Requests + +We use an automated triage system to prioritize module requests based on tool popularity, community need, and request quality. When you submit a module request, it will be automatically analyzed and prioritized. For best results, include example files and complete information about the tool. + +Learn more about how the system works and how to get your request prioritized in the [Module Triage System Guide](../.claude/docs/module-triage-system.md). + However, don't be put off by this template - other more general issues and suggestions are welcome! Contributions to the code are even more welcome ;) > _If you need help using MultiQC then the best place to go is the Seqera community forum where you can questions in the MultiQC category: [https://community.seqera.io](https://community.seqera.io/c/multiqc/6)_ diff --git a/.github/ISSUE_TEMPLATE/module-request.yml b/.github/ISSUE_TEMPLATE/module-request.yml index 5d1c15a414..7d56a5d756 100644 --- a/.github/ISSUE_TEMPLATE/module-request.yml +++ b/.github/ISSUE_TEMPLATE/module-request.yml @@ -2,19 +2,39 @@ name: New module description: Request support for a new bioinformatics tool in MultiQC labels: ["module: new"] body: + - type: markdown + attributes: + value: | + ## 📋 Module Request Guidelines + + Thank you for requesting a new MultiQC module! Your request will be automatically analyzed and prioritized based on: + - **Tool popularity** (GitHub stars, downloads) + - **Request quality** (completeness, example files) + - **Community need** (reactions, comments) + + 💡 **Tips for higher priority:** + - Upload actual example files (most important!) + - Provide complete information + - Use a popular, actively maintained tool + + ✨ **Get instant feedback:** Comment `@claude analyze-module` anytime for detailed analysis! + + 📖 [Learn more about the triage system](https://github.com/MultiQC/MultiQC/blob/main/.claude/docs/module-triage-system.md) + - type: input id: name attributes: label: Name of the tool - placeholder: eg. My Tool + placeholder: eg. FastQC validations: required: true - type: input id: homepage attributes: - label: Tool homepage - placeholder: eg. http://www.github/me/mytool + label: Tool homepage or repository + description: "Provide the main homepage or GitHub/GitLab repository URL. This helps us assess tool popularity and maintenance status." + placeholder: eg. https://github.com/user/mytool or https://mytool.readthedocs.io validations: required: true @@ -23,20 +43,25 @@ body: attributes: label: Tool description description: A short single-sentence description that will be used in the modules listing on the MultiQC homepage. - placeholder: eg. My Tool is a really excellent tool that does what no other tool can do + placeholder: eg. FastQC is a quality control tool for high throughput sequence data validations: required: true - type: textarea id: example-files attributes: - label: Tool output + label: Tool output example files description: | - Please drag and drop (and upload to the GitHub issue) some example files that can be used to write the module. - ***Please do not copy and paste log contents***, as important whitespace can change. - If the file type is not allowed, please compress into a `.zip` file. - If the file is many MBs then it's ok to truncate it, though please leave a few lines of data in so that it's clear what the format is. - placeholder: "[ Drag and drop some example files here to upload ]" + **📁 Example files are critical for module development!** + + - Drag and drop example files showing typical tool output + - **Do NOT copy/paste file contents** - formatting and whitespace matter + - Use `.zip` files if GitHub doesn't support the file type + - Include both small and representative examples + - Truncate large files but keep enough data to show the format + + 💡 **Quality examples significantly increase your request's priority!** + placeholder: "[ Drag and drop example files here to upload ]" validations: required: true @@ -52,24 +77,44 @@ body: - type: textarea id: plots attributes: - label: Data suitable for MultiQC plot(s) + label: Data suitable for MultiQC plots description: | What data in the tool output is most important - what would you like to see in a plot or dedicated table? Remember that MultiQC should just highlight the important bits - enough to spot outlier samples for further examination. + placeholder: | + - Distribution of quality scores across reads + - Pass/fail counts by sample + - Summary metrics comparison - type: textarea id: general-stats attributes: label: Most interesting data for the General Stats table - description: Is there 1-3 values that would be helpful to have in the _General Statistics_ table, alongside results from other tools? + description: Suggest 1-3 key values that would be helpful in the General Statistics table, alongside results from other tools. + placeholder: | + - Total reads processed + - Percentage passed filters + - Average quality score + + - type: textarea + id: context + attributes: + label: Additional context + description: | + Help us understand why this module would be valuable: + - How widely is this tool used in your field? + - Are there existing alternatives in MultiQC? + - What unique value would this module provide? + - Is this related to a published workflow or pipeline? + placeholder: "This tool is widely used in genomics for... It would complement existing modules by..." - type: checkboxes id: checklist attributes: - label: Before submitting - description: >- - Please ensure your module request fulfills all of the following requirements. + label: Checklist + description: These help improve your request's priority, but aren't strictly required. We're here to help! options: - - label: >- - I have included example data (zipped, not pasted) that can be used to write the module. - required: true + - label: I have included example data files (uploaded/zipped, not copy-pasted) + - label: I have checked that no similar module already exists in the [MultiQC modules list](https://multiqc.info/modules/) + - label: I have provided the tool's homepage or repository URL + - label: I understand this request will be prioritized based on tool popularity, community need, and request quality diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 4bd132aa3b..be6e1ccec4 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -7,16 +7,17 @@ This checklist is for my own reference, as I forget the steps every time. 3. Generate a new changelog section stub (make sure to export a GitHub token to avoid rate limits): ```bash - export GITHUB_TOKEN= - python scripts/print_changelog.py + GITHUB_TOKEN=$(gh auth token) python scripts/print_changelog.py ``` Then paste it into `CHANGELOG.md` and edit accordingly: group changes if needed, add highlights. -4. Update module documentation markdown files: +4. Update module documentation markdown files and update the config JSON schema: ```bash + cd test-data && git pull && cd ../ python scripts/make_module_docs.py + python scripts/generate_config_schema.py ``` 5. Install the package again in the `install` mode. Make sure to remove the build directory and egg info that might cache outdated metadata such as entry point names: @@ -29,12 +30,10 @@ This checklist is for my own reference, as I forget the steps every time. This removes the commit hash from the version number when MultiQC runs. 6. Run using test data - - Check for any command line or javascript errors - Check version numbers are printed correctly 7. Create new demo reports for the website - - Comment out any config in `~/.multiqc_config.yaml` ```bash @@ -44,7 +43,7 @@ This checklist is for my own reference, as I forget the steps every time. - Generate reports in the seqeralabs/web repo. ```bash - cd ../seqeralabs/web/packages/website/public/examples + cd ../../seqera/web/services/website/public/examples bash update_examples.sh ``` diff --git a/.github/workflows/claude-code-review-trigger.yml b/.github/workflows/claude-code-review-trigger.yml new file mode 100644 index 0000000000..f922611068 --- /dev/null +++ b/.github/workflows/claude-code-review-trigger.yml @@ -0,0 +1,44 @@ +name: Claude Code Review Trigger + +on: + pull_request: + types: [opened] + issue_comment: + types: [created] + +jobs: + trigger: + runs-on: ubuntu-latest + # Only run on PR comments (issue_comment fires for both issues and PRs) + # or when PR is opened + if: | + github.event_name == 'pull_request' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + contains(github.event.comment.body, '/review')) + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Save PR info + run: | + mkdir -p pr-info + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "${{ github.event.pull_request.number }}" > pr-info/pr-number.txt + echo "${{ github.event.pull_request.head.sha }}" > pr-info/head-sha.txt + else + # For issue_comment, get PR number and fetch head SHA + echo "${{ github.event.issue.number }}" > pr-info/pr-number.txt + HEAD_SHA=$(gh pr view "${{ github.event.issue.number }}" --json headRefOid -q .headRefOid) + echo "$HEAD_SHA" > pr-info/head-sha.txt + fi + env: + GH_TOKEN: ${{ github.token }} + + - name: Upload PR info + uses: actions/upload-artifact@v4 + with: + name: pr-info + path: pr-info/ + retention-days: 1 diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 23ce89c599..4ae02b8d3e 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,20 +1,15 @@ name: Claude Code Review on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" + workflow_run: + workflows: ["Claude Code Review Trigger"] + types: + - completed jobs: claude-review: - # Only run on PRs from branches in the same repository (not forks) - # This prevents the workflow from failing when secrets aren't available - if: github.event.pull_request.head.repo.full_name == github.repository + # Only run if the trigger workflow succeeded + if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest permissions: @@ -24,28 +19,55 @@ jobs: id-token: write steps: - - name: Checkout repository + - name: Download PR info + uses: actions/download-artifact@v4 + with: + name: pr-info + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Get PR number + id: pr-info + run: | + ls -la + PR_NUMBER=$(cat pr-number.txt) + HEAD_SHA=$(cat head-sha.txt) + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + echo "PR Number: $PR_NUMBER" + echo "Head SHA: $HEAD_SHA" + + - name: Checkout PR uses: actions/checkout@v4 with: - fetch-depth: 1 + ref: ${{ steps.pr-info.outputs.head_sha }} + fetch-depth: 0 - name: Run Claude Code Review id: claude-review + timeout-minutes: 15 uses: anthropics/claude-code-action@v1 + env: + PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }} with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + show_full_output: true prompt: | - Please review this pull request and provide feedback on: + Please review pull request #${{ steps.pr-info.outputs.pr_number }} and provide feedback on: - Code quality and best practices - Potential bugs or issues - Performance considerations - Security concerns - Test coverage + Write a very brief summary at the top, then include the rest of your review within a
HTML tag. + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + IMPORTANT: Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. You MUST post the comment - do not just display it. # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options - claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' + claude_args: | + --permission-mode bypassPermissions + --allowed-tools "Bash(gh:*) Read Glob Grep" diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 9439e19594..b3a7cbf4e4 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.13"] # Oldest and newest supported Python versions + python-version: ["3.8", "3.14.0"] # Oldest and newest supported Python versions (3.14.1 has upstream bug) timeout-minutes: 10 steps: diff --git a/.github/workflows/lint_code.yml b/.github/workflows/lint_code.yml index a14a3b71e0..079ceb1eb5 100644 --- a/.github/workflows/lint_code.yml +++ b/.github/workflows/lint_code.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.14.0" # 3.14.1 has upstream bug cache: "pip" - uses: pre-commit/action@v3.0.1 @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.14.0" # 3.14.1 has upstream bug cache: "pip" - run: pip install rich diff --git a/.github/workflows/module-requests.yml b/.github/workflows/module-requests.yml new file mode 100644 index 0000000000..84c0bace5e --- /dev/null +++ b/.github/workflows/module-requests.yml @@ -0,0 +1,116 @@ +name: Module Request Management + +on: + # # Individual request analysis + # issue_comment: + # types: [created] + # issues: + # types: [opened] + + # Weekly bulk triage (commented out until tested via workflow_dispatch) + # schedule: + # - cron: "0 9 * * 1" # Mondays at 9 AM UTC + + # Manual trigger + workflow_dispatch: + inputs: + mode: + description: "Mode: analyze-single, triage-all, or dry-run" + required: false + default: "triage-all" + type: choice + options: + - analyze-single + - triage-all + - dry-run + issue_number: + description: "Issue number for analyze-single mode" + required: false + type: string + +permissions: + issues: write + contents: read + id-token: write + +jobs: + module-request-handler: + runs-on: ubuntu-latest + + # Only run for module requests or scheduled/manual triggers + if: | + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && + contains(github.event.issue.labels.*.name, 'module: new') && + contains(github.event.comment.body, '@claude analyze-module')) || + (github.event_name == 'issues' && + github.event.action == 'opened' && + contains(github.event.issue.labels.*.name, 'module: new')) + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Determine Operation Mode + id: mode + uses: actions/github-script@v7 + with: + script: | + const { eventName, payload } = context; + const inputs = context.payload.inputs || {}; + + // Determine mode based on trigger: + // - analyze-single: Skill analyzes one specific issue + // - triage-all: Skill loops over all open issues with 'module: new' label + // - dry-run: Same as triage-all but without making changes + let mode, issueNumber; + + if (eventName === 'schedule') { + mode = 'triage-all'; + } else if (eventName === 'workflow_dispatch') { + mode = inputs.mode || 'triage-all'; + issueNumber = inputs.issue_number; + } else if (eventName === 'issues' || eventName === 'issue_comment') { + mode = 'analyze-single'; + issueNumber = payload.issue?.number; + } + + core.setOutput('mode', mode); + core.setOutput('issue_number', issueNumber || ''); + + console.log(`Mode: ${mode}${issueNumber ? ` | Issue: #${issueNumber}` : ''}`); + return { mode, issueNumber } + + - name: Run Module Triage + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + show_full_output: true + prompt: | + Use the `triaging-module-requests` skill to analyze module requests. + + Mode: ${{ steps.mode.outputs.mode }} + Issue: #${{ steps.mode.outputs.issue_number || 'all pending' }} + + IMPORTANT: After generating the analysis, you MUST post it as a comment to the GitHub issue using the `gh issue comment` command. Do not just display the analysis - actually post it to the issue! + IMPORTANT: After posting the comment, you MUST assign a triage label (module: prio-LEVEL) + + claude_args: | + --permission-mode bypassPermissions + --allowed-tools "Skill WebSearch WebFetch Bash(gh:*) Bash(node:*) Bash(curl:*) Bash(jq:*) Bash(echo:*) Bash(cat:*) Bash(grep:*) Bash(sed:*) Bash(find:*)" + + - name: Report Workflow Status + if: always() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const mode = '${{ steps.mode.outputs.mode }}'; + const issueNumber = '${{ steps.mode.outputs.issue_number }}'; + + console.log(`✅ Module triage workflow completed`); + console.log(`Mode: ${mode}`); + if (issueNumber) { + console.log(`Issue: #${issueNumber}`); + } diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 06980c15d3..be5dd95a83 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.13"] + python-version: ["3.9", "3.14.0"] # 3.14.1 has upstream bug steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 4794970a36..173e73f309 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.13"] + python-version: ["3.9", "3.14.0"] # 3.14.1 has upstream bug env: # GitHub currently has 4 cores available for Linux runners diff --git a/.gitignore b/.gitignore index 6804320e70..c616f163cc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ multiqc_plots*/ # CI tooling .ruff_cache +.playwright-mcp # Generated by the CI tests full_report.html @@ -41,6 +42,7 @@ Thumbs.db .idea .vscode runTest.sh +CLAUDE.local.md # Prevent direnv from cluttering the repo .envrc @@ -54,10 +56,8 @@ venv* /seqeralabs-docs node_modules/ -package-lock.json -package.json .env -.claude +.claude/settings.local.json Pipfile diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf94bdcdd9..965a4b8467 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: check-hooks-apply - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v5.0.0" + rev: "v6.0.0" hooks: - id: check-added-large-files - id: check-merge-conflict @@ -24,14 +24,23 @@ repos: rev: "v3.1.0" hooks: - id: prettier + additional_dependencies: + - prettier@3.7.4 + - prettier-plugin-jinja-template@2.1.0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.9.1" + rev: "v0.14.8" hooks: + # Run the linter. + - id: ruff-check + args: [--fix] + # Run the formatter. - id: ruff-format - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.9.1" + - repo: local hooks: - - id: ruff - args: [--fix, --exit-non-zero-on-fix] + - id: build-assets + name: Build assets with Vite + language: script + entry: scripts/build-assets.sh + files: ^multiqc/templates/default/src/.*\.(scss|js)$ diff --git a/.prettierignore b/.prettierignore index d449ec1146..9b6bca3839 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,15 @@ -*.html +docs/markdown/modules/ multiqc/templates/*/assets/js/packages -bootstrap.min.css +multiqc/templates/*/compiled jquery.toast.css +bootstrap.min.css + +# The Jinja {% raw %} block crashes on these files +multiqc/templates/default/head.html +multiqc/templates/original/head.html + +# Templates have intentional Jinja structure that prettier can't understand +multiqc/templates/original/content.html +multiqc/templates/original/includes.html +multiqc/templates/simple/header.html +multiqc/templates/simple/includes.html diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000000..652991528c --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,14 @@ +module.exports = { + printWidth: 120, + tabWidth: 2, + trailingComma: "all", + plugins: [require.resolve("prettier-plugin-jinja-template")], + overrides: [ + { + files: ["*.html"], + options: { + parser: "jinja-template", + }, + }, + ], +}; diff --git a/.prettierrc.yaml b/.prettierrc.yaml deleted file mode 100644 index 211da14a84..0000000000 --- a/.prettierrc.yaml +++ /dev/null @@ -1,3 +0,0 @@ -printWidth: 120 -tabWidth: 2 -trailingComma: all diff --git a/CHANGELOG.md b/CHANGELOG.md index 11205b7909..0de60b27ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,88 @@ # MultiQC Version History +## [MultiQC v1.33](https://github.com/MultiQC/MultiQC/releases/tag/v1.33) - 2025-12-09 + +### New modules + +- Seqkit stats ([#3401](https://github.com/MultiQC/MultiQC/pull/3401)) + - A cross-platform and ultrafast toolkit for FASTA/Q file manipulation +- RiboTish ([#3384](https://github.com/MultiQC/MultiQC/pull/3384)) + - Ribo-seq quality metrics +- Sylph ([#3370](https://github.com/MultiQC/MultiQC/pull/3370)) + - Sylph is a program that performs metagenomic profiling or containment average nucleotide identity querying for metagenomic shotgun sequencing samples. +- Bbsplit basic stats ([#3394](https://github.com/MultiQC/MultiQC/pull/3394)) + - New submodule for bbtools + +### Feature updates and improvements + +- Custom logo: dark mode option, custom width option. ([#3400](https://github.com/MultiQC/MultiQC/pull/3400)) +- Update Seqera AI chat URL ([#3415](https://github.com/MultiQC/MultiQC/pull/3415)) +- Add sample_groups configuration for visual grouping in bar graphs ([#3404](https://github.com/MultiQC/MultiQC/pull/3404)) +- Custom content: strip whitespace around categories for tsv,csv ([#3421](https://github.com/MultiQC/MultiQC/pull/3421)) +- General Stats: Add configurable help text ([#3341](https://github.com/MultiQC/MultiQC/pull/3341)) +- Custom content: support passing help text ([#3338](https://github.com/MultiQC/MultiQC/pull/3338)) +- Add new flag "axis_controlled_by_switches" to pconfig that allow control of which axis to apply logarithmic scale ([#3423](https://github.com/MultiQC/MultiQC/pull/3423)) + +### Module updates + +- fastp: Add support for naming samples after `--report_title` in Fastp command ([#3418](https://github.com/MultiQC/MultiQC/pull/3418)) +- Glimpse: Add more decimal to general table stats ([#3423](https://github.com/MultiQC/MultiQC/pull/3423)) +- Refactor BISCUIT module for better consistency with current MultiQC codebase ([#3345](https://github.com/MultiQC/MultiQC/pull/3345), [#3426](https://github.com/MultiQC/MultiQC/pull/3426)) +- Add version fetching for HiCUP, QoRTs, QualiMap, RNA-SeQC ([#3420](https://github.com/MultiQC/MultiQC/pull/3420)) + +### Fixes + +- Fix MultiQC plotly export bug affecting FastQC heatmaps ([#3402](https://github.com/MultiQC/MultiQC/pull/3402)) +- Fix bug with sample filter buttons in new template ([#3389](https://github.com/MultiQC/MultiQC/pull/3389)) +- Fix KeyError in bargraph when using reference lines with horizontal orientation ([#3385](https://github.com/MultiQC/MultiQC/pull/3385)) + +### Module fixes + +- Fix validation errors in strict mode for invalid plot config options ([#3428](https://github.com/MultiQC/MultiQC/pull/3428)) + - Remove invalid `hide_zero_cats` from line plot configs (mosdepth, bamdst, samtools/coverage, humid, dragen_fastqc) + - Fix bar plot category configs using invalid fields (picard/IlluminaBasecallingMetrics, motus) +- Samtools coverage: Don't crash if incorrect number of columns found ([#3419](https://github.com/MultiQC/MultiQC/pull/3419)) +- Lima: split delimiter is a tab, not any whitespace. ([#3395](https://github.com/MultiQC/MultiQC/pull/3395)) +- Homer: Fix unique / total count swap in `homer/tagdirectory` ([#3381](https://github.com/MultiQC/MultiQC/pull/3381)) + +### Infrastructure and packaging + +- Avoid Python 3.14.1 ([#3414](https://github.com/MultiQC/MultiQC/pull/3414)) +- Version check: Add installation method to detect installs using `uv` ([#3422](https://github.com/MultiQC/MultiQC/pull/3422)) +- Bump pre commit versions ([#3417](https://github.com/MultiQC/MultiQC/pull/3417)) + +### Optimization + +- Make Parquet merging much, much faster (60% faster) ([#3403](https://github.com/MultiQC/MultiQC/pull/3403)) + +## [MultiQC v1.32](https://github.com/MultiQC/MultiQC/releases/tag/v1.32) - 2025-10-26 + +This release really has one really major change in it: + +- Bootstrap upgrade and DARK MODE ([#3264](https://github.com/MultiQC/MultiQC/pull/3264)) + +This has been brewing for a long time, and is a large rewrite of how MultiQC HTML / CSS and JS is written and packaged. + +MultiQC now supports simple theming with [Bootstrap colour modes](https://getbootstrap.com/docs/5.3/customize/color-modes/), and the updated `default` template ships with both light- and dark-mode by default 😎 + +The old template is still available, but has been renamed to `original`. + +### New modules + +- New module: sompy ([#3186](https://github.com/MultiQC/MultiQC/pull/3186)) + +### Module updates + +- Move most of Xenium code into a [plugin](https://github.com/MultiQC/xenium-extra) ([#3376](https://github.com/MultiQC/MultiQC/pull/3376)) + +### Fixes + +- Docs: Fix a bunch of links ([#3314](https://github.com/MultiQC/MultiQC/pull/3314)) +- Fix flag typo in `running_multiqc.md` ([#3347](https://github.com/MultiQC/MultiQC/pull/3347)) +- Update Dockerfile to optionally include all LaTeX requirements for `--pdf` ([#3349](https://github.com/MultiQC/MultiQC/pull/3349)) +- Update Claude Code GitHub Workflow ([#3353](https://github.com/MultiQC/MultiQC/pull/3353)) +- Remove bedrock availability check when creating client ([#3352](https://github.com/MultiQC/MultiQC/pull/3352)) + ## [MultiQC v1.31](https://github.com/MultiQC/MultiQC/releases/tag/v1.31) - 2025-09-05 Adding new module for [Xenium analysis](https://www.10xgenomics.com/products/xenium-analysis), 10x Genomics Xenium spatial transcriptomics quality control report. @@ -212,7 +295,6 @@ Few fixes and improvements in AI summaries: ### Feature updates and improvements - AI summaries: - - Add "Chat with Seqera AI" buttons to all summaries ([#3067](https://github.com/MultiQC/MultiQC/pull/3067)) - Add options to configure custom OpenAI endpoint ([#3083](https://github.com/MultiQC/MultiQC/pull/3083)) - Support anonymizing sample names ([#3074](https://github.com/MultiQC/MultiQC/pull/3074)) @@ -224,7 +306,6 @@ Few fixes and improvements in AI summaries: - Make token count warning a debug ([#3071](https://github.com/MultiQC/MultiQC/pull/3071)) - Improving table small scatter plots: - - Support hide, rename, highlight ([#3082](https://github.com/MultiQC/MultiQC/pull/3082)) - Remove legend to make more space ([#3081](https://github.com/MultiQC/MultiQC/pull/3081)) diff --git a/README.md b/README.md index c50137e987..0b3d178887 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ multiqc . That's it! MultiQC will scan the specified directory (`.` is the current dir) and produce a report detailing whatever it finds. - + ![`cd test-data/data/modules/fastqc/v0.10.1 && multiqc .`](https://github.com/MultiQC/MultiQC/raw/main/docs/images/screenshots/fastqc-run.svg) diff --git a/docs/images/bargraph_sample_groups.png b/docs/images/bargraph_sample_groups.png new file mode 100644 index 0000000000..e9667248c8 Binary files /dev/null and b/docs/images/bargraph_sample_groups.png differ diff --git a/docs/images/screenshots/fastqc-run.svg b/docs/images/screenshots/fastqc-run.svg index a3cc7a85ee..2df89f9da2 100644 --- a/docs/images/screenshots/fastqc-run.svg +++ b/docs/images/screenshots/fastqc-run.svg @@ -19,52 +19,52 @@ font-weight: 700; } - .terminal-3616762501-matrix { + .terminal-3730270855-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3616762501-title { + .terminal-3730270855-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3616762501-r1 { fill: #c5c8c6 } -.terminal-3616762501-r2 { fill: #ff2627 } -.terminal-3616762501-r3 { fill: #c5c8c6;font-weight: bold } -.terminal-3616762501-r4 { fill: #868887 } -.terminal-3616762501-r5 { fill: #608ab1 } + .terminal-3730270855-r1 { fill: #c5c8c6 } +.terminal-3730270855-r2 { fill: #ff2627 } +.terminal-3730270855-r3 { fill: #c5c8c6;font-weight: bold } +.terminal-3730270855-r4 { fill: #868887 } +.terminal-3730270855-r5 { fill: #608ab1 } - + - + - + - + - + - + - + - + - + @@ -76,18 +76,18 @@ - + - - $ multiqc . - -///MultiQC 🔍 v1.31 - -       file_search | Search path: /home/runner/work/MultiQC/MultiQC/test-data/data/modules/fastqc/v0.10.1 -            fastqc | Found 2 reports -     write_results | Data        : multiqc_data -     write_results | Report      : multiqc_report.html -           multiqc | MultiQC complete + + $ multiqc . + +///MultiQC 🔍 v1.33 + +       file_search | Search path: /home/runner/work/MultiQC/MultiQC/test-data/data/modules/fastqc/v0.10.1 +            fastqc | Found 2 reports +     write_results | Data        : multiqc_data +     write_results | Report      : multiqc_report.html +           multiqc | MultiQC complete diff --git a/docs/markdown/ai/index.md b/docs/markdown/ai/index.md index 47ff69c208..c1059f43b4 100644 --- a/docs/markdown/ai/index.md +++ b/docs/markdown/ai/index.md @@ -57,7 +57,7 @@ Remember: Treat your API keys like passwords and do not share them. Seqera AI is free to use.[^seqera-ai-usage-limits] Use of other third-party APIs are billed by their respective providers based on consumption. -Seqera AI uses the latest AI provider models under the hood, at the time of writing that is Anthropic Claude Sonnet 4.0. +Seqera AI uses the latest AI provider models under the hood. ### Choosing a model @@ -65,7 +65,7 @@ If you're using OpenAI, Anthropic or AWS Bedrock you can choose the exact model This is done by setting `ai_model` in the MultiQC config. - Anthropic model names must begin with `claude` - - Default: `claude-sonnet-4-0`. + - Default: `claude-sonnet-4-5`. - See the [Anthropic docs](https://docs.anthropic.com/en/docs/intro-to-claude#model-options). - OpenAI model names must being with `gpt` - Default: `gpt-4o`. @@ -81,7 +81,7 @@ MultiQC supports reasoning models from multiple providers which provide enhanced ### Supported Reasoning Models - OpenAI: `o1`, `o3`, `o3-mini`, `o4-mini` -- Anthropic Claude 4 series: `claude-sonnet-4-0` +- Anthropic Claude 4 series: `claude-sonnet-4-5` ### Configuration @@ -91,7 +91,7 @@ Simply set your AI model to a reasoning model: # multiqc_config.yaml ai_summary: true ai_provider: openai # or Anthropic for Claude 4 -ai_model: o3-mini # or claude-sonnet-4-0, o4-mini, etc. +ai_model: o3-mini # or claude-sonnet-4-5, o4-mini, etc. ``` Reasoning models support additional configuration parameters: @@ -113,7 +113,7 @@ ai_max_completion_tokens: 8000 # adjust based on needs # multiqc_config.yaml ai_summary: true ai_provider: anthropic -ai_model: claude-sonnet-4-0 +ai_model: claude-sonnet-4-5 ai_extended_thinking: true # enable extended thinking ai_thinking_budget_tokens: 15000 # budget for extended thinking ``` @@ -183,7 +183,7 @@ ai_max_completion_tokens: 3000 ```yaml ai_summary: true ai_provider: anthropic -ai_model: claude-sonnet-4-0 +ai_model: claude-sonnet-4-5 ai_extended_thinking: true # enable extended thinking ai_thinking_budget_tokens: 12000 # budget for thinking process ``` @@ -216,7 +216,6 @@ AI summaries are disabled by default when running MultiQC. To generate them, you must enable them either on the command line or via a MultiQC config file. - Command line flags: - - `--ai` / `--ai-summary`: Generate a short report summary and put it on top of the report (fast) - `--ai-summary-full`: Generate a detailed version of the summary with analysis and recommendations (slower) - `--ai-provider `: Choose AI provider. One of `seqera`, `openai`, `anthropic` or `aws_bedrock`. Default `seqera` diff --git a/docs/markdown/custom_content/index.md b/docs/markdown/custom_content/index.md index c3ffd14806..58400b1576 100644 --- a/docs/markdown/custom_content/index.md +++ b/docs/markdown/custom_content/index.md @@ -377,6 +377,7 @@ section_anchor: # Used in report section #soft-links section_name: # Nice name used for the report section header section_href: null # External URL for the data, to find more information description: null # Introductory text to be printed under the section header +helptext: null # Help text to be shown in a collapsible box (toggled with a help button) section_extra: null # Custom HTML to add after the section description file_format: null # File format of the data (eg. csv / tsv) plot_type: diff --git a/docs/markdown/development/modules.md b/docs/markdown/development/modules.md index 6b5e4fa7d0..6853b472ab 100644 --- a/docs/markdown/development/modules.md +++ b/docs/markdown/development/modules.md @@ -179,7 +179,6 @@ plugins for [Prettier](https://github.com/prettier/prettier-vscode). ## Other style considerations 1. We use modern Python 3, thus: - - Always use f-strings (e.g. `f"{var}"`) over the legacy `"{var}".format()` calls. - Use double quotes for strings. - Built-in `dict` preserve order, thus most of the time you don't need to use `OrderedDict`. @@ -551,6 +550,7 @@ mymodule: You can use _AND_ logic by specifying keys within a single list item. For example: + ```yaml mymodule: fn: "mylog.txt" @@ -560,8 +560,9 @@ myother_module: contents: "This is myprogram v1.3" - fn: "another.txt" contents: ["What are these files anyway?", "End of program"] - contents_re: "^Metric: \d+\.\d+" + contents_re: '^Metric: \d+\.\d+' ``` + For `mymodule`, a file must have the filename `mylog.txt` _and_ contain the string `mystring`. @@ -1111,6 +1112,51 @@ This supports the following arguments: - `content`: Any custom HTML - `autoformat`: Default `True`. Automatically format the `description`, `comment` and `helptext` strings. - `autoformat_type`: Default `markdown`. Autoformat text type. Currently only `markdown` supported. +- `statuses`: Optional dictionary with keys `"pass"`, `"warn"`, and `"fail"`, each containing lists of sample names. When provided, adds an interactive status progress bar to the section header showing pass/warn/fail counts. + +### Section status bars + +If your tool generates pass/warn/fail metrics for different QC checks, you can add interactive status bars to section headers using the `statuses` parameter in `add_section()`. + +The status bars will automatically: + +- Show sample lists on hover (after 0.5s delay) +- Pin the popover on click +- Provide "Highlight" and "Filter" buttons for integration with the MultiQC toolbox +- Display colored progress bars showing the proportion of samples in each status category + +For example: + +```python +# Collect sample names by status for this section +status_data = { + "pass": ["sample1", "sample2", "sample3"], + "warn": ["sample4"], + "fail": ["sample5"] +} + +# Add section with status bar +self.add_section( + name="Quality Check", + anchor="quality_check", + description="Results from quality control analysis", + plot=my_plot, + statuses=status_data +) +``` + +#### Status bar user configuration + +Users can control status bar visibility globally or per-module using the `section_status_checks` config: + +```yaml +section_status_checks: + fastqc: false # Disable all FastQC status bars + mymodule: + section1: false # Disable specific section status bar +``` + +By default, all status bars are enabled. Configuration can be set at the module level (boolean) or per-section level (nested dictionary). For example: diff --git a/docs/markdown/development/plots.md b/docs/markdown/development/plots.md index 0981b5d7c5..f5f65b6d26 100644 --- a/docs/markdown/development/plots.md +++ b/docs/markdown/development/plots.md @@ -226,6 +226,42 @@ html = bargraph.plot([data, data], cats, pconfig=...) Note that, as in this example, the plot data can be the same dictionary supplied twice. +### Grouped stacked bar charts + +Use `sample_groups` to create grouped stacked bar charts where bars are organized into visual groups on the y-axis. The config is a dict mapping group labels to lists of `[sample name, group ID]` pairs: + +- **Group label** (dict key): Displayed on the y-axis +- **Sample name**: The key in the data dict identifying this sample +- **Group ID**: Determines the visual "lane" within each group. Samples with the same ID are aligned vertically across different group labels. + +```python +from multiqc.plots import bargraph + +data = { + 'sample1_25nt': {'Frame0': 50, 'Frame1': 30, 'Frame2': 20}, + 'sample1_26nt': {'Frame0': 60, 'Frame1': 25, 'Frame2': 15}, + 'sample2_25nt': {'Frame0': 55, 'Frame1': 28, 'Frame2': 17}, + 'sample2_26nt': {'Frame0': 65, 'Frame1': 22, 'Frame2': 13}, +} + +pconfig = { + 'id': 'my_bargraph', + 'title': 'My Bar Graph', + 'sample_groups': { + '25nt': [['sample1_25nt', 'sample1'], ['sample2_25nt', 'sample2']], + '26nt': [['sample1_26nt', 'sample1'], ['sample2_26nt', 'sample2']], + } +} + +html = bargraph.plot(data, cats, pconfig=pconfig) +``` + +In this example, for each read length group (`25nt`, `26nt`), bars with the same `offset_group` (`sample1` or `sample2`) are aligned at the same horizontal position, allowing direct visual comparison of sample1 vs sample2 across read lengths. + +![bargraph sample groups](../../../docs/images/bargraph_sample_groups.png) + +See the [custom content example file](https://github.com/MultiQC/test-data/blob/main/data/custom_content/embedded_config/frame_bargraph_mqc.csv) to reproduce this plot. + ## Line graphs This base function works much like the above, but for two-dimensional @@ -262,6 +298,7 @@ pconfig = { "logswitch": False, # Show the 'Log10' switch? "logswitch_active": False, # Initial display with 'Log10' active? "logswitch_label": "Log10", # Label for 'Log10' button + "axis_controlled_by_switches": ["yaxis"], # Which axes should be impacted by the switch button (one or both of xaxis, yaxis) "extra_series": None, # See section below # Plot configuration "title": None, # Plot title - should be in format "Module Name: Plot Title" diff --git a/docs/markdown/development/templates.md b/docs/markdown/development/templates.md index f16769c5ef..34f86cb154 100644 --- a/docs/markdown/development/templates.md +++ b/docs/markdown/development/templates.md @@ -84,6 +84,10 @@ your own `header.html` which will overwrite the default header. Files within the default template have comments at the top explaining what part of the report they generate. +Child templates can also inherit template functions from their parent. For example, +the default template provides the `material_icon` function which can be used +in any child template without additional configuration. + ## Extra init variables There are a few extra variables that can be added to the `__init__.py` file @@ -110,6 +114,19 @@ copy_files = ['assets'] config.plots_force_flat = True ``` +## Development mode + +When developing a template, you can use the `development: true` config option +or the `--development` command line flag. This instructs MultiQC not to embed source files +directly into the HTML and instead link to the MultiQC source code files: + +- JavaScript and CSS files are loaded directly from the source code template directory instead of being embedded +- Plot images are linked from external files rather than being embedded as base64 data URIs +- Plot data is exported as an uncompressed JSON file (`multiqc_plots.js`) in the data directory + +This allows you to see changes to your template files immediately without rebuilding or +recompiling. Simply refresh the report in your browser after making changes. + ## Jinja template variables There are a number of variables that you can use within your Jinja template. @@ -133,6 +150,28 @@ function. For example: ``` +### Material Design Icons + +The default template includes a `material_icon` function that embeds Material Design +Icons as inline SVG. This function is available to child templates that inherit from +the default template. Usage: + +```jinja +{{ material_icon('delete') }} +{{ material_icon('warning', 16) }} +{{ material_icon('info', 20, '#0066cc') }} +``` + +The function takes three parameters: + +- `icon_name` (required): Name of the Material Design Icon (e.g., 'delete', 'info', 'warning') +- `size` (optional, default 24): Size of the icon in pixels +- `color` (optional, default 'currentColor'): Color of the icon + +The function will try to load the filled variant first, then fall back to the outlined +variant if the filled version is not found. If the icon cannot be found, it returns an +empty string. In strict mode (`--strict`), missing icons will be reported as errors. + ## Appendices ### Custom plotting functions diff --git a/docs/markdown/getting_started/config.md b/docs/markdown/getting_started/config.md index ee807e9751..400be65604 100644 --- a/docs/markdown/getting_started/config.md +++ b/docs/markdown/getting_started/config.md @@ -270,7 +270,7 @@ You can pre-configure which samples to show or hide: ```yaml show_hide_buttons: ["Hide controls"] -show_hide_patterns: ["control_"]] +show_hide_patterns: ["control_"] show_hide_regex: [false] show_hide_mode: ["hide"] # can be "show" or "hide" ``` diff --git a/docs/markdown/getting_started/installation.md b/docs/markdown/getting_started/installation.md index 7371fd9bfb..9f9ba9a942 100644 --- a/docs/markdown/getting_started/installation.md +++ b/docs/markdown/getting_started/installation.md @@ -226,7 +226,7 @@ with required dependencies. To build MultiQC, run `nix build`. ### Docker A Docker container is provided on Docker Hub called [`multiqc/multiqc`](https://hub.docker.com/r/multiqc/multiqc/). -It's based on an `python-slim` base image to give the smallest image size possible. +It's based on a `python-slim` base image to give the smallest image size possible. To use, call the `docker run` with your current working directory mounted as a volume and working directory. Then just specify the MultiQC command at the end as usual: @@ -248,6 +248,27 @@ By default, docker will use the `:latest` tag. For MultiQC, this is set to be th To use the most recent development code, use `multiqc/multiqc:dev`. You can also specify specific versions, eg: `multiqc/multiqc:v1.20`. +#### Docker image variants + +MultiQC provides two Docker image variants to suit different needs: + +1. **Standard image** (recommended for most users): `multiqc/multiqc:latest` (~1.5GB) + - Includes all core MultiQC functionality + - Smaller image size for faster downloads and reduced storage + +2. **PDF-enabled image**: `multiqc/multiqc:pdf-latest` (~3.2GB) + - Includes Pandoc and LaTeX (LuaLaTeX) for PDF report generation + - Required if you need to use the `--pdf` flag + - Significantly larger due to LaTeX dependencies + +To use the PDF-enabled image: + +```bash +docker run -t -v `pwd`:`pwd` -w `pwd` multiqc/multiqc:pdf-latest multiqc . --pdf +``` + +Both variants are also available with the `:dev` tag for the latest development version (e.g., `multiqc/multiqc:pdf-dev`), and with specific version tags (e.g., `multiqc/multiqc:pdf-v1.20`). + Note that all files on the command line (eg. config files) must also be mounted in the docker container to be accessible. For more help, look into [the Docker documentation](https://docs.docker.com/engine/reference/commandline/run/). @@ -284,11 +305,12 @@ docker pull --platform linux/arm64 multiqc/multiqc:latest ### GitHub Packages -If you prefer, the Docker image above is also available from [GitHub packages](https://github.com/MultiQC/MultiQC/pkgs/container/multiqc). +If you prefer, the Docker images above are also available from [GitHub packages](https://github.com/MultiQC/MultiQC/pkgs/container/multiqc). Usage is identical, the only difference is that the URI has a `ghcr.io/` prefix: ```bash docker pull ghcr.io/multiqc/multiqc +docker pull ghcr.io/multiqc/multiqc:pdf-latest ``` This image was also renamed, versions up to v1.19 can be found at [`ghcr.io/ewels/multiqc`](https://github.com/users/ewels/packages/container/package/multiqc). diff --git a/docs/markdown/getting_started/running_multiqc.md b/docs/markdown/getting_started/running_multiqc.md index bdc8c74b81..3c1bc56d98 100644 --- a/docs/markdown/getting_started/running_multiqc.md +++ b/docs/markdown/getting_started/running_multiqc.md @@ -199,6 +199,16 @@ missing. You can always save static image versions of plots from within MultiQC reports, using the [Export toolbox](../reports#exporting-plots) in the side bar. ::: +### Export timeout + +Static plot generation uses [Kaleido](https://github.com/plotly/Kaleido) under the hood, +which can occasionally hang. To prevent this from blocking report generation indefinitely, +MultiQC applies a timeout to each plot export. If the timeout is exceeded, the plot export +is skipped and report generation continues. + +The default timeout is 60 seconds per plot. You can adjust this with the `export_plots_timeout` +config option. + ## PDF Reports Whilst HTML is definitely the format of choice for MultiQC reports due to @@ -237,6 +247,10 @@ for example if the lualatex dependency is not installed you will see the followi lualatex not found. Please select a different --pdf-engine or install lualatex ``` +:::tip{title="Using Docker for PDF generation"} +If you're using Docker, a PDF-enabled image is available that includes all required dependencies (Pandoc and LaTeX). See the [Docker installation documentation](installation.md#docker-image-variants) for details on using `multiqc/multiqc:pdf-latest`. +::: + Note that not all plots have flat image equivalents, so some will be missing (at time of writing: FastQC sequence content plot, beeswarm dot plots, heatmaps). diff --git a/docs/markdown/modules.mdx b/docs/markdown/modules.mdx index d04382e7d0..431980e9cc 100644 --- a/docs/markdown/modules.mdx +++ b/docs/markdown/modules.mdx @@ -10,7 +10,7 @@ This file is autogenerated. Do not edit the markdown, it will be overwritten. ~~~~~~~~~~~~~~~~~~~~~~~ --> -MultiQC currently has modules to support 167 bioinformatics tools, listed below. +MultiQC currently has modules to support 171 bioinformatics tools, listed below. Click the tool name to go to the MultiQC documentation for that tool. @@ -659,6 +659,13 @@ import MultiqcModules from "@site/src/components/MultiqcModules"; data: { name: "QualiMap", summary: "Quality control of alignment data and its derivatives like feature counts." }, }, { id: "modules/quast", data: { name: "QUAST", summary: "Quality assessment tool for genome assemblies." } }, + { + id: "modules/ribotish", + data: { + name: "Ribo-TISH", + summary: "Identifies translated ORFs from Ribo-seq data and reports reading frame quality metrics.", + }, + }, { id: "modules/rna_seqc", data: { name: "RNA-SeQC", summary: "RNA-Seq metrics for quality control and process optimization." }, @@ -697,6 +704,10 @@ import MultiqcModules from "@site/src/components/MultiqcModules"; data: { name: "Seqera Platform CLI", summary: "Reports statistics generated by the Seqera Platform CLI." }, }, { id: "modules/seqfu", data: { name: "Seqfu", summary: "Manipulate FASTA/FASTQ files." } }, + { + id: "modules/seqkit", + data: { name: "SeqKit", summary: "Cross-platform and ultrafast toolkit for FASTA/Q file manipulation." }, + }, { id: "modules/sequali", data: { name: "Sequali", summary: "Sequencing quality control for both long-read and short-read data." }, @@ -752,6 +763,10 @@ import MultiqcModules from "@site/src/components/MultiqcModules"; summary: "Genotype to pedigree correspondence checks from sketches derived from BAM/CRAM or VCF.", }, }, + { + id: "modules/sompy", + data: { name: "som.py", summary: "Benchmarks somatic variant calls against gold standard truth datasets." }, + }, { id: "modules/sortmerna", data: { @@ -779,6 +794,7 @@ import MultiqcModules from "@site/src/components/MultiqcModules"; id: "modules/supernova", data: { name: "Supernova", summary: "De novo genome assembler of 10X Genomics linked-reads." }, }, + { id: "modules/sylphtax", data: { name: "Sylph-tax", summary: "Taxonomic profiling of metagenomic reads." } }, { id: "modules/telseq", data: { name: "telseq", summary: "Estimates telomere length from whole genome sequencing data (BAMs)." }, diff --git a/docs/markdown/modules/adapterremoval.md b/docs/markdown/modules/adapterremoval.md index 8ca22ca30d..44731ee51f 100644 --- a/docs/markdown/modules/adapterremoval.md +++ b/docs/markdown/modules/adapterremoval.md @@ -2,7 +2,7 @@ title: Adapter Removal displayed_sidebar: multiqcSidebar description: > -

Removes adapter sequences, trims low quality bases from 3' ends, or merges overlapping pairs into consensus.

+

Removes adapter sequences, trims low quality bases from 3' ends, or merges overlapping pairs into consensus.

--- :::note -

Removes adapter sequences, trims low quality bases from 3' ends, or merges overlapping pairs into consensus.

[https://github.com/mikkelschubert/adapterremoval](https://github.com/mikkelschubert/adapterremoval) @@ -36,6 +35,7 @@ Supported setting file results: ```yaml adapterremoval: contents: AdapterRemoval - fn: "*.settings" + fn: '*.settings' num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/afterqc.md b/docs/markdown/modules/afterqc.md index 2b87a3d3a5..ce21e713cb 100644 --- a/docs/markdown/modules/afterqc.md +++ b/docs/markdown/modules/afterqc.md @@ -2,7 +2,7 @@ title: AfterQC displayed_sidebar: multiqcSidebar description: > -

Automatic filtering, trimming, error removing, and quality control for FastQ data.

+

Automatic filtering, trimming, error removing, and quality control for FastQ data.

--- :::note -

Automatic filtering, trimming, error removing, and quality control for FastQ data.

[https://github.com/OpenGene/AfterQC](https://github.com/OpenGene/AfterQC) @@ -29,6 +28,7 @@ which contains good reads, bad reads and the QC results of each fastq file/pair. ```yaml afterqc: contents: allow_mismatch_in_poly - fn: "*.json" + fn: '*.json' num_lines: 10000 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/anglerfish.md b/docs/markdown/modules/anglerfish.md index aa1b144a16..29720eea47 100644 --- a/docs/markdown/modules/anglerfish.md +++ b/docs/markdown/modules/anglerfish.md @@ -2,7 +2,7 @@ title: Anglerfish displayed_sidebar: multiqcSidebar description: > -

Quality controls Illumina libraries sequenced on Oxford Nanopore flowcells.

+

Quality controls Illumina libraries sequenced on Oxford Nanopore flowcells.

--- :::note -

Quality controls Illumina libraries sequenced on Oxford Nanopore flowcells.

[https://github.com/remiolsen/anglerfish](https://github.com/remiolsen/anglerfish) @@ -28,5 +27,6 @@ Assessment of pool balancing, contamination, and insert sizes are currently supp ```yaml anglerfish: contents: anglerfish_version - fn: "*.json" + fn: '*.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/ataqv.md b/docs/markdown/modules/ataqv.md index 8f42d994f9..c9bcac4ed0 100644 --- a/docs/markdown/modules/ataqv.md +++ b/docs/markdown/modules/ataqv.md @@ -2,7 +2,7 @@ title: ATAQV displayed_sidebar: multiqcSidebar description: > -

Toolkit for quality control and visualization of ATAC-seq data.

+

Toolkit for quality control and visualization of ATAC-seq data.

--- :::note -

Toolkit for quality control and visualization of ATAC-seq data.

[https://github.com/ParkerLab/ataqv/](https://github.com/ParkerLab/ataqv/) @@ -26,6 +25,7 @@ File path for the source of this content: multiqc/modules/ataqv/ataqv.py ```yaml ataqv: contents: ataqv_version - fn: "*.json" + fn: '*.json' num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bakta.md b/docs/markdown/modules/bakta.md index 029c2d9b19..3bebd6c939 100644 --- a/docs/markdown/modules/bakta.md +++ b/docs/markdown/modules/bakta.md @@ -2,7 +2,7 @@ title: Bakta displayed_sidebar: multiqcSidebar description: > -

Rapid & standardized annotation of bacterial genomes, MAGs & plasmids.

+

Rapid & standardized annotation of bacterial genomes, MAGs & plasmids.

--- :::note -

Rapid & standardized annotation of bacterial genomes, MAGs & plasmids.

[https://github.com/oschwengers/bakta](https://github.com/oschwengers/bakta) @@ -29,6 +28,7 @@ the output of v1.7.0. ```yaml bakta: - contents: "Bakta:" - fn: "*.txt" + contents: 'Bakta:' + fn: '*.txt' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bamdst.md b/docs/markdown/modules/bamdst.md index ed6bd6d00f..6e6a54c683 100644 --- a/docs/markdown/modules/bamdst.md +++ b/docs/markdown/modules/bamdst.md @@ -2,7 +2,7 @@ title: Bamdst displayed_sidebar: multiqcSidebar description: > -

Lightweight tool to stat the depth coverage of target regions of BAM file(s).

+

Lightweight tool to stat the depth coverage of target regions of BAM file(s).

--- :::note -

Lightweight tool to stat the depth coverage of target regions of BAM file(s).

[https://https://github.com/shiquan/bamdst](https://https://github.com/shiquan/bamdst) @@ -85,6 +84,7 @@ This is disabled by default as there can be very many in some cases. ```yaml bamdst/coverage: - contents: "## The file was created by bamdst" + contents: '## The file was created by bamdst' num_lines: 5 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bamtools.md b/docs/markdown/modules/bamtools.md index c3bd383ce2..cd97f3280d 100644 --- a/docs/markdown/modules/bamtools.md +++ b/docs/markdown/modules/bamtools.md @@ -2,7 +2,7 @@ title: Bamtools displayed_sidebar: multiqcSidebar description: > -

Provides both a programmer's API and an end-user's toolkit for handling BAM files.

+

Provides both a programmer's API and an end-user's toolkit for handling BAM files.

--- :::note -

Provides both a programmer's API and an end-user's toolkit for handling BAM files.

[https://github.com/pezmaster31/bamtools](https://github.com/pezmaster31/bamtools) @@ -29,6 +28,7 @@ Supported commands: `stats` ```yaml bamtools/stats: - contents: "Stats for BAM file(s):" + contents: 'Stats for BAM file(s):' num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bases2fastq.md b/docs/markdown/modules/bases2fastq.md index a929fb504a..48f175da8a 100644 --- a/docs/markdown/modules/bases2fastq.md +++ b/docs/markdown/modules/bases2fastq.md @@ -2,7 +2,7 @@ title: Bases2Fastq displayed_sidebar: multiqcSidebar description: > -

Demultiplexes and converts Element AVITI base calls into FASTQ files.

+

Demultiplexes and converts Element AVITI base calls into FASTQ files.

--- :::note -

Demultiplexes and converts Element AVITI base calls into FASTQ files.

[https://docs.elembio.io/docs/bases2fastq/introduction/](https://docs.elembio.io/docs/bases2fastq/introduction/) @@ -26,7 +25,7 @@ File path for the source of this content: multiqc/modules/bases2fastq/bases2fast ```yaml bases2fastq/project: contents: SampleStats - fn: "*_RunStats.json" + fn: '*_RunStats.json' num_lines: 100 bases2fastq/run: contents: SampleStats @@ -37,3 +36,4 @@ bases2fastq/manifest: fn: RunManifest.json num_lines: 100 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bbduk.md b/docs/markdown/modules/bbduk.md index 7f3e6fa1b6..2e891f342c 100644 --- a/docs/markdown/modules/bbduk.md +++ b/docs/markdown/modules/bbduk.md @@ -2,7 +2,7 @@ title: BBDuk displayed_sidebar: multiqcSidebar description: > -

Common data-quality-related trimming, filtering, and masking operations with a kmer based approach.

+

Common data-quality-related trimming, filtering, and masking operations with a kmer based approach.

--- :::note -

Common data-quality-related trimming, filtering, and masking operations with a kmer based approach.

[https://jgi.doe.gov/data-and-tools/software-tools/bbtools/bb-tools-user-guide/bbduk-guide/](https://jgi.doe.gov/data-and-tools/software-tools/bbtools/bb-tools-user-guide/bbduk-guide/) @@ -47,3 +46,4 @@ bbduk: contents: Executing jgi.BBDuk num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bbmap.md b/docs/markdown/modules/bbmap.md index f5a9191ea0..3b705851fc 100644 --- a/docs/markdown/modules/bbmap.md +++ b/docs/markdown/modules/bbmap.md @@ -2,7 +2,7 @@ title: BBTools displayed_sidebar: multiqcSidebar description: > -

Pre-processing, assembly, alignment, and statistics tools for DNA/RNA sequencing reads.

+

Pre-processing, assembly, alignment, and statistics tools for DNA/RNA sequencing reads.

--- :::note -

Pre-processing, assembly, alignment, and statistics tools for DNA/RNA sequencing reads.

[http://jgi.doe.gov/data-and-tools/bbtools/](http://jgi.doe.gov/data-and-tools/bbtools/) @@ -40,8 +39,8 @@ The module can summarise data from the following BBMap output files - 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` @@ -72,6 +71,9 @@ Additional information on the BBMap tools is available on bbmap/aqhist: contents: "#Quality\tcount1\tfraction1\tcount2\tfraction2" num_lines: 10 +bbmap/bbsplit: + contents: "#name\t%unambiguousReads\tunambiguousMB\t%ambiguousReads" + num_lines: 5 bbmap/bhist: contents: "#Pos\tA\tC\tG\tT\tN" num_lines: 10 @@ -93,18 +95,18 @@ bbmap/ehist: num_lines: 10 bbmap/gchist: contents: - - "#Mean\t" - - "#GC\tCount" + - "#Mean\t" + - "#GC\tCount" num_lines: 10 bbmap/idhist: contents: - - "#Mean_reads" - - "#Identity\tReads\tBases" + - '#Mean_reads' + - "#Identity\tReads\tBases" num_lines: 10 bbmap/ihist: contents: - - "#Mean\t" - - "#InsertSize\tCount" + - "#Mean\t" + - "#InsertSize\tCount" num_lines: 10 bbmap/indelhist: contents: "#Length\tDeletions\tInsertions" @@ -127,27 +129,28 @@ bbmap/qhist: num_lines: 10 bbmap/rpkm: contents: - - "#File\t" - - "#Reads\t" - - "#Mapped\t" - - "#RefSequences\t" - - "#Name Length" + - "#File\t" + - "#Reads\t" + - "#Mapped\t" + - "#RefSequences\t" + - '#Name Length' num_lines: 10 bbmap/stats: contents: - - "#File" - - "#Total" - - "#Matched" - - "#Name\tReads\tReadsPct" + - '#File' + - '#Total' + - '#Matched' + - "#Name\tReads\tReadsPct" num_lines: 10 bbmap/statsfile: contents: - - "Reads Used:" - - "Mapping:" - - "Reads/sec:" - - "kBases/sec:" + - 'Reads Used:' + - 'Mapping:' + - 'Reads/sec:' + - 'kBases/sec:' num_lines: 10 bbmap/statsfile_machine: contents: Reads Used= num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bcftools.md b/docs/markdown/modules/bcftools.md index e414d3c75d..9759c1e4ba 100644 --- a/docs/markdown/modules/bcftools.md +++ b/docs/markdown/modules/bcftools.md @@ -2,7 +2,7 @@ title: Bcftools displayed_sidebar: multiqcSidebar description: > -

Utilities for variant calling and manipulating VCFs and BCFs.

+

Utilities for variant calling and manipulating VCFs and BCFs.

--- :::note -

Utilities for variant calling and manipulating VCFs and BCFs.

[https://samtools.github.io/bcftools/](https://samtools.github.io/bcftools/) @@ -44,3 +43,4 @@ in the resulting plot. bcftools/stats: contents: This file was produced by bcftools stats ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bcl2fastq.md b/docs/markdown/modules/bcl2fastq.md index a4abf44c06..f881af5537 100644 --- a/docs/markdown/modules/bcl2fastq.md +++ b/docs/markdown/modules/bcl2fastq.md @@ -2,7 +2,7 @@ title: bcl2fastq displayed_sidebar: multiqcSidebar description: > -

Demultiplexes data and converts BCL files to FASTQ file formats for downstream analysis.

+

Demultiplexes data and converts BCL files to FASTQ file formats for downstream analysis.

--- :::note -

Demultiplexes data and converts BCL files to FASTQ file formats for downstream analysis.

[https://support.illumina.com/sequencing/sequencing_software/bcl2fastq-conversion-software.html](https://support.illumina.com/sequencing/sequencing_software/bcl2fastq-conversion-software.html) @@ -34,3 +33,4 @@ bcl2fastq: fn: Stats.json num_lines: 300 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bclconvert.md b/docs/markdown/modules/bclconvert.md index 63c06a49c8..a1a25e9849 100644 --- a/docs/markdown/modules/bclconvert.md +++ b/docs/markdown/modules/bclconvert.md @@ -2,7 +2,7 @@ title: BCL Convert displayed_sidebar: multiqcSidebar description: > -

Demultiplexes data and converts BCL files to FASTQ file formats for downstream analysis.

+

Demultiplexes data and converts BCL files to FASTQ file formats for downstream analysis.

--- :::note -

Demultiplexes data and converts BCL files to FASTQ file formats for downstream analysis.

[https://support.illumina.com/sequencing/sequencing_software/bcl-convert.html](https://support.illumina.com/sequencing/sequencing_software/bcl-convert.html) @@ -73,3 +72,4 @@ bclconvert/runinfo: bclconvert/unknown_barcodes: fn: Top_Unknown_Barcodes.csv ``` + \ No newline at end of file diff --git a/docs/markdown/modules/biobambam2.md b/docs/markdown/modules/biobambam2.md index 6566ff1b5f..342858e8d8 100644 --- a/docs/markdown/modules/biobambam2.md +++ b/docs/markdown/modules/biobambam2.md @@ -2,7 +2,7 @@ title: biobambam2 displayed_sidebar: multiqcSidebar description: > -

Tools for early stage alignment file processing.

+

Tools for early stage alignment file processing.

--- :::note -

Tools for early stage alignment file processing.

[https://gitlab.com/german.tischler/biobambam2](https://gitlab.com/german.tischler/biobambam2) @@ -33,6 +32,7 @@ as all other MultiQC modules. ```yaml biobambam2/bamsormadup: - contents: "# bamsormadup" + contents: '# bamsormadup' num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/biobloomtools.md b/docs/markdown/modules/biobloomtools.md index 1b0417ffc0..20456136e6 100644 --- a/docs/markdown/modules/biobloomtools.md +++ b/docs/markdown/modules/biobloomtools.md @@ -2,7 +2,7 @@ title: BioBloom Tools displayed_sidebar: multiqcSidebar description: > -

Assigns reads to different references using bloom filters. This is faster than alignment and can be used for contamination detection.

+

Assigns reads to different references using bloom filters. This is faster than alignment and can be used for contamination detection.

--- :::note -

Assigns reads to different references using bloom filters. This is faster than alignment and can be used for contamination detection.

[https://github.com/bcgsc/biobloom/](https://github.com/bcgsc/biobloom/) @@ -33,3 +32,4 @@ biobloomtools: contents: "filter_id\thits\tmisses\tshared\trate_hit\trate_miss\trate_shared" num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/biscuit.md b/docs/markdown/modules/biscuit.md index 1bbdc65ddc..1f9da35f85 100644 --- a/docs/markdown/modules/biscuit.md +++ b/docs/markdown/modules/biscuit.md @@ -2,7 +2,7 @@ title: BISCUIT displayed_sidebar: multiqcSidebar description: > -

Maps bisulfite converted DNA sequence reads and determines cytosine methylation states.

+

Maps bisulfite converted DNA sequence reads and determines cytosine methylation states.

--- :::note -

Maps bisulfite converted DNA sequence reads and determines cytosine methylation states.

[https://github.com/huishenlab/biscuit](https://github.com/huishenlab/biscuit) ::: -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. @@ -40,54 +41,55 @@ in the documentation. ```yaml biscuit/align_isize: contents: BISCUITqc Insert Size Table - fn: "*_isize_table.txt" + fn: '*_isize_table.txt' num_lines: 3 biscuit/align_mapq: contents: BISCUITqc Mapping Quality Table - fn: "*_mapq_table.txt" + fn: '*_mapq_table.txt' num_lines: 3 biscuit/align_strand: contents: BISCUITqc Strand Table - fn: "*_strand_table.txt" + fn: '*_strand_table.txt' num_lines: 3 biscuit/base_avg_retention_rate: - fn: "*_totalBaseConversionRate.txt" + fn: '*_totalBaseConversionRate.txt' biscuit/covdist_all_base: - fn: "*_covdist_all_base_table.txt" + fn: '*_covdist_all_base_table.txt' biscuit/covdist_all_base_botgc: - fn: "*_covdist_all_base_botgc_table.txt" + fn: '*_covdist_all_base_botgc_table.txt' biscuit/covdist_all_base_topgc: - fn: "*_covdist_all_base_topgc_table.txt" + fn: '*_covdist_all_base_topgc_table.txt' biscuit/covdist_all_cpg: - fn: "*_covdist_all_cpg_table.txt" + fn: '*_covdist_all_cpg_table.txt' biscuit/covdist_all_cpg_botgc: - fn: "*_covdist_all_cpg_botgc_table.txt" + fn: '*_covdist_all_cpg_botgc_table.txt' biscuit/covdist_all_cpg_topgc: - fn: "*_covdist_all_cpg_topgc_table.txt" + fn: '*_covdist_all_cpg_topgc_table.txt' biscuit/covdist_q40_base: - fn: "*_covdist_q40_base_table.txt" + fn: '*_covdist_q40_base_table.txt' biscuit/covdist_q40_base_botgc: - fn: "*_covdist_q40_base_botgc_table.txt" + fn: '*_covdist_q40_base_botgc_table.txt' biscuit/covdist_q40_base_topgc: - fn: "*_covdist_q40_base_topgc_table.txt" + fn: '*_covdist_q40_base_topgc_table.txt' biscuit/covdist_q40_cpg: - fn: "*_covdist_q40_cpg_table.txt" + fn: '*_covdist_q40_cpg_table.txt' biscuit/covdist_q40_cpg_botgc: - fn: "*_covdist_q40_cpg_botgc_table.txt" + fn: '*_covdist_q40_cpg_botgc_table.txt' biscuit/covdist_q40_cpg_topgc: - fn: "*_covdist_q40_cpg_topgc_table.txt" + fn: '*_covdist_q40_cpg_topgc_table.txt' biscuit/cpg_retention_readpos: - fn: "*_CpGRetentionByReadPos.txt" + fn: '*_CpGRetentionByReadPos.txt' biscuit/cph_retention_readpos: - fn: "*_CpHRetentionByReadPos.txt" + fn: '*_CpHRetentionByReadPos.txt' biscuit/dup_report: contents: BISCUITqc Read Duplication Table - fn: "*_dup_report.txt" + fn: '*_dup_report.txt' num_lines: 3 biscuit/qc_cv: contents: BISCUITqc Uniformity Table - fn: "*_cv_table.txt" + fn: '*_cv_table.txt' num_lines: 3 biscuit/read_avg_retention_rate: - fn: "*_totalReadConversionRate.txt" + fn: '*_totalReadConversionRate.txt' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bismark.md b/docs/markdown/modules/bismark.md index 49c94cab92..a30b18a351 100644 --- a/docs/markdown/modules/bismark.md +++ b/docs/markdown/modules/bismark.md @@ -2,7 +2,7 @@ title: Bismark displayed_sidebar: multiqcSidebar description: > -

Maps bisulfite converted sequence reads and determine cytosine methylation states.

+

Maps bisulfite converted sequence reads and determine cytosine methylation states.

--- :::note -

Maps bisulfite converted sequence reads and determine cytosine methylation states.

[http://www.bioinformatics.babraham.ac.uk/projects/bismark/](http://www.bioinformatics.babraham.ac.uk/projects/bismark/) @@ -25,13 +24,14 @@ File path for the source of this content: multiqc/modules/bismark/bismark.py ```yaml bismark/align: - fn: "*_[SP]E_report.txt" + fn: '*_[SP]E_report.txt' bismark/bam2nuc: - fn: "*.nucleotide_stats.txt" + fn: '*.nucleotide_stats.txt' bismark/dedup: - fn: "*.deduplication_report.txt" + fn: '*.deduplication_report.txt' bismark/m_bias: - fn: "*M-bias.txt" + fn: '*M-bias.txt' bismark/meth_extract: - fn: "*_splitting_report.txt" + fn: '*_splitting_report.txt' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bowtie1.md b/docs/markdown/modules/bowtie1.md index 3dd954ecfe..fc52367aae 100644 --- a/docs/markdown/modules/bowtie1.md +++ b/docs/markdown/modules/bowtie1.md @@ -2,7 +2,7 @@ title: Bowtie 1 displayed_sidebar: multiqcSidebar description: > -

Ultrafast, memory-efficient short read aligner.

+

Ultrafast, memory-efficient short read aligner.

--- :::note -

Ultrafast, memory-efficient short read aligner.

[http://bowtie-bio.sourceforge.net/](http://bowtie-bio.sourceforge.net/) @@ -25,15 +24,16 @@ File path for the source of this content: multiqc/modules/bowtie1/bowtie1.py ```yaml bowtie1: - contents: "# reads processed:" + contents: '# reads processed:' exclude_fn: - - bowtie.left_kept_reads.log - - bowtie.left_kept_reads.m2g_um.log - - bowtie.left_kept_reads.m2g_um_seg1.log - - bowtie.left_kept_reads.m2g_um_seg2.log - - bowtie.right_kept_reads.log - - bowtie.right_kept_reads.m2g_um.log - - bowtie.right_kept_reads.m2g_um_seg1.log - - bowtie.right_kept_reads.m2g_um_seg2.log + - bowtie.left_kept_reads.log + - bowtie.left_kept_reads.m2g_um.log + - bowtie.left_kept_reads.m2g_um_seg1.log + - bowtie.left_kept_reads.m2g_um_seg2.log + - bowtie.right_kept_reads.log + - bowtie.right_kept_reads.m2g_um.log + - bowtie.right_kept_reads.m2g_um_seg1.log + - bowtie.right_kept_reads.m2g_um_seg2.log shared: true ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bowtie2.md b/docs/markdown/modules/bowtie2.md index 91ec29b9ef..7f1829aae0 100644 --- a/docs/markdown/modules/bowtie2.md +++ b/docs/markdown/modules/bowtie2.md @@ -2,7 +2,7 @@ title: Bowtie 2 / HiSAT2 displayed_sidebar: multiqcSidebar description: > -

Results from both Bowtie 2 and HISAT2, tools for aligning reads against a reference genome.

+

Results from both Bowtie 2 and HISAT2, tools for aligning reads against a reference genome.

--- :::note -

Results from both Bowtie 2 and HISAT2, tools for aligning reads against a reference genome.

[http://bowtie-bio.sourceforge.net/bowtie2/](http://bowtie-bio.sourceforge.net/bowtie2/), [https://ccb.jhu.edu/software/hisat2/](https://ccb.jhu.edu/software/hisat2/) @@ -58,9 +57,10 @@ Bowtie 2 and HISAT2 are used by other tools too, so if your log file contains th ```yaml bowtie2: - contents: "reads; of these:" + contents: 'reads; of these:' exclude_contents: - - bisulfite - - HiC-Pro + - bisulfite + - HiC-Pro shared: true ``` + \ No newline at end of file diff --git a/docs/markdown/modules/busco.md b/docs/markdown/modules/busco.md index d0c886244b..8ecb4c2dc5 100644 --- a/docs/markdown/modules/busco.md +++ b/docs/markdown/modules/busco.md @@ -2,7 +2,7 @@ title: BUSCO displayed_sidebar: multiqcSidebar description: > -

Assesses genome assembly and annotation completeness.

+

Assesses genome assembly and annotation completeness.

--- :::note -

Assesses genome assembly and annotation completeness.

[http://busco.ezlab.org/](http://busco.ezlab.org/) @@ -34,7 +33,8 @@ output from BUSCO v1.22 - v2. ```yaml busco: - contents: "BUSCO version is:" + contents: 'BUSCO version is:' fn: short_summary* num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/bustools.md b/docs/markdown/modules/bustools.md index 66e8f84fee..9ba3ebeb71 100644 --- a/docs/markdown/modules/bustools.md +++ b/docs/markdown/modules/bustools.md @@ -2,7 +2,7 @@ title: Bustools displayed_sidebar: multiqcSidebar description: > -

Tools for BUS files - a file format for single-cell RNA-seq data designed to facilitate the development of modular workflows for data processing.

+

Tools for BUS files - a file format for single-cell RNA-seq data designed to facilitate the development of modular workflows for data processing.

--- :::note -

Tools for BUS files - a file format for single-cell RNA-seq data designed to facilitate the development of modular workflows for data processing.

[https://bustools.github.io/](https://bustools.github.io/) @@ -31,5 +30,6 @@ way as all other MultiQC modules. ```yaml bustools: - fn: "*inspect.json" + fn: '*inspect.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/ccs.md b/docs/markdown/modules/ccs.md index 738bdd1538..55d0b672ab 100644 --- a/docs/markdown/modules/ccs.md +++ b/docs/markdown/modules/ccs.md @@ -2,7 +2,7 @@ title: CCS displayed_sidebar: multiqcSidebar description: > -

PacBio tool that generates highly accurate single-molecule consensus reads (HiFi Reads).

+

PacBio tool that generates highly accurate single-molecule consensus reads (HiFi Reads).

--- :::note -

PacBio tool that generates highly accurate single-molecule consensus reads (HiFi Reads).

[https://github.com/PacificBiosciences/ccs](https://github.com/PacificBiosciences/ccs) @@ -35,5 +34,6 @@ ccs/v4: num_lines: 2 ccs/v5: contents: '"id": "ccs_processing"' - fn: "*.json" + fn: '*.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/cellranger.md b/docs/markdown/modules/cellranger.md index 4101fb0a21..4ee5206522 100644 --- a/docs/markdown/modules/cellranger.md +++ b/docs/markdown/modules/cellranger.md @@ -2,7 +2,7 @@ title: Cell Ranger displayed_sidebar: multiqcSidebar description: > -

Analyzes single cell expression or VDJ data produced by 10X Genomics.

+

Analyzes single cell expression or VDJ data produced by 10X Genomics.

--- :::note -

Analyzes single cell expression or VDJ data produced by 10X Genomics.

[https://support.10xgenomics.com/single-cell-gene-expression/software/pipelines/latest/what-is-cell-ranger](https://support.10xgenomics.com/single-cell-gene-expression/software/pipelines/latest/what-is-cell-ranger) @@ -42,17 +41,18 @@ If present in the original report, any warning is reported as well. ```yaml cellranger/count_html: - - contents: '"command":"Cell Ranger","subcommand":"count"' - fn: "*.html" - num_lines: 20 - - contents: '"command": "Cell Ranger", "subcommand": "count"' - fn: "*.html" - num_lines: 20 +- contents: '"command":"Cell Ranger","subcommand":"count"' + fn: '*.html' + num_lines: 20 +- contents: '"command": "Cell Ranger", "subcommand": "count"' + fn: '*.html' + num_lines: 20 cellranger/vdj_html: - - contents: '"command":"Cell Ranger","subcommand":"vdj"' - fn: "*.html" - num_lines: 20 - - contents: '"command": "Cell Ranger", "subcommand": "vdj"' - fn: "*.html" - num_lines: 20 +- contents: '"command":"Cell Ranger","subcommand":"vdj"' + fn: '*.html' + num_lines: 20 +- contents: '"command": "Cell Ranger", "subcommand": "vdj"' + fn: '*.html' + num_lines: 20 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/cellranger_arc.md b/docs/markdown/modules/cellranger_arc.md index a092c25957..384c75e363 100644 --- a/docs/markdown/modules/cellranger_arc.md +++ b/docs/markdown/modules/cellranger_arc.md @@ -2,7 +2,7 @@ title: Cell Ranger ARC displayed_sidebar: multiqcSidebar description: > -

Analyzes single-cell multiome ATAC and gene expression data produced by 10X Genomics.

+

Analyzes single-cell multiome ATAC and gene expression data produced by 10X Genomics.

--- :::note -

Analyzes single-cell multiome ATAC and gene expression data produced by 10X Genomics.

[https://www.10xgenomics.com/support/software/cell-ranger-arc/latest](https://www.10xgenomics.com/support/software/cell-ranger-arc/latest) @@ -43,7 +42,8 @@ If present in the original report, any warning is reported as well. ```yaml cellranger_arc: - - contents: Cell Ranger ARC - fn: "*.html" - num_lines: 250 +- contents: Cell Ranger ARC + fn: '*.html' + num_lines: 250 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/cells2stats.md b/docs/markdown/modules/cells2stats.md index 51b6d7584e..fd25f0adbd 100644 --- a/docs/markdown/modules/cells2stats.md +++ b/docs/markdown/modules/cells2stats.md @@ -2,7 +2,7 @@ title: cells2stats displayed_sidebar: multiqcSidebar description: > -

Generate output files and statistics from Element Biosciences Teton cytoprofiling assays.

+

Generate output files and statistics from Element Biosciences Teton cytoprofiling assays.

--- :::note -

Generate output files and statistics from Element Biosciences Teton cytoprofiling assays.

[https://docs.elembio.io/docs/cells2stats/introduction/](https://docs.elembio.io/docs/cells2stats/introduction/) @@ -29,3 +28,4 @@ cells2stats/run: fn: RunStats.json num_lines: 100 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/checkm.md b/docs/markdown/modules/checkm.md index 2f66b4c438..6b5d2a9e53 100644 --- a/docs/markdown/modules/checkm.md +++ b/docs/markdown/modules/checkm.md @@ -2,7 +2,7 @@ title: CheckM displayed_sidebar: multiqcSidebar description: > -

Estimates genome completeness and contamination based on the presence or absence of marker genes.

+

Estimates genome completeness and contamination based on the presence or absence of marker genes.

--- :::note -

Estimates genome completeness and contamination based on the presence or absence of marker genes.

[https://github.com/Ecogenomics/CheckM](https://github.com/Ecogenomics/CheckM) ::: The module parses the output files generated by CheckM. -It will only parse an output file from `checkm lineage_wf`, `checkm taxonomy_wf`, and `checkm qa`. -The output file needs to be in format 1 (`-o 1`). -All statistics for all samples are saved to `multiqc_data/checkm-table.txt`. + It will only parse an output file from `checkm lineage_wf`, `checkm taxonomy_wf`, and `checkm qa`. + The output file needs to be in format 1 (`-o 1`). + All statistics for all samples are saved to `multiqc_data/checkm-table.txt`. Tested with CheckM v1.2.1 @@ -32,7 +31,8 @@ All statistics for all samples are saved to `multiqc_data/checkm-table.txt`. ```yaml checkm: - - contents_re: ".*Bin Id(?:\t| {3,})Marker lineage(?:\t| {3,})# genomes(?:\t| {3,})#\ - \ markers(?:\t| {3,})# marker sets.*" - num_lines: 10 +- contents_re: ".*Bin Id(?:\t| {3,})Marker lineage(?:\t| {3,})# genomes(?:\t| {3,})#\ + \ markers(?:\t| {3,})# marker sets.*" + num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/checkm2.md b/docs/markdown/modules/checkm2.md index c17abd1e31..0fe13b4ced 100644 --- a/docs/markdown/modules/checkm2.md +++ b/docs/markdown/modules/checkm2.md @@ -2,7 +2,7 @@ title: CheckM2 displayed_sidebar: multiqcSidebar description: > -

Assesses microbial genome quality using machine learning.

+

Assesses microbial genome quality using machine learning.

--- :::note -

Assesses microbial genome quality using machine learning.

[https://github.com/chklovski/CheckM2](https://github.com/chklovski/CheckM2) ::: The module parses the `quality_report.tsv` files generated by CheckM2. -All statistics for all samples are saved to `multiqc_data/checkm2-first-table.txt`. + All statistics for all samples are saved to `multiqc_data/checkm2-first-table.txt`. Tested with CheckM2 v1.0.1 and v1.0.2 @@ -33,3 +32,4 @@ checkm2: contents: "Name\tCompleteness\tContamination\tCompleteness_Model_Used\tTranslation_Table_Used" num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/checkqc.md b/docs/markdown/modules/checkqc.md index d1a6d1d129..6411e203bd 100644 --- a/docs/markdown/modules/checkqc.md +++ b/docs/markdown/modules/checkqc.md @@ -2,7 +2,7 @@ title: CheckQC displayed_sidebar: multiqcSidebar description: > -

Checks a set of quality criteria against an Illumina runfolder.

+

Checks a set of quality criteria against an Illumina runfolder.

--- :::note -

Checks a set of quality criteria against an Illumina runfolder.

[https://github.com/Molmed/checkQC](https://github.com/Molmed/checkQC) @@ -28,5 +27,6 @@ The module parses a CheckQC JSON file, so make sure to use CheckQC with the `--j ```yaml checkqc: contents: instrument_and_reagent_type - fn: "*.json" + fn: '*.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/clipandmerge.md b/docs/markdown/modules/clipandmerge.md index 59e3ca6665..a2e36e11ad 100644 --- a/docs/markdown/modules/clipandmerge.md +++ b/docs/markdown/modules/clipandmerge.md @@ -2,7 +2,7 @@ title: ClipAndMerge displayed_sidebar: multiqcSidebar description: > -

Adapter clipping and read merging for ancient DNA data.

+

Adapter clipping and read merging for ancient DNA data.

--- :::note -

Adapter clipping and read merging for ancient DNA data.

[http://www.github.com/apeltzer/ClipAndMerge](http://www.github.com/apeltzer/ClipAndMerge) @@ -31,3 +30,4 @@ clipandmerge: contents: ClipAndMerge ( num_lines: 5 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/clusterflow.md b/docs/markdown/modules/clusterflow.md index 821399e84f..554d2a4272 100644 --- a/docs/markdown/modules/clusterflow.md +++ b/docs/markdown/modules/clusterflow.md @@ -2,7 +2,7 @@ title: Cluster Flow displayed_sidebar: multiqcSidebar description: > -

Simple and flexible bioinformatics pipeline tool.

+

Simple and flexible bioinformatics pipeline tool.

--- :::note -

Simple and flexible bioinformatics pipeline tool.

[http://clusterflow.io](http://clusterflow.io) @@ -31,10 +30,11 @@ shown (some basic statistics plus the pipeline steps / params used). ```yaml clusterflow/logs: - fn: "*_clusterFlow.txt" + fn: '*_clusterFlow.txt' shared: true clusterflow/runfiles: contents: Cluster Flow Run File - fn: "*.run" + fn: '*.run' num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/conpair.md b/docs/markdown/modules/conpair.md index 3e0b08af5f..7bb27f86cb 100644 --- a/docs/markdown/modules/conpair.md +++ b/docs/markdown/modules/conpair.md @@ -2,7 +2,7 @@ title: Conpair displayed_sidebar: multiqcSidebar description: > -

Estimates concordance and contamination for tumor–normal pairs.

+

Estimates concordance and contamination for tumor–normal pairs.

--- :::note -

Estimates concordance and contamination for tumor–normal pairs.

[https://github.com/nygenome/Conpair](https://github.com/nygenome/Conpair) @@ -30,6 +29,7 @@ conpair/concordance: contents: markers (coverage per marker threshold num_lines: 3 conpair/contamination: - contents: "Tumor sample contamination level: " + contents: 'Tumor sample contamination level: ' num_lines: 3 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/cutadapt.md b/docs/markdown/modules/cutadapt.md index 9958172e41..4120cb1f07 100644 --- a/docs/markdown/modules/cutadapt.md +++ b/docs/markdown/modules/cutadapt.md @@ -2,7 +2,7 @@ title: Cutadapt displayed_sidebar: multiqcSidebar description: > -

Finds and removes adapter sequences, primers, poly-A tails, and other types of unwanted sequences.

+

Finds and removes adapter sequences, primers, poly-A tails, and other types of unwanted sequences.

--- :::note -

Finds and removes adapter sequences, primers, poly-A tails, and other types of unwanted sequences.

[https://cutadapt.readthedocs.io/](https://cutadapt.readthedocs.io/) @@ -43,8 +42,9 @@ The module also understands logs saved by Trim Galore, which contain cutadapt lo ```yaml cutadapt: - - contents: This is cutadapt - num_lines: 100 - - contents: Cutadapt report - fn: "*.json" +- contents: This is cutadapt + num_lines: 100 +- contents: Cutadapt report + fn: '*.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/damageprofiler.md b/docs/markdown/modules/damageprofiler.md index 299cf632c8..84379ba71a 100644 --- a/docs/markdown/modules/damageprofiler.md +++ b/docs/markdown/modules/damageprofiler.md @@ -2,7 +2,7 @@ title: DamageProfiler displayed_sidebar: multiqcSidebar description: > -

DNA damage pattern retrieval for ancient DNA analysis.

+

DNA damage pattern retrieval for ancient DNA analysis.

--- :::note -

DNA damage pattern retrieval for ancient DNA analysis.

[https://github.com/Integrative-Transcriptomics/DamageProfiler](https://github.com/Integrative-Transcriptomics/DamageProfiler) @@ -25,5 +24,6 @@ File path for the source of this content: multiqc/modules/damageprofiler/damagep ```yaml damageprofiler: - fn: "*dmgprof.json" + fn: '*dmgprof.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/dedup.md b/docs/markdown/modules/dedup.md index 55ce3d750f..0bf10a6d59 100644 --- a/docs/markdown/modules/dedup.md +++ b/docs/markdown/modules/dedup.md @@ -2,7 +2,7 @@ title: DeDup displayed_sidebar: multiqcSidebar description: > -

Improved Duplicate Removal for merged/collapsed reads in ancient DNA analysis.

+

Improved Duplicate Removal for merged/collapsed reads in ancient DNA analysis.

--- :::note -

Improved Duplicate Removal for merged/collapsed reads in ancient DNA analysis.

[http://www.github.com/apeltzer/DeDup](http://www.github.com/apeltzer/DeDup) @@ -35,6 +34,7 @@ ancient_read_count_multiplier: 0.001 ```yaml dedup: contents: '"tool_name": "DeDup"' - fn: "*.json" + fn: '*.json' num_lines: 20 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/deeptools.md b/docs/markdown/modules/deeptools.md index 12ee231b55..1e25a55b9a 100644 --- a/docs/markdown/modules/deeptools.md +++ b/docs/markdown/modules/deeptools.md @@ -2,7 +2,7 @@ title: deepTools displayed_sidebar: multiqcSidebar description: > -

Tools to process and analyze deep sequencing data.

+

Tools to process and analyze deep sequencing data.

--- :::note -

Tools to process and analyze deep sequencing data.

[http://deeptools.readthedocs.io](http://deeptools.readthedocs.io) @@ -43,7 +42,7 @@ Note that sample names are parsed from the text files themselves, they are not d ```yaml deeptools/bamPEFragmentSizeDistribution: - contents: "#bamPEFragmentSize" + contents: '#bamPEFragmentSize' num_lines: 1 deeptools/bamPEFragmentSizeTable: contents: "\tFrag. Sampled\tFrag. Len. Min.\tFrag. Len. 1st. Qu.\tFrag. Len. Mean\t\ @@ -54,10 +53,10 @@ deeptools/estimateReadFiltering: Estimated mapped reads" num_lines: 1 deeptools/plotCorrelationData: - contents: "#plotCorrelation --outFileCorMatrix" + contents: '#plotCorrelation --outFileCorMatrix' num_lines: 1 deeptools/plotCoverageOutRawCounts: - contents: "#plotCoverage --outRawCounts" + contents: '#plotCoverage --outRawCounts' num_lines: 1 deeptools/plotCoverageStdout: contents: "sample\tmean\tstd\tmin\t25%\t50%\t75%\tmax" @@ -70,12 +69,13 @@ deeptools/plotFingerprintOutQualityMetrics: \ Point\tSynthetic Elbow Point" num_lines: 1 deeptools/plotFingerprintOutRawCounts: - contents: "#plotFingerprint --outRawCounts" + contents: '#plotFingerprint --outRawCounts' num_lines: 1 deeptools/plotPCAData: - contents: "#plotPCA --outFileNameData" + contents: '#plotPCA --outFileNameData' num_lines: 1 deeptools/plotProfile: contents: bin labels num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/diamond.md b/docs/markdown/modules/diamond.md index 619a17b5ae..4df1df3281 100644 --- a/docs/markdown/modules/diamond.md +++ b/docs/markdown/modules/diamond.md @@ -2,7 +2,7 @@ title: DIAMOND displayed_sidebar: multiqcSidebar description: > -

Sequence aligner for protein and translated DNA searches, a drop-in replacement for the NCBI BLAST.

+

Sequence aligner for protein and translated DNA searches, a drop-in replacement for the NCBI BLAST.

--- :::note -

Sequence aligner for protein and translated DNA searches, a drop-in replacement for the NCBI BLAST.

[https://github.com/bbuchfink/diamond](https://github.com/bbuchfink/diamond) ::: Key features are: - - Pairwise alignment of proteins and translated DNA at 100x-10,000x speed of BLAST. - Frameshift alignments for long read analysis. - Low resource requirements and suitable for running on standard desktops or laptops. @@ -37,3 +35,4 @@ the number of sequences aligned and displays them in the General Stats table. diamond: fn: diamond.log ``` + \ No newline at end of file diff --git a/docs/markdown/modules/disambiguate.md b/docs/markdown/modules/disambiguate.md index 97f35da750..d2704ff5b3 100644 --- a/docs/markdown/modules/disambiguate.md +++ b/docs/markdown/modules/disambiguate.md @@ -2,7 +2,7 @@ title: Disambiguate displayed_sidebar: multiqcSidebar description: > -

Disambiguate reads aligned to two different species (e.g. human and mouse).

+

Disambiguate reads aligned to two different species (e.g. human and mouse).

--- :::note -

Disambiguate reads aligned to two different species (e.g. human and mouse).

[https://github.com/AstraZeneca-NGS/disambiguate](https://github.com/AstraZeneca-NGS/disambiguate) @@ -28,3 +27,4 @@ disambiguate: contents: unique species A pairs num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/dragen.md b/docs/markdown/modules/dragen.md index 95339b72ca..ff5ce44905 100644 --- a/docs/markdown/modules/dragen.md +++ b/docs/markdown/modules/dragen.md @@ -2,7 +2,7 @@ title: DRAGEN displayed_sidebar: multiqcSidebar description: > -

Illumina Bio-IT Platform that uses FPGA for secondary analysis of sequencing data.

+

Illumina Bio-IT Platform that uses FPGA for secondary analysis of sequencing data.

--- :::note -

Illumina Bio-IT Platform that uses FPGA for secondary analysis of sequencing data.

[https://www.illumina.com/products/by-type/informatics-products/dragen-bio-it-platform.html](https://www.illumina.com/products/by-type/informatics-products/dragen-bio-it-platform.html) @@ -58,7 +57,7 @@ and alike. This MultiQC module supports some of the output but not all. Contribu - Summary table for single-cell ATAC metrics The code is structured in a way so every mix-in parses one type of QC file that DRAGEN generates -(e.g. _.mapping_metrics.csv, _.wgs_fine_hist_normal.csv, etc.). If a corresponding file is found, a mix-in adds +(e.g. *.mapping_metrics.csv, *.wgs_fine_hist_normal.csv, etc.). If a corresponding file is found, a mix-in adds a section into the report. DRAGEN can be treated as a fast aligner with additional features on top, as users will unlikely use any @@ -71,35 +70,36 @@ place it accordingly in the module_order list, in docs, etc. dragen/coverage_metrics: fn_re: .*_coverage_metrics.*\.csv dragen/fragment_length_hist: - fn: "*.fragment_length_hist.csv" + fn: '*.fragment_length_hist.csv' dragen/gc_metrics: - fn: "*.gc_metrics.csv" + fn: '*.gc_metrics.csv' dragen/gvcf_metrics: - fn: "*.gvcf_metrics.csv" + fn: '*.gvcf_metrics.csv' dragen/mapping_metrics: contents: Number of unique reads (excl. duplicate marked reads) - fn: "*.mapping_metrics.csv" + fn: '*.mapping_metrics.csv' num_lines: 50 dragen/overall_mean_cov_metrics: fn_re: .*_overall_mean_cov.*\.csv dragen/ploidy_estimation_metrics: - fn: "*.ploidy_estimation_metrics.csv" + fn: '*.ploidy_estimation_metrics.csv' dragen/rna_quant_metrics: - fn: "*.quant[._]metrics.csv" + fn: '*.quant[._]metrics.csv' dragen/rna_transcript_cov: - fn: "*.quant.transcript_coverage.txt" + fn: '*.quant.transcript_coverage.txt' dragen/sc_atac_metrics: - fn: "*.scATAC[._]metrics.csv" + fn: '*.scATAC[._]metrics.csv' dragen/sc_rna_metrics: - fn: "*.scRNA[._]metrics.csv" + fn: '*.scRNA[._]metrics.csv' dragen/time_metrics: - fn: "*.time_metrics.csv" + fn: '*.time_metrics.csv' dragen/trimmer_metrics: - fn: "*.trimmer_metrics.csv" + fn: '*.trimmer_metrics.csv' dragen/vc_metrics: - fn: "*.vc_metrics.csv" + fn: '*.vc_metrics.csv' dragen/wgs_contig_mean_cov: fn_re: .*\.wgs_contig_mean_cov_?(tumor|normal)?\.csv dragen/wgs_fine_hist: fn_re: .*\.wgs_fine_hist_?(tumor|normal)?\.csv ``` + \ No newline at end of file diff --git a/docs/markdown/modules/dragen_fastqc.md b/docs/markdown/modules/dragen_fastqc.md index 060ac30a6e..89f62a7b6b 100644 --- a/docs/markdown/modules/dragen_fastqc.md +++ b/docs/markdown/modules/dragen_fastqc.md @@ -2,7 +2,7 @@ title: DRAGEN-FastQC displayed_sidebar: multiqcSidebar description: > -

Illumina Bio-IT Platform that uses FPGA for secondary analysis of sequencing data.

+

Illumina Bio-IT Platform that uses FPGA for secondary analysis of sequencing data.

--- :::note -

Illumina Bio-IT Platform that uses FPGA for secondary analysis of sequencing data.

[https://www.illumina.com/products/by-type/informatics-products/dragen-bio-it-platform.html](https://www.illumina.com/products/by-type/informatics-products/dragen-bio-it-platform.html) @@ -61,5 +60,6 @@ dragen_fastqc: ```yaml dragen_fastqc: - fn: "*.fastqc_metrics.csv" + fn: '*.fastqc_metrics.csv' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/eigenstratdatabasetools.md b/docs/markdown/modules/eigenstratdatabasetools.md index cdab1ce7c5..a3a45789e7 100644 --- a/docs/markdown/modules/eigenstratdatabasetools.md +++ b/docs/markdown/modules/eigenstratdatabasetools.md @@ -2,7 +2,7 @@ title: eigenstratdatabasetools displayed_sidebar: multiqcSidebar description: > -

Tools to compare and manipulate the contents of EingenStrat databases, and to calculate SNP coverage statistics in such databases.

+

Tools to compare and manipulate the contents of EingenStrat databases, and to calculate SNP coverage statistics in such databases.

--- :::note -

Tools to compare and manipulate the contents of EingenStrat databases, and to calculate SNP coverage statistics in such databases.

[https://github.com/TCLamnidis/EigenStratDatabaseTools](https://github.com/TCLamnidis/EigenStratDatabaseTools) @@ -25,5 +24,6 @@ File path for the source of this content: multiqc/modules/eigenstratdatabasetool ```yaml eigenstratdatabasetools: - fn: "*_eigenstrat_coverage.json" + fn: '*_eigenstrat_coverage.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/fastp.md b/docs/markdown/modules/fastp.md index df92c3fe5f..46962ed02b 100644 --- a/docs/markdown/modules/fastp.md +++ b/docs/markdown/modules/fastp.md @@ -2,7 +2,7 @@ title: fastp displayed_sidebar: multiqcSidebar description: > -

All-in-one FASTQ preprocessor (QC, adapters, trimming, filtering, splitting...).

+

All-in-one FASTQ preprocessor (QC, adapters, trimming, filtering, splitting...).

--- :::note -

All-in-one FASTQ preprocessor (QC, adapters, trimming, filtering, splitting...).

[https://github.com/OpenGene/fastp](https://github.com/OpenGene/fastp) @@ -27,20 +26,26 @@ depiction of the consequences of the filtering process. Notably, the latter can variety of parameters including quality scores, length, as well as the presence of adapters, polyG, or polyX tailing. -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](../getting_started/config#using-log-filenames-as-sample-names) +for more details. + ### File search patterns ```yaml fastp: contents: '"before_filtering": {' - fn: "*.json" + fn: '*.json' num_lines: 50 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/fastq_screen.md b/docs/markdown/modules/fastq_screen.md index 298e17e912..3ed5eb7e94 100644 --- a/docs/markdown/modules/fastq_screen.md +++ b/docs/markdown/modules/fastq_screen.md @@ -2,7 +2,7 @@ title: FastQ Screen displayed_sidebar: multiqcSidebar description: > -

Screens a library of sequences in FastQ format against a set of sequence databases to see if the composition of the library matches with what you expect.

+

Screens a library of sequences in FastQ format against a set of sequence databases to see if the composition of the library matches with what you expect.

--- :::note -

Screens a library of sequences in FastQ format against a set of sequence databases to see if the composition of the library matches with what you expect.

[http://www.bioinformatics.babraham.ac.uk/projects/fastq_screen/](http://www.bioinformatics.babraham.ac.uk/projects/fastq_screen/) @@ -37,5 +36,6 @@ fastqscreen_simpleplot: true ```yaml fastq_screen: - fn: "*_screen.txt" + fn: '*_screen.txt' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/fastqc.md b/docs/markdown/modules/fastqc.md index bde958611b..f9c570e069 100644 --- a/docs/markdown/modules/fastqc.md +++ b/docs/markdown/modules/fastqc.md @@ -2,7 +2,7 @@ title: FastQC displayed_sidebar: multiqcSidebar description: > -

Quality control tool for high throughput sequencing data.

+

Quality control tool for high throughput sequencing data.

--- :::note -

Quality control tool for high throughput sequencing data.

[http://www.bioinformatics.babraham.ac.uk/projects/fastqc/](http://www.bioinformatics.babraham.ac.uk/projects/fastqc/) @@ -148,7 +147,8 @@ fastqc_config: Remember that it is possible to customise the order in which the different module sections appear in the report if you wish. -See [the docs](../reports/customisation#order-of-module-and-module-subsection-output) for more information. +See [the docs](../reports/customisation#order-of-module-and-module-subsection-output) +for more information. For example, to show the _Status Checks_ section at the top, use the following config: @@ -175,7 +175,8 @@ fastqc_config: fastqc/data: fn: fastqc_data.txt fastqc/theoretical_gc: - fn: "*fastqc_theoretical_gc*" + fn: '*fastqc_theoretical_gc*' fastqc/zip: - fn: "*_fastqc.zip" + fn: '*_fastqc.zip' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/featurecounts.md b/docs/markdown/modules/featurecounts.md index 97faab6409..64651c0f4a 100644 --- a/docs/markdown/modules/featurecounts.md +++ b/docs/markdown/modules/featurecounts.md @@ -2,7 +2,7 @@ title: featureCounts displayed_sidebar: multiqcSidebar description: > -

Counts mapped reads for genomic features such as genes, exons, promoter, gene bodies, genomic bins and chromosomal locations.

+

Counts mapped reads for genomic features such as genes, exons, promoter, gene bodies, genomic bins and chromosomal locations.

--- :::note -

Counts mapped reads for genomic features such as genes, exons, promoter, gene bodies, genomic bins and chromosomal locations.

[http://subread.sourceforge.net/](http://subread.sourceforge.net/) @@ -34,6 +33,7 @@ cause the parsing to fail. ```yaml featurecounts: - fn: "*.summary" + fn: '*.summary' shared: true ``` + \ No newline at end of file diff --git a/docs/markdown/modules/fgbio.md b/docs/markdown/modules/fgbio.md index 424b3066e2..db7d193a06 100644 --- a/docs/markdown/modules/fgbio.md +++ b/docs/markdown/modules/fgbio.md @@ -2,7 +2,7 @@ title: fgbio displayed_sidebar: multiqcSidebar description: > -

Processing and evaluating data containing UMIs.

+

Processing and evaluating data containing UMIs.

--- :::note -

Processing and evaluating data containing UMIs.

[http://fulcrumgenomics.github.io/fgbio/](http://fulcrumgenomics.github.io/fgbio/) @@ -37,3 +36,4 @@ fgbio/groupreadsbyumi: contents: fraction_gt_or_eq_family_size num_lines: 3 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/filtlong.md b/docs/markdown/modules/filtlong.md index a6cb8ad8f1..5ed13df6c9 100644 --- a/docs/markdown/modules/filtlong.md +++ b/docs/markdown/modules/filtlong.md @@ -2,7 +2,7 @@ title: Filtlong displayed_sidebar: multiqcSidebar description: > -

Filters long reads by quality.

+

Filters long reads by quality.

--- :::note -

Filters long reads by quality.

[https://github.com/rrwick/Filtlong](https://github.com/rrwick/Filtlong) @@ -48,3 +47,4 @@ filtlong: contents_re: .*Filtering long reads.* num_lines: 5 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/flash.md b/docs/markdown/modules/flash.md index e9c2618ea4..7fc914d5ae 100644 --- a/docs/markdown/modules/flash.md +++ b/docs/markdown/modules/flash.md @@ -2,7 +2,7 @@ title: FLASh displayed_sidebar: multiqcSidebar description: > -

Merges paired-end reads from next-generation sequencing experiments.

+

Merges paired-end reads from next-generation sequencing experiments.

--- :::note -

Merges paired-end reads from next-generation sequencing experiments.

[https://ccb.jhu.edu/software/FLASH/](https://ccb.jhu.edu/software/FLASH/) @@ -50,7 +49,8 @@ sp: ```yaml flash/hist: - fn: "*flash*.hist" + fn: '*flash*.hist' flash/log: - contents: "[FLASH]" + contents: '[FLASH]' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/flexbar.md b/docs/markdown/modules/flexbar.md index 81e5e3b1dc..7076323df5 100644 --- a/docs/markdown/modules/flexbar.md +++ b/docs/markdown/modules/flexbar.md @@ -2,7 +2,7 @@ title: Flexbar displayed_sidebar: multiqcSidebar description: > -

Barcode and adapter removal tool.

+

Barcode and adapter removal tool.

--- :::note -

Barcode and adapter removal tool.

[https://github.com/seqan/flexbar](https://github.com/seqan/flexbar) @@ -31,3 +30,4 @@ Flexbar increases read mapping rates and improves genome as well as transcriptom flexbar: contents: Flexbar - flexible barcode and adapter removal ``` + \ No newline at end of file diff --git a/docs/markdown/modules/freyja.md b/docs/markdown/modules/freyja.md index 0870eb7445..fcda61dd66 100644 --- a/docs/markdown/modules/freyja.md +++ b/docs/markdown/modules/freyja.md @@ -2,7 +2,7 @@ title: Freyja displayed_sidebar: multiqcSidebar description: > -

Recovers relative lineage abundances from mixed SARS-CoV-2 samples.

+

Recovers relative lineage abundances from mixed SARS-CoV-2 samples.

--- :::note -

Recovers relative lineage abundances from mixed SARS-CoV-2 samples.

[https://github.com/andersen-lab/Freyja](https://github.com/andersen-lab/Freyja) @@ -30,6 +29,7 @@ phylogenetic tree to solve the constrained (unit sum, non-negative) de-mixing pr ```yaml freyja: contents: "summarized\t[" - fn: "*.tsv" + fn: '*.tsv' num_lines: 6 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/ganon.md b/docs/markdown/modules/ganon.md index 7710f25f5f..de61e4ecce 100644 --- a/docs/markdown/modules/ganon.md +++ b/docs/markdown/modules/ganon.md @@ -2,7 +2,7 @@ title: Ganon displayed_sidebar: multiqcSidebar description: > -

Metagenomics classification: quickly assigns sequence fragments to their closest reference among thousands of references via Interleaved Bloom Filters of k-mer/minimizers.

+

Metagenomics classification: quickly assigns sequence fragments to their closest reference among thousands of references via Interleaved Bloom Filters of k-mer/minimizers.

--- :::note -

Metagenomics classification: quickly assigns sequence fragments to their closest reference among thousands of references via Interleaved Bloom Filters of k-mer/minimizers.

[https://pirovc.github.io/ganon/](https://pirovc.github.io/ganon/) @@ -28,6 +27,7 @@ The module takes summary statistics from a file containing stdout from `ganon cl ```yaml ganon: contents: - - ganon-classify processed + - ganon-classify processed num_lines: 100 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/gatk.md b/docs/markdown/modules/gatk.md index 82c6de1883..f0a4b27471 100644 --- a/docs/markdown/modules/gatk.md +++ b/docs/markdown/modules/gatk.md @@ -2,7 +2,7 @@ title: GATK displayed_sidebar: multiqcSidebar description: > -

Wide variety of tools with a primary focus on variant discovery and genotyping.

+

Wide variety of tools with a primary focus on variant discovery and genotyping.

--- :::note -

Wide variety of tools with a primary focus on variant discovery and genotyping.

[https://www.broadinstitute.org/gatk/](https://www.broadinstitute.org/gatk/) @@ -52,14 +51,15 @@ variants in dbSNP, genotype concordance, Ti/Tv ratios and a lot more. ```yaml gatk/analyze_saturation_mutagenesis: - contents: ">>Reads in disjoint pairs evaluated separately:" - fn: "*.readCounts" + contents: '>>Reads in disjoint pairs evaluated separately:' + fn: '*.readCounts' num_lines: 10 gatk/base_recalibrator: - - contents: "#:GATKTable:Arguments:Recalibration" - num_lines: 3 - - contents: "#:SENTIEON_QCAL_TABLE:Arguments:Recalibration" - num_lines: 3 +- contents: '#:GATKTable:Arguments:Recalibration' + num_lines: 3 +- contents: '#:SENTIEON_QCAL_TABLE:Arguments:Recalibration' + num_lines: 3 gatk/varianteval: - contents: "#:GATKTable:TiTvVariantEvaluator" + contents: '#:GATKTable:TiTvVariantEvaluator' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/gffcompare.md b/docs/markdown/modules/gffcompare.md index 1a3c50222d..c55b071dcf 100644 --- a/docs/markdown/modules/gffcompare.md +++ b/docs/markdown/modules/gffcompare.md @@ -2,7 +2,7 @@ title: GffCompare displayed_sidebar: multiqcSidebar description: > -

Tool to compare, merge and annotate one or more GFF files with a reference annotation in GFF format.

+

Tool to compare, merge and annotate one or more GFF files with a reference annotation in GFF format.

--- :::note -

Tool to compare, merge and annotate one or more GFF files with a reference annotation in GFF format.

[https://ccb.jhu.edu/software/stringtie/gffcompare.shtml](https://ccb.jhu.edu/software/stringtie/gffcompare.shtml) @@ -41,7 +40,8 @@ It is hoped to refactor this code in a future release - please submit a PR if yo ```yaml gffcompare: - contents: "# gffcompare" - fn: "*.stats" + contents: '# gffcompare' + fn: '*.stats' num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/glimpse.md b/docs/markdown/modules/glimpse.md index 85da87a00b..8d00f19396 100644 --- a/docs/markdown/modules/glimpse.md +++ b/docs/markdown/modules/glimpse.md @@ -2,7 +2,7 @@ title: GLIMPSE displayed_sidebar: multiqcSidebar description: > -

Low-coverage whole genome sequencing imputation.

+

Low-coverage whole genome sequencing imputation.

--- :::note -

Low-coverage whole genome sequencing imputation.

[https://odelaneau.github.io/GLIMPSE/](https://odelaneau.github.io/GLIMPSE/) @@ -35,9 +34,10 @@ The supported files are generated from the `GLIMPSE2_concordance` command. The f ```yaml glimpse/err_grp: - fn: "*.error.grp.txt.gz" + fn: '*.error.grp.txt.gz' num_lines: 1 glimpse/err_spl: - fn: "*.error.spl.txt.gz" + fn: '*.error.spl.txt.gz' num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/goleft_indexcov.md b/docs/markdown/modules/goleft_indexcov.md index c907ce7354..69795bc167 100644 --- a/docs/markdown/modules/goleft_indexcov.md +++ b/docs/markdown/modules/goleft_indexcov.md @@ -2,7 +2,7 @@ title: goleft indexcov displayed_sidebar: multiqcSidebar description: > -

Quickly estimate coverage from a whole-genome bam index, providing 16KB resolution.

+

Quickly estimate coverage from a whole-genome bam index, providing 16KB resolution.

--- :::note -

Quickly estimate coverage from a whole-genome bam index, providing 16KB resolution.

[https://github.com/brentp/goleft/tree/master/indexcov](https://github.com/brentp/goleft/tree/master/indexcov) @@ -49,7 +48,8 @@ goleft_indexcov_config: ```yaml goleft_indexcov/ped: - fn: "*-indexcov.ped" + fn: '*-indexcov.ped' goleft_indexcov/roc: - fn: "*-indexcov.roc" + fn: '*-indexcov.roc' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/gopeaks.md b/docs/markdown/modules/gopeaks.md index abb6b0710c..ce069c1adb 100644 --- a/docs/markdown/modules/gopeaks.md +++ b/docs/markdown/modules/gopeaks.md @@ -2,7 +2,7 @@ title: GoPeaks displayed_sidebar: multiqcSidebar description: > -

Calls peaks in CUT&TAG/CUT&RUN datasets.

+

Calls peaks in CUT&TAG/CUT&RUN datasets.

--- :::note -

Calls peaks in CUT&TAG/CUT&RUN datasets.

[https://github.com/maxsonBraunLab/gopeaks](https://github.com/maxsonBraunLab/gopeaks) @@ -31,5 +30,6 @@ the number of peaks called per sample via the general table and the bar plot. ```yaml gopeaks: - fn: "*_gopeaks.json" + fn: '*_gopeaks.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/gtdbtk.md b/docs/markdown/modules/gtdbtk.md index f8018cafc2..b1367d5792 100644 --- a/docs/markdown/modules/gtdbtk.md +++ b/docs/markdown/modules/gtdbtk.md @@ -2,7 +2,7 @@ title: GTDB-Tk displayed_sidebar: multiqcSidebar description: > -

Assigns objective taxonomic classifications to bacterial and archaeal genomes.

+

Assigns objective taxonomic classifications to bacterial and archaeal genomes.

--- :::note -

Assigns objective taxonomic classifications to bacterial and archaeal genomes.

[https://ecogenomics.github.io/GTDBTk/index.html](https://ecogenomics.github.io/GTDBTk/index.html) @@ -35,3 +34,4 @@ gtdbtk: closest_genome_taxonomy\tclosest_genome_ani" num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/haplocheck.md b/docs/markdown/modules/haplocheck.md index cc0b4b99c1..bc65469eb0 100644 --- a/docs/markdown/modules/haplocheck.md +++ b/docs/markdown/modules/haplocheck.md @@ -2,7 +2,7 @@ title: Haplocheck displayed_sidebar: multiqcSidebar description: > -

Detects in-sample contamination in mtDNA or WGS sequencing studies by analyzing the mitchondrial content.

+

Detects in-sample contamination in mtDNA or WGS sequencing studies by analyzing the mitchondrial content.

--- :::note -

Detects in-sample contamination in mtDNA or WGS sequencing studies by analyzing the mitchondrial content.

[https://github.com/genepi/haplocheck/](https://github.com/genepi/haplocheck/) @@ -29,3 +28,4 @@ haplocheck: \t\"Sample Coverage\"" num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/happy.md b/docs/markdown/modules/happy.md index 9499e27ce2..5754566466 100644 --- a/docs/markdown/modules/happy.md +++ b/docs/markdown/modules/happy.md @@ -2,7 +2,7 @@ title: hap.py displayed_sidebar: multiqcSidebar description: > -

Benchmarks variant calls against gold standard truth datasets.

+

Benchmarks variant calls against gold standard truth datasets.

--- :::note -

Benchmarks variant calls against gold standard truth datasets.

[https://github.com/Illumina/hap.py](https://github.com/Illumina/hap.py) ::: -Som.py output not currently supported. +Som.py output supported in separate sompy module. ### File search patterns ```yaml happy: contents: Type,Filter,TRUTH - fn: "*.summary.csv" + fn: '*.summary.csv' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/hicexplorer.md b/docs/markdown/modules/hicexplorer.md index d131970d81..45bedf7c80 100644 --- a/docs/markdown/modules/hicexplorer.md +++ b/docs/markdown/modules/hicexplorer.md @@ -2,7 +2,7 @@ title: HiCExplorer displayed_sidebar: multiqcSidebar description: > -

Hi-C analysis from processing to visualization.

+

Hi-C analysis from processing to visualization.

--- :::note -

Hi-C analysis from processing to visualization.

[https://hicexplorer.readthedocs.io](https://hicexplorer.readthedocs.io) @@ -32,3 +31,4 @@ hicexplorer: max_filesize: 4096 num_lines: 26 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/hicpro.md b/docs/markdown/modules/hicpro.md index 72e575b27e..815bdfcfed 100644 --- a/docs/markdown/modules/hicpro.md +++ b/docs/markdown/modules/hicpro.md @@ -2,7 +2,7 @@ title: HiC-Pro displayed_sidebar: multiqcSidebar description: > -

Pipeline for Hi-C data processing.

+

Pipeline for Hi-C data processing.

--- :::note -

Pipeline for Hi-C data processing.

[https://github.com/nservant/HiC-Pro](https://github.com/nservant/HiC-Pro) @@ -30,20 +29,21 @@ The MultiQC module is supported since HiC-Pro v2.11.0. ```yaml hicpro/assplit: - fn: "*assplit.stat" + fn: '*assplit.stat' hicpro/mRSstat: contents: Valid_interaction_pairs - fn: "*RSstat" + fn: '*RSstat' hicpro/mergestat: contents: valid_interaction - fn: "*.mergestat" + fn: '*.mergestat' num_lines: 10 hicpro/mmapstat: contents: total_R - fn: "*mapstat" + fn: '*mapstat' num_lines: 10 hicpro/mpairstat: contents: Total_pairs_processed - fn: "*pairstat" + fn: '*pairstat' num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/hicup.md b/docs/markdown/modules/hicup.md index a8fac015b5..6dbbb319c5 100644 --- a/docs/markdown/modules/hicup.md +++ b/docs/markdown/modules/hicup.md @@ -2,7 +2,7 @@ title: HiCUP displayed_sidebar: multiqcSidebar description: > -

Mapping and quality control on Hi-C data.

+

Mapping and quality control on Hi-C data.

--- :::note -

Mapping and quality control on Hi-C data.

[http://www.bioinformatics.babraham.ac.uk/projects/hicup/](http://www.bioinformatics.babraham.ac.uk/projects/hicup/) @@ -26,4 +25,7 @@ File path for the source of this content: multiqc/modules/hicup/hicup.py ```yaml hicup: fn: HiCUP_summary_report* +hicup/html: + fn: '*HiCUP_summary_report*.html' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/hifiasm.md b/docs/markdown/modules/hifiasm.md index 6ce06ab465..22b7bb58aa 100644 --- a/docs/markdown/modules/hifiasm.md +++ b/docs/markdown/modules/hifiasm.md @@ -2,7 +2,7 @@ title: HiFiasm displayed_sidebar: multiqcSidebar description: > -

Haplotype-resolved assembler for accurate Hifi reads.

+

Haplotype-resolved assembler for accurate Hifi reads.

--- :::note -

Haplotype-resolved assembler for accurate Hifi reads.

[https://github.com/chhylp123/hifiasm](https://github.com/chhylp123/hifiasm) @@ -25,6 +24,7 @@ File path for the source of this content: multiqc/modules/hifiasm/hifiasm.py ```yaml hifiasm: - contents: "[M::ha_analyze_count]" + contents: '[M::ha_analyze_count]' num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/hisat2.md b/docs/markdown/modules/hisat2.md index bce0b5ce21..11bcde42ed 100644 --- a/docs/markdown/modules/hisat2.md +++ b/docs/markdown/modules/hisat2.md @@ -2,7 +2,7 @@ title: HISAT2 displayed_sidebar: multiqcSidebar description: > -

Maps DNA or RNA reads against a genome or a population of genomes.

+

Maps DNA or RNA reads against a genome or a population of genomes.

--- :::note -

Maps DNA or RNA reads against a genome or a population of genomes.

[https://ccb.jhu.edu/software/hisat2/](https://ccb.jhu.edu/software/hisat2/) @@ -42,5 +41,6 @@ MultiQC report. ```yaml hisat2: - contents: "HISAT2 summary stats:" + contents: 'HISAT2 summary stats:' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/homer.md b/docs/markdown/modules/homer.md index 700c58e473..83237dd98c 100644 --- a/docs/markdown/modules/homer.md +++ b/docs/markdown/modules/homer.md @@ -2,7 +2,7 @@ title: HOMER displayed_sidebar: multiqcSidebar description: > -

Motif discovery and next-gen sequencing analysis.

+

Motif discovery and next-gen sequencing analysis.

--- :::note -

Motif discovery and next-gen sequencing analysis.

[http://homer.ucsd.edu/homer/](http://homer.ucsd.edu/homer/) @@ -50,10 +49,11 @@ homer/LengthDistribution: homer/RestrictionDistribution: fn: petagRestrictionDistribution.*.txt homer/findpeaks: - contents: "# HOMER Peaks" + contents: '# HOMER Peaks' num_lines: 3 homer/genomeGCcontent: fn: genomeGCcontent.txt homer/tagInfo: fn: tagInfo.txt ``` + \ No newline at end of file diff --git a/docs/markdown/modules/hops.md b/docs/markdown/modules/hops.md index 777951cd74..b41efd842e 100644 --- a/docs/markdown/modules/hops.md +++ b/docs/markdown/modules/hops.md @@ -2,7 +2,7 @@ title: HOPS displayed_sidebar: multiqcSidebar description: > -

Ancient DNA characteristics screening tool of output from the metagenomic aligner MALT.

+

Ancient DNA characteristics screening tool of output from the metagenomic aligner MALT.

--- :::note -

Ancient DNA characteristics screening tool of output from the metagenomic aligner MALT.

[https://github.com/rhuebler/HOPS/](https://github.com/rhuebler/HOPS/) @@ -31,3 +30,4 @@ categories (small edit distance, damage, both edit distance and aDNA damage) tha hops: fn: heatmap_overview_Wevid.json ``` + \ No newline at end of file diff --git a/docs/markdown/modules/hostile.md b/docs/markdown/modules/hostile.md index d981d28a66..c7f06751e1 100644 --- a/docs/markdown/modules/hostile.md +++ b/docs/markdown/modules/hostile.md @@ -2,7 +2,7 @@ title: Hostile displayed_sidebar: multiqcSidebar description: > -

Removes host sequences from short and long read (meta)genomes, from paired or unpaired fastq[.gz].

+

Removes host sequences from short and long read (meta)genomes, from paired or unpaired fastq[.gz].

--- :::note -

Removes host sequences from short and long read (meta)genomes, from paired or unpaired fastq[.gz].

[https://github.com/bede/hostile](https://github.com/bede/hostile) @@ -64,6 +63,7 @@ as host-reads vs cleaned-reads (non-host reads). ```yaml hostile: contents: '"reads_removed_proportion"' - fn: "*.json" + fn: '*.json' num_lines: 100 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/htseq.md b/docs/markdown/modules/htseq.md index e559c8dc96..6cee797430 100644 --- a/docs/markdown/modules/htseq.md +++ b/docs/markdown/modules/htseq.md @@ -2,7 +2,7 @@ title: HTSeq Count displayed_sidebar: multiqcSidebar description: > -

Part of the HTSeq package: counts reads covering specified genomic features.

+

Part of the HTSeq package: counts reads covering specified genomic features.

--- :::note -

Part of the HTSeq package: counts reads covering specified genomic features.

[https://htseq.readthedocs.io/en/master/htseqcount.html](https://htseq.readthedocs.io/en/master/htseqcount.html) @@ -30,10 +29,11 @@ reads, plus a list of genomic features and counts how many reads map to each fea ```yaml htseq: - - contents_re: ^feature\tcount$ - num_lines: 1 - shared: true - - contents_re: ^\w+.*\t\d+$ - num_lines: 1 - shared: true +- contents_re: ^feature\tcount$ + num_lines: 1 + shared: true +- contents_re: ^\w+.*\t\d+$ + num_lines: 1 + shared: true ``` + \ No newline at end of file diff --git a/docs/markdown/modules/humid.md b/docs/markdown/modules/humid.md index 601e7aa249..ab30943a97 100644 --- a/docs/markdown/modules/humid.md +++ b/docs/markdown/modules/humid.md @@ -2,7 +2,7 @@ title: HUMID displayed_sidebar: multiqcSidebar description: > -

Reference-free tool to quickly remove duplicates from FastQ files, with or without UMIs.

+

Reference-free tool to quickly remove duplicates from FastQ files, with or without UMIs.

--- :::note -

Reference-free tool to quickly remove duplicates from FastQ files, with or without UMIs.

[https://github.com/jfjlaros/HUMID](https://github.com/jfjlaros/HUMID) @@ -25,19 +24,20 @@ File path for the source of this content: multiqc/modules/humid/humid.py ```yaml humid/clusters: - contents_re: "[0-9]+ [0-9]+" + contents_re: '[0-9]+ [0-9]+' fn: clusters.dat num_lines: 1 humid/counts: - contents_re: "[0-9]+ [0-9]+" + contents_re: '[0-9]+ [0-9]+' fn: counts.dat num_lines: 1 humid/neighbours: - contents_re: "[0-9]+ [0-9]+" + contents_re: '[0-9]+ [0-9]+' fn: neigh.dat num_lines: 1 humid/stats: - contents: "total: " + contents: 'total: ' fn: stats.dat num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/interop.md b/docs/markdown/modules/interop.md index b25b21cf70..00d588b079 100644 --- a/docs/markdown/modules/interop.md +++ b/docs/markdown/modules/interop.md @@ -2,7 +2,7 @@ title: Illumina InterOp Statistics displayed_sidebar: multiqcSidebar description: > -

Reading and writing InterOp metric files.

+

Reading and writing InterOp metric files.

--- :::note -

Reading and writing InterOp metric files.

[http://illumina.github.io/interop/index.html](http://illumina.github.io/interop/index.html) @@ -42,3 +41,4 @@ interop/index-summary: interop/summary: contents: Level,Yield,Projected Yield,Aligned,Error Rate,Intensity C1,%>=Q30 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/isoseq.md b/docs/markdown/modules/isoseq.md index 37049ed636..b5f0b74ce5 100644 --- a/docs/markdown/modules/isoseq.md +++ b/docs/markdown/modules/isoseq.md @@ -2,7 +2,7 @@ title: Iso-Seq displayed_sidebar: multiqcSidebar description: > -

Identifies transcripts in PacBio single-molecule sequencing data (HiFi reads).

+

Identifies transcripts in PacBio single-molecule sequencing data (HiFi reads).

--- :::note -

Identifies transcripts in PacBio single-molecule sequencing data (HiFi reads).

[https://github.com/PacificBiosciences/IsoSeq](https://github.com/PacificBiosciences/IsoSeq) @@ -27,19 +26,20 @@ Supports outputs generated by two commands: trims poly(A) tails and removes concatemers. - [IsoSeq `cluster`](https://github.com/PacificBiosciences/IsoSeq/blob/master/isoseq-clustering.md#step-4---clustering) - performs clustering using hierarchical n\*log(n) alignment and iterative cluster merging. + performs clustering using hierarchical n*log(n) alignment and iterative cluster merging. ### File search patterns ```yaml isoseq/cluster-csv: contents: cluster_id - fn: "*cluster_report.csv" + fn: '*cluster_report.csv' num_lines: 1 isoseq/refine-csv: contents: id,strand,fivelen,threelen,polyAlen,insertlen,primer - fn: "*.csv" + fn: '*.csv' isoseq/refine-json: contents: '"num_reads_fl"' - fn: "*.json" + fn: '*.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/ivar.md b/docs/markdown/modules/ivar.md index 705977aa53..c7086e3835 100644 --- a/docs/markdown/modules/ivar.md +++ b/docs/markdown/modules/ivar.md @@ -2,7 +2,7 @@ title: iVar displayed_sidebar: multiqcSidebar description: > -

Functions for viral amplicon-based sequencing.

+

Functions for viral amplicon-based sequencing.

--- :::note -

Functions for viral amplicon-based sequencing.

[https://github.com/andersen-lab/ivar](https://github.com/andersen-lab/ivar) @@ -31,3 +30,4 @@ ivar/trim: contents: Number of references num_lines: 8 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/jcvi.md b/docs/markdown/modules/jcvi.md index 71c1c0e435..24d2d791e4 100644 --- a/docs/markdown/modules/jcvi.md +++ b/docs/markdown/modules/jcvi.md @@ -2,7 +2,7 @@ title: JCVI Genome Annotation displayed_sidebar: multiqcSidebar description: > -

Computes statistics on genome annotation.

+

Computes statistics on genome annotation.

--- :::note -

Computes statistics on genome annotation.

[https://pypi.org/project/jcvi/](https://pypi.org/project/jcvi/) @@ -52,6 +51,7 @@ The JCVI module has been tested with output from JCVI v1.0.9. ```yaml jcvi: - contents: " o % GC % of genome Average size (bp) Median size (bp) Number Total - length (Mb)" + contents: ' o % GC % of genome Average size (bp) Median size (bp) Number Total + length (Mb)' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/jellyfish.md b/docs/markdown/modules/jellyfish.md index 1eaac7bf23..63ad273d61 100644 --- a/docs/markdown/modules/jellyfish.md +++ b/docs/markdown/modules/jellyfish.md @@ -2,7 +2,7 @@ title: Jellyfish displayed_sidebar: multiqcSidebar description: > -

Counting k-mers in DNA.

+

Counting k-mers in DNA.

--- :::note -

Counting k-mers in DNA.

[https://github.com/gmarcais/Jellyfish](https://github.com/gmarcais/Jellyfish) @@ -42,5 +41,6 @@ multiqc . --cl-config "sp: { jellyfish: { fn: '*.hist' } }" ```yaml jellyfish: - fn: "*_jf.hist" + fn: '*_jf.hist' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/kaiju.md b/docs/markdown/modules/kaiju.md index 75cd9a7efd..4c76e70e75 100644 --- a/docs/markdown/modules/kaiju.md +++ b/docs/markdown/modules/kaiju.md @@ -2,7 +2,7 @@ title: Kaiju displayed_sidebar: multiqcSidebar description: > -

Taxonomic classification for metagenomics.

+

Taxonomic classification for metagenomics.

--- :::note -

Taxonomic classification for metagenomics.

[http://kaiju.binf.ku.dk/](http://kaiju.binf.ku.dk/) @@ -36,3 +35,4 @@ kaiju: contents_re: file\tpercent\treads\ttaxon_id\ttaxon_name num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/kallisto.md b/docs/markdown/modules/kallisto.md index 18113d94bc..3f8a54ac6b 100644 --- a/docs/markdown/modules/kallisto.md +++ b/docs/markdown/modules/kallisto.md @@ -2,7 +2,7 @@ title: Kallisto displayed_sidebar: multiqcSidebar description: > -

Quantifies abundances of transcripts (or more generally, of target sequences) from RNA-Seq data.

+

Quantifies abundances of transcripts (or more generally, of target sequences) from RNA-Seq data.

--- :::note -

Quantifies abundances of transcripts (or more generally, of target sequences) from RNA-Seq data.

[http://pachterlab.github.io/kallisto/](http://pachterlab.github.io/kallisto/) @@ -29,5 +28,6 @@ Kallisto stdout to a file when running to use the MultiQC module. ```yaml kallisto: - contents: "[quant] finding pseudoalignments for the reads" + contents: '[quant] finding pseudoalignments for the reads' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/kat.md b/docs/markdown/modules/kat.md index fabcf9e25b..d11cd63a5b 100644 --- a/docs/markdown/modules/kat.md +++ b/docs/markdown/modules/kat.md @@ -2,7 +2,7 @@ title: K-mer Analysis Toolkit displayed_sidebar: multiqcSidebar description: > -

Analyses sequencing data via its k-mer spectra.

+

Analyses sequencing data via its k-mer spectra.

--- :::note -

Analyses sequencing data via its k-mer spectra.

[https://github.com/TGAC/KAT](https://github.com/TGAC/KAT) @@ -28,5 +27,6 @@ contain information such as estimated genome size and heterozygosity rates from ```yaml kat: - fn: "*.dist_analysis.json" + fn: '*.dist_analysis.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/kraken.md b/docs/markdown/modules/kraken.md index 3c1e1da27f..2a51c8942f 100644 --- a/docs/markdown/modules/kraken.md +++ b/docs/markdown/modules/kraken.md @@ -2,7 +2,7 @@ title: Kraken displayed_sidebar: multiqcSidebar description: > -

Taxonomic classification using exact k-mer matches to find the lowest common ancestor (LCA) of a given sequence.

+

Taxonomic classification using exact k-mer matches to find the lowest common ancestor (LCA) of a given sequence.

--- :::note -

Taxonomic classification using exact k-mer matches to find the lowest common ancestor (LCA) of a given sequence.

[https://ccb.jhu.edu/software/kraken/](https://ccb.jhu.edu/software/kraken/) @@ -53,3 +52,4 @@ kraken: contents_re: ^\s{0,2}(\d{1,3}\.\d{1,2})\t(\d+)\t(\d+)\t((\d+)\t(\d+)\t)?([URDKPCOFGS-]\d{0,2})\t(\d+)(\s+)[root|unclassified] num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/leehom.md b/docs/markdown/modules/leehom.md index efd6e3a835..54d71589e8 100644 --- a/docs/markdown/modules/leehom.md +++ b/docs/markdown/modules/leehom.md @@ -2,7 +2,7 @@ title: leeHom displayed_sidebar: multiqcSidebar description: > -

Bayesian reconstruction of ancient DNA.

+

Bayesian reconstruction of ancient DNA.

--- :::note -

Bayesian reconstruction of ancient DNA.

[https://github.com/grenaud/leeHom](https://github.com/grenaud/leeHom) @@ -33,3 +32,4 @@ leehom: contents: Adapter dimers/chimeras num_lines: 100 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/librarian.md b/docs/markdown/modules/librarian.md index e7367354f1..38c904f726 100644 --- a/docs/markdown/modules/librarian.md +++ b/docs/markdown/modules/librarian.md @@ -2,7 +2,7 @@ title: Librarian displayed_sidebar: multiqcSidebar description: > -

Predicts the sequencing library type from the base composition of a FastQ file.

+

Predicts the sequencing library type from the base composition of a FastQ file.

--- :::note -

Predicts the sequencing library type from the base composition of a FastQ file.

[https://github.com/DesmondWillowbrook/Librarian](https://github.com/DesmondWillowbrook/Librarian) @@ -48,3 +47,4 @@ librarian: librarian: fn: librarian_heatmap.txt ``` + \ No newline at end of file diff --git a/docs/markdown/modules/lima.md b/docs/markdown/modules/lima.md index 1302f59617..64c68b5b7c 100644 --- a/docs/markdown/modules/lima.md +++ b/docs/markdown/modules/lima.md @@ -2,7 +2,7 @@ title: Lima displayed_sidebar: multiqcSidebar description: > -

Demultiplex PacBio single-molecule sequencing reads.

+

Demultiplex PacBio single-molecule sequencing reads.

--- :::note -

Demultiplex PacBio single-molecule sequencing reads.

[https://github.com/PacificBiosciences/barcoding](https://github.com/PacificBiosciences/barcoding) @@ -48,3 +47,4 @@ lima/summary: max_filesize: 1024 num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/longranger.md b/docs/markdown/modules/longranger.md index a8224d3b69..4337018df2 100644 --- a/docs/markdown/modules/longranger.md +++ b/docs/markdown/modules/longranger.md @@ -2,7 +2,7 @@ title: Long Ranger displayed_sidebar: multiqcSidebar description: > -

Sample demultiplexing, barcode processing, alignment, quality control, variant calling, phasing, and structural variant calling.

+

Sample demultiplexing, barcode processing, alignment, quality control, variant calling, phasing, and structural variant calling.

--- :::note -

Sample demultiplexing, barcode processing, alignment, quality control, variant calling, phasing, and structural variant calling.

[https://support.10xgenomics.com/genome-exome/software/pipelines/latest/what-is-long-ranger](https://support.10xgenomics.com/genome-exome/software/pipelines/latest/what-is-long-ranger) @@ -45,6 +44,7 @@ longranger/invocation: max_filesize: 2048 longranger/summary: contents: longranger_version,instrument_ids,gems_detected,mean_dna_per_gem,bc_on_whitelist,bc_mean_qscore,n50_linked_reads_per_molecule - fn: "*summary.csv" + fn: '*summary.csv' num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/macs2.md b/docs/markdown/modules/macs2.md index 9f74d58fb9..e88ef3d56f 100644 --- a/docs/markdown/modules/macs2.md +++ b/docs/markdown/modules/macs2.md @@ -2,7 +2,7 @@ title: MACS2 displayed_sidebar: multiqcSidebar description: > -

Identifies transcription factor binding sites in ChIP-seq data.

+

Identifies transcription factor binding sites in ChIP-seq data.

--- :::note -

Identifies transcription factor binding sites in ChIP-seq data.

[https://macs3-project.github.io/MACS/](https://macs3-project.github.io/MACS/) @@ -33,5 +32,6 @@ found in the General Statistics table. Numerous additional values are parsed and ```yaml macs2: - fn: "*_peaks.xls" + fn: '*_peaks.xls' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/malt.md b/docs/markdown/modules/malt.md index cb5913141c..3ce235d1a8 100644 --- a/docs/markdown/modules/malt.md +++ b/docs/markdown/modules/malt.md @@ -2,7 +2,7 @@ title: MALT displayed_sidebar: multiqcSidebar description: > -

Aligns of metagenomic reads to a database of reference sequences (such as NR, GenBank or Silva) and outputs a MEGAN RMA file.

+

Aligns of metagenomic reads to a database of reference sequences (such as NR, GenBank or Silva) and outputs a MEGAN RMA file.

--- :::note -

Aligns of metagenomic reads to a database of reference sequences (such as NR, GenBank or Silva) and outputs a MEGAN RMA file.

[http://ab.inf.uni-tuebingen.de/software/malt/](http://ab.inf.uni-tuebingen.de/software/malt/) @@ -34,3 +33,4 @@ malt: contents: MaltRun - Aligns sequences using MALT (MEGAN alignment tool) num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/mapdamage.md b/docs/markdown/modules/mapdamage.md index a303cf5945..6ada7ed12d 100644 --- a/docs/markdown/modules/mapdamage.md +++ b/docs/markdown/modules/mapdamage.md @@ -2,7 +2,7 @@ title: mapDamage displayed_sidebar: multiqcSidebar description: > -

Tracks and quantifies damage patterns in ancient DNA sequences.

+

Tracks and quantifies damage patterns in ancient DNA sequences.

--- :::note -

Tracks and quantifies damage patterns in ancient DNA sequences.

[https://github.com/ginolhac/mapDamage](https://github.com/ginolhac/mapDamage) @@ -27,7 +26,8 @@ This module parses the base `misincorporation` output. ```yaml mapdamage: - - fn: 3p*_freq.txt - - fn: 5p*_freq.txt - - fn: lgdistribution.txt +- fn: 3p*_freq.txt +- fn: 5p*_freq.txt +- fn: lgdistribution.txt ``` + \ No newline at end of file diff --git a/docs/markdown/modules/megahit.md b/docs/markdown/modules/megahit.md index 887039dd5b..be31885303 100644 --- a/docs/markdown/modules/megahit.md +++ b/docs/markdown/modules/megahit.md @@ -2,7 +2,7 @@ title: MEGAHIT displayed_sidebar: multiqcSidebar description: > -

NGS read assembler.

+

NGS read assembler.

--- :::note -

NGS read assembler.

[https://github.com/voutcn/megahit](https://github.com/voutcn/megahit) @@ -28,6 +27,7 @@ name (e.g. `sample1.log` will yield a sample name of `sample1`). ```yaml megahit: - contents: " - MEGAHIT v" + contents: ' - MEGAHIT v' num_lines: 5 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/metaphlan.md b/docs/markdown/modules/metaphlan.md index ec6ae2fd25..e009106606 100644 --- a/docs/markdown/modules/metaphlan.md +++ b/docs/markdown/modules/metaphlan.md @@ -2,7 +2,7 @@ title: MetaPhlAn displayed_sidebar: multiqcSidebar description: > -

Profiles the composition of microbial communities from metagenomic shotgun sequencing data.

+

Profiles the composition of microbial communities from metagenomic shotgun sequencing data.

--- :::note -

Profiles the composition of microbial communities from metagenomic shotgun sequencing data.

[https://github.com/biobakery/MetaPhlAn](https://github.com/biobakery/MetaPhlAn) @@ -47,5 +46,6 @@ metaphlan: ```yaml metaphlan: contents: "#clade_name\tNCBI_tax_id\trelative_abundance\t" - fn: "*.txt" + fn: '*.txt' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/methylqa.md b/docs/markdown/modules/methylqa.md index 67e28d3bf1..406b44b5bf 100644 --- a/docs/markdown/modules/methylqa.md +++ b/docs/markdown/modules/methylqa.md @@ -2,7 +2,7 @@ title: methylQA displayed_sidebar: multiqcSidebar description: > -

Methylation sequencing data quality assessment tool.

+

Methylation sequencing data quality assessment tool.

--- :::note -

Methylation sequencing data quality assessment tool.

[http://methylqa.sourceforge.net/](http://methylqa.sourceforge.net/) @@ -25,6 +24,7 @@ File path for the source of this content: multiqc/modules/methylqa/methylqa.py ```yaml methylqa: - fn: "*.report" + fn: '*.report' shared: true ``` + \ No newline at end of file diff --git a/docs/markdown/modules/mgikit.md b/docs/markdown/modules/mgikit.md index 043c713177..14039169c5 100644 --- a/docs/markdown/modules/mgikit.md +++ b/docs/markdown/modules/mgikit.md @@ -2,7 +2,7 @@ title: mgikit displayed_sidebar: multiqcSidebar description: > -

Demultiplexes FASTQ files from an MGI sequencing instrument.

+

Demultiplexes FASTQ files from an MGI sequencing instrument.

--- :::note -

Demultiplexes FASTQ files from an MGI sequencing instrument.

[https://github.com/sagc-bioinformatics/mgikit](https://github.com/sagc-bioinformatics/mgikit) @@ -23,11 +22,11 @@ File path for the source of this content: multiqc/modules/mgikit/mgikit.py Possible mgikit output files are: -1. **Sample stats file** ('\*.L{1,2,3,4}.mgikit.sample_stats'): Sample statistics for each lane like yield, quality scores, cluster count -2. **General info file** ('\*.L{1,2,3,4}.mgikit.general'): Rounded up sample stats, but also includes lane-level stats -3. **General info file** ('\*.L{1,2,3,4}.mgikit.info'): Matching indexes within the data generated by a specific lane -4. **Undetermined barcodes file** ('\*.L{1,2,3,4}.mgikit.undetermined_barcode'): Barcodes that did not match with any sample. -5. **Ambiguous barcodes file** ('\*.L{1,2,3,4}.mgikit.ambiguous_barcode'): Barcodes that match with multiple samples. This situation can happen when setting a high mismatch threshold. +1. **Sample stats file** ('*.L{1,2,3,4}.mgikit.sample_stats'): Sample statistics for each lane like yield, quality scores, cluster count +2. **General info file** ('*.L{1,2,3,4}.mgikit.general'): Rounded up sample stats, but also includes lane-level stats +3. **General info file** ('*.L{1,2,3,4}.mgikit.info'): Matching indexes within the data generated by a specific lane +4. **Undetermined barcodes file** ('*.L{1,2,3,4}.mgikit.undetermined_barcode'): Barcodes that did not match with any sample. +5. **Ambiguous barcodes file** ('*.L{1,2,3,4}.mgikit.ambiguous_barcode'): Barcodes that match with multiple samples. This situation can happen when setting a high mismatch threshold. Configuration options: @@ -47,13 +46,14 @@ mgikit: ```yaml mgikit/mgi_ambiguous_barcode: - fn: "*.mgikit.ambiguous_barcode" + fn: '*.mgikit.ambiguous_barcode' mgikit/mgi_general_info: - fn: "*.mgikit.general" + fn: '*.mgikit.general' mgikit/mgi_sample_reads: - fn: "*.mgikit.info" + fn: '*.mgikit.info' mgikit/mgi_sample_stats: - fn: "*.mgikit.sample_stats" + fn: '*.mgikit.sample_stats' mgikit/mgi_undetermined_barcode: - fn: "*.mgikit.undetermined_barcode" + fn: '*.mgikit.undetermined_barcode' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/minionqc.md b/docs/markdown/modules/minionqc.md index 65de4abb31..57bfc0b4d8 100644 --- a/docs/markdown/modules/minionqc.md +++ b/docs/markdown/modules/minionqc.md @@ -2,7 +2,7 @@ title: MinIONQC displayed_sidebar: multiqcSidebar description: > -

Quality control for ONT (Oxford Nanopore) long reads.

+

Quality control for ONT (Oxford Nanopore) long reads.

--- :::note -

Quality control for ONT (Oxford Nanopore) long reads.

[https://github.com/roblanf/minion_qc](https://github.com/roblanf/minion_qc) @@ -34,3 +33,4 @@ minionqc: contents: total.gigabases fn: summary.yaml ``` + \ No newline at end of file diff --git a/docs/markdown/modules/mirtop.md b/docs/markdown/modules/mirtop.md index 59b7c8ba10..ba78706b15 100644 --- a/docs/markdown/modules/mirtop.md +++ b/docs/markdown/modules/mirtop.md @@ -2,7 +2,7 @@ title: mirtop displayed_sidebar: multiqcSidebar description: > -

Annotates miRNAs and isomiRs and compute general statistics in mirGFF3 format.

+

Annotates miRNAs and isomiRs and compute general statistics in mirGFF3 format.

--- :::note -

Annotates miRNAs and isomiRs and compute general statistics in mirGFF3 format.

[https://github.com/miRTop/mirtop/](https://github.com/miRTop/mirtop/) @@ -31,5 +30,6 @@ isomiR-SEA, sRNAbench, Prost! as well as BAM files. ```yaml mirtop: - fn: "*_mirtop_stats.log" + fn: '*_mirtop_stats.log' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/mirtrace.md b/docs/markdown/modules/mirtrace.md index 09e10a0fd6..615cc55078 100644 --- a/docs/markdown/modules/mirtrace.md +++ b/docs/markdown/modules/mirtrace.md @@ -2,7 +2,7 @@ title: miRTrace displayed_sidebar: multiqcSidebar description: > -

Quality control for small RNA sequencing data.

+

Quality control for small RNA sequencing data.

--- :::note -

Quality control for small RNA sequencing data.

[https://github.com/friedlanderlab/mirtrace](https://github.com/friedlanderlab/mirtrace) @@ -44,3 +43,4 @@ mirtrace/mirnacomplexity: mirtrace/summary: fn: mirtrace-results.json ``` + \ No newline at end of file diff --git a/docs/markdown/modules/mosaicatcher.md b/docs/markdown/modules/mosaicatcher.md index 724160bfca..9ec06c6b0f 100644 --- a/docs/markdown/modules/mosaicatcher.md +++ b/docs/markdown/modules/mosaicatcher.md @@ -2,7 +2,7 @@ title: MosaiCatcher displayed_sidebar: multiqcSidebar description: > -

Counts strand-seq reads and classifies strand states of each chromosome in each cell using a Hidden Markov Model.

+

Counts strand-seq reads and classifies strand states of each chromosome in each cell using a Hidden Markov Model.

--- :::note -

Counts strand-seq reads and classifies strand states of each chromosome in each cell using a Hidden Markov Model.

[https://github.com/friendsofstrandseq/mosaicatcher](https://github.com/friendsofstrandseq/mosaicatcher) @@ -25,5 +24,6 @@ File path for the source of this content: multiqc/modules/mosaicatcher/mosaicatc ```yaml mosaicatcher: - fn: "*.mosaicatcher_info_raw.txt" + fn: '*.mosaicatcher_info_raw.txt' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/mosdepth.md b/docs/markdown/modules/mosdepth.md index b4d8a7fab4..31699f9986 100644 --- a/docs/markdown/modules/mosdepth.md +++ b/docs/markdown/modules/mosdepth.md @@ -2,7 +2,7 @@ title: Mosdepth displayed_sidebar: multiqcSidebar description: > -

Fast BAM/CRAM depth calculation for WGS, exome, or targeted sequencing.

+

Fast BAM/CRAM depth calculation for WGS, exome, or targeted sequencing.

--- :::note -

Fast BAM/CRAM depth calculation for WGS, exome, or targeted sequencing.

[https://github.com/brentp/mosdepth](https://github.com/brentp/mosdepth) @@ -120,9 +119,10 @@ mosdepth_config: ```yaml mosdepth/global_dist: - fn: "*.mosdepth.global.dist.txt" + fn: '*.mosdepth.global.dist.txt' mosdepth/region_dist: - fn: "*.mosdepth.region.dist.txt" + fn: '*.mosdepth.region.dist.txt' mosdepth/summary: - fn: "*.mosdepth.summary.txt" + fn: '*.mosdepth.summary.txt' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/motus.md b/docs/markdown/modules/motus.md index 168ace304b..9648b26151 100644 --- a/docs/markdown/modules/motus.md +++ b/docs/markdown/modules/motus.md @@ -2,7 +2,7 @@ title: Motus displayed_sidebar: multiqcSidebar description: > -

Microbial profiling through marker gene (MG)-based operational taxonomic units (mOTUs).

+

Microbial profiling through marker gene (MG)-based operational taxonomic units (mOTUs).

--- :::note -

Microbial profiling through marker gene (MG)-based operational taxonomic units (mOTUs).

[https://motu-tool.org/](https://motu-tool.org/) @@ -30,3 +29,4 @@ motus: contents: Reads are aligned (by BWA) to marker gene sequences in the reference database num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/mtnucratio.md b/docs/markdown/modules/mtnucratio.md index d7a7529074..11bcd403a4 100644 --- a/docs/markdown/modules/mtnucratio.md +++ b/docs/markdown/modules/mtnucratio.md @@ -2,7 +2,7 @@ title: mtnucratio displayed_sidebar: multiqcSidebar description: > -

Computes mitochondrial to nuclear genome ratios in NGS datasets.

+

Computes mitochondrial to nuclear genome ratios in NGS datasets.

--- :::note -

Computes mitochondrial to nuclear genome ratios in NGS datasets.

[http://www.github.com/apeltzer/MTNucRatioCalculator](http://www.github.com/apeltzer/MTNucRatioCalculator) @@ -25,5 +24,6 @@ File path for the source of this content: multiqc/modules/mtnucratio/mtnucratio. ```yaml mtnucratio: - fn: "*mtnuc.json" + fn: '*mtnuc.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/multivcfanalyzer.md b/docs/markdown/modules/multivcfanalyzer.md index d70e8129a2..9056bcdae5 100644 --- a/docs/markdown/modules/multivcfanalyzer.md +++ b/docs/markdown/modules/multivcfanalyzer.md @@ -2,7 +2,7 @@ title: MultiVCFAnalyzer displayed_sidebar: multiqcSidebar description: > -

Reads multiple VCF files into combined genotype calls, produces summary statistics and downstream formats.

+

Reads multiple VCF files into combined genotype calls, produces summary statistics and downstream formats.

--- :::note -

Reads multiple VCF files into combined genotype calls, produces summary statistics and downstream formats.

[https://github.com/alexherbig/MultiVCFAnalyzer](https://github.com/alexherbig/MultiVCFAnalyzer) @@ -29,3 +28,4 @@ The downstream formats are useful for follow-up analyses such as phylogeny recon multivcfanalyzer: fn: MultiVCFAnalyzer.json ``` + \ No newline at end of file diff --git a/docs/markdown/modules/nanoq.md b/docs/markdown/modules/nanoq.md index 98add59812..394bba68f6 100644 --- a/docs/markdown/modules/nanoq.md +++ b/docs/markdown/modules/nanoq.md @@ -2,7 +2,7 @@ title: nanoq displayed_sidebar: multiqcSidebar description: > -

Reports read quality and length from nanopore sequencing data.

+

Reports read quality and length from nanopore sequencing data.

--- :::note -

Reports read quality and length from nanopore sequencing data.

[https://github.com/nerdna/nanoq/](https://github.com/nerdna/nanoq/) @@ -28,3 +27,4 @@ nanoq: contents: Nanoq Read Summary num_lines: 3 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/nanostat.md b/docs/markdown/modules/nanostat.md index e3044415ef..ba88064fda 100644 --- a/docs/markdown/modules/nanostat.md +++ b/docs/markdown/modules/nanostat.md @@ -2,7 +2,7 @@ title: NanoStat displayed_sidebar: multiqcSidebar description: > -

Reports various statistics for long read dataset in FASTQ, BAM, or albacore sequencing summary format (supports NanoPack; NanoPlot, NanoComp).

+

Reports various statistics for long read dataset in FASTQ, BAM, or albacore sequencing summary format (supports NanoPack; NanoPlot, NanoComp).

--- :::note -

Reports various statistics for long read dataset in FASTQ, BAM, or albacore sequencing summary format (supports NanoPack; NanoPlot, NanoComp).

[https://github.com/wdecoster/nanostat/](https://github.com/wdecoster/nanostat/), [https://github.com/wdecoster/nanoplot/](https://github.com/wdecoster/nanoplot/) @@ -33,48 +32,48 @@ For example, to show number of reads, mean read length and median quality for FA ```yaml general_stats_columns: - nanostat: - columns: - Number of reads_fastq: - title: "# Reads" - description: "Number of reads" - hidden: false - Mean read length_fastq: - title: "Mean Length" - description: "Mean read length" - hidden: false - Median read quality_fastq: - title: "Median Quality" - description: "Median read quality" - hidden: false + nanostat: + columns: + Number of reads_fastq: + title: "# Reads" + description: "Number of reads" + hidden: false + Mean read length_fastq: + title: "Mean Length" + description: "Mean read length" + hidden: false + Median read quality_fastq: + title: "Median Quality" + description: "Median read quality" + hidden: false ``` Available metrics that can be added to General Statistics (append `_fastq`, `_aligned`, `_fasta` or `_seq summary` depending on the data type): -- `Active channels` - Number of active channels -- `Median read length` - Median read length (bp) -- `Mean read length` - Mean read length (bp) -- `Read length N50` - Read length N50 -- `Median read quality` - Median read quality (Phred scale) -- `Mean read quality` - Mean read quality (Phred scale) -- `Median percent identity` - Median percent identity -- `Average percent identity` - Average percent identity -- `Number of reads` - Number of reads -- `Total bases` - Total number of bases -- `Total bases aligned` - Total number of aligned bases +* `Active channels` - Number of active channels +* `Median read length` - Median read length (bp) +* `Mean read length` - Mean read length (bp) +* `Read length N50` - Read length N50 +* `Median read quality` - Median read quality (Phred scale) +* `Mean read quality` - Mean read quality (Phred scale) +* `Median percent identity` - Median percent identity +* `Average percent identity` - Average percent identity +* `Number of reads` - Number of reads +* `Total bases` - Total number of bases +* `Total bases aligned` - Total number of aligned bases Each metric can be customized with the following options: -- `title` - Column title -- `description` - Column description -- `hidden` - Whether to hide the column by default -- `scale` - Color scale for the column -- `format` - Number format -- `min` - Minimum value for the color scale -- `max` - Maximum value for the color scale -- `suffix` - Suffix to add to values -- `shared_key` - Share color scale with other columns +* `title` - Column title +* `description` - Column description +* `hidden` - Whether to hide the column by default +* `scale` - Color scale for the column +* `format` - Number format +* `min` - Minimum value for the color scale +* `max` - Maximum value for the color scale +* `suffix` - Suffix to add to values +* `shared_key` - Share color scale with other columns ### File search patterns @@ -88,3 +87,4 @@ nanostat/legacy: max_filesize: 4096 num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/nextclade.md b/docs/markdown/modules/nextclade.md index 2af051a12c..554acb3436 100644 --- a/docs/markdown/modules/nextclade.md +++ b/docs/markdown/modules/nextclade.md @@ -2,7 +2,7 @@ title: Nextclade displayed_sidebar: multiqcSidebar description: > -

Viral genome alignment, clade assignment, mutation calling, and quality checks.

+

Viral genome alignment, clade assignment, mutation calling, and quality checks.

--- :::note -

Viral genome alignment, clade assignment, mutation calling, and quality checks.

[https://github.com/nextstrain/nextclade](https://github.com/nextstrain/nextclade) @@ -32,3 +31,4 @@ nextclade: contents: seqName;clade; num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/ngsbits.md b/docs/markdown/modules/ngsbits.md index 311f50bb63..4e4cb0bb8e 100644 --- a/docs/markdown/modules/ngsbits.md +++ b/docs/markdown/modules/ngsbits.md @@ -2,7 +2,7 @@ title: ngs-bits displayed_sidebar: multiqcSidebar description: > -

Calculating statistics from FASTQ, BAM, and VCF.

+

Calculating statistics from FASTQ, BAM, and VCF.

--- :::note -

Calculating statistics from FASTQ, BAM, and VCF.

[https://github.com/imgag/ngs-bits](https://github.com/imgag/ngs-bits) ::: The ngs-bits module parses XML output generated for several tools in the ngs-bits collection: - -- [ReadQC](https://github.com/imgag/ngs-bits/blob/master/doc/tools/ReadQC.md) for statistics on FASTQ files, -- [MappingQC](https://github.com/imgag/ngs-bits/blob/master/doc/tools/MappingQC.md) for statistics on BAM files, -- [SampleGender](https://github.com/imgag/ngs-bits/blob/master/doc/tools/SampleGender.md) for gender prediction based on sequencing data. +* [ReadQC](https://github.com/imgag/ngs-bits/blob/master/doc/tools/ReadQC.md) for statistics on FASTQ files, +* [MappingQC](https://github.com/imgag/ngs-bits/blob/master/doc/tools/MappingQC.md) for statistics on BAM files, +* [SampleGender](https://github.com/imgag/ngs-bits/blob/master/doc/tools/SampleGender.md) for gender prediction based on sequencing data. ### File search patterns ```yaml ngsbits/mappingqc: - - contents: MappingQC - fn: "*.qcML" - num_lines: 20 +- contents: MappingQC + fn: '*.qcML' + num_lines: 20 ngsbits/readqc: - - contents: ReadQC - fn: "*.qcML" - num_lines: 20 - - contents: SeqPurge - fn: "*.qcML" - num_lines: 20 +- contents: ReadQC + fn: '*.qcML' + num_lines: 20 +- contents: SeqPurge + fn: '*.qcML' + num_lines: 20 ngsbits/samplegender: - - fn: "*_ngsbits_sex.tsv" +- fn: '*_ngsbits_sex.tsv' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/ngsderive.md b/docs/markdown/modules/ngsderive.md index e93a86b399..d715130b1d 100644 --- a/docs/markdown/modules/ngsderive.md +++ b/docs/markdown/modules/ngsderive.md @@ -2,7 +2,7 @@ title: ngsderive displayed_sidebar: multiqcSidebar description: > -

Forensic tool for by backwards computing library information in sequencing data.

+

Forensic tool for by backwards computing library information in sequencing data.

--- :::note -

Forensic tool for by backwards computing library information in sequencing data.

[https://github.com/stjudecloud/ngsderive](https://github.com/stjudecloud/ngsderive) @@ -44,3 +43,4 @@ ngsderive/strandedness: contents: "File\tTotalReads\tForwardPct\tReversePct\tPredicted" num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/nonpareil.md b/docs/markdown/modules/nonpareil.md index 8bdd46e5bb..37abb78cf2 100644 --- a/docs/markdown/modules/nonpareil.md +++ b/docs/markdown/modules/nonpareil.md @@ -2,7 +2,7 @@ title: Nonpareil displayed_sidebar: multiqcSidebar description: > -

Estimates metagenomic coverage and sequence diversity.

+

Estimates metagenomic coverage and sequence diversity.

--- :::note -

Estimates metagenomic coverage and sequence diversity.

[https://github.com/lmrodriguezr/nonpareil](https://github.com/lmrodriguezr/nonpareil) @@ -27,7 +26,6 @@ to achieve "nearly complete coverage", defined as ≥95% or ≥99% average cover Since Nonpareil main output has no model information, it is necessary extract the `curves` object as a `JSON` file. From version `v3.5.5` this can be done with an auxiliary `R` script, briefly: - ```bash NonpareilCurves.R --json out.json model.npo ``` @@ -47,8 +45,9 @@ nonpareil: ```yaml nonpareil: - - contents: LRstar - fn: "*.json" - max_filesize: 1048576 - num_lines: 50 +- contents: LRstar + fn: '*.json' + max_filesize: 1048576 + num_lines: 50 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/odgi.md b/docs/markdown/modules/odgi.md index 33fa716d2f..98f18e75a7 100644 --- a/docs/markdown/modules/odgi.md +++ b/docs/markdown/modules/odgi.md @@ -2,7 +2,7 @@ title: ODGI displayed_sidebar: multiqcSidebar description: > -

Analysis and manipulation of pangenome graphs structured in the variation graph model.

+

Analysis and manipulation of pangenome graphs structured in the variation graph model.

--- :::note -

Analysis and manipulation of pangenome graphs structured in the variation graph model.

[https://github.com/pangenome/odgi](https://github.com/pangenome/odgi) @@ -72,10 +71,10 @@ sum_of_path_node_distances: For the odgi module to discover the [odgi stats](https://odgi.readthedocs.io/en/latest/rst/commands/odgi_stats.html) reports, the file must match one of the following patterns: -- "\*.og.stats.yaml" -- "\*.og.stats.yml" -- "\*.odgi.stats.yaml" -- "\*.odgi.stats.yml" +- "*.og.stats.yaml" +- "*.og.stats.yml" +- "*.odgi.stats.yaml" +- "*.odgi.stats.yml" A bar graph is generated, which shows the length, number of nodes, edges and paths for each sample. Additionally, a second bar graph is generated visualizing the `in_node_space` and `in_nucleotide_space` for every sample. @@ -89,8 +88,9 @@ Ensure that the names of the PNGs match `*_odgi_viz_mqc.png`. ```yaml odgi: - - fn: "*.og.stats.yaml" - - fn: "*.og.stats.yml" - - fn: "*.odgi.stats.yaml" - - fn: "*.odgi.stats.yml" +- fn: '*.og.stats.yaml' +- fn: '*.og.stats.yml' +- fn: '*.odgi.stats.yaml' +- fn: '*.odgi.stats.yml' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/optitype.md b/docs/markdown/modules/optitype.md index 11d5650d20..4df120e23c 100644 --- a/docs/markdown/modules/optitype.md +++ b/docs/markdown/modules/optitype.md @@ -2,7 +2,7 @@ title: OptiType displayed_sidebar: multiqcSidebar description: > -

Precision HLA typing from next-generation sequencing data.

+

Precision HLA typing from next-generation sequencing data.

--- :::note -

Precision HLA typing from next-generation sequencing data.

[https://github.com/FRED-2/OptiType](https://github.com/FRED-2/OptiType) @@ -31,3 +30,4 @@ optitype: contents: "\tA1\tA2\tB1\tB2\tC1\tC2\tReads\tObjective" num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/pairtools.md b/docs/markdown/modules/pairtools.md index 75691f5e46..2faed30296 100644 --- a/docs/markdown/modules/pairtools.md +++ b/docs/markdown/modules/pairtools.md @@ -2,7 +2,7 @@ title: pairtools displayed_sidebar: multiqcSidebar description: > -

Toolkit for Chromatin Conformation Capture experiments. Handles short-reads paired reference alignments, extracts 3C-specific information, and perform common tasks such as sorting, filtering, and deduplication.

+

Toolkit for Chromatin Conformation Capture experiments. Handles short-reads paired reference alignments, extracts 3C-specific information, and perform common tasks such as sorting, filtering, and deduplication.

--- :::note -

Toolkit for Chromatin Conformation Capture experiments. Handles short-reads paired reference alignments, extracts 3C-specific information, and perform common tasks such as sorting, filtering, and deduplication.

[https://github.com/mirnylab/pairtools](https://github.com/mirnylab/pairtools) @@ -28,9 +27,10 @@ The module parses summary statistics generated by pairtools's `dedup` and `stats ```yaml pairtools: contents: - - "total_single_sided_mapped\t" - - "cis\t" - - "trans\t" - - pair_types/ + - "total_single_sided_mapped\t" + - "cis\t" + - "trans\t" + - pair_types/ num_lines: 20 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/pangolin.md b/docs/markdown/modules/pangolin.md index e01b987777..bb630ac551 100644 --- a/docs/markdown/modules/pangolin.md +++ b/docs/markdown/modules/pangolin.md @@ -2,7 +2,7 @@ title: Pangolin displayed_sidebar: multiqcSidebar description: > -

Uses variant calls to assign SARS-CoV-2 genome sequences to global lineages.

+

Uses variant calls to assign SARS-CoV-2 genome sequences to global lineages.

--- :::note -

Uses variant calls to assign SARS-CoV-2 genome sequences to global lineages.

[https://github.com/cov-lineages/pangolin](https://github.com/cov-lineages/pangolin) @@ -32,3 +31,4 @@ pangolin: contents: pangolin_version num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/pbmarkdup.md b/docs/markdown/modules/pbmarkdup.md index 7617173a10..6a38c4b994 100644 --- a/docs/markdown/modules/pbmarkdup.md +++ b/docs/markdown/modules/pbmarkdup.md @@ -2,7 +2,7 @@ title: pbmarkdup displayed_sidebar: multiqcSidebar description: > -

Takes one or multiple sequencing chips of an amplified libray as HiFi reads and marks or removes duplicates.

+

Takes one or multiple sequencing chips of an amplified libray as HiFi reads and marks or removes duplicates.

--- :::note -

Takes one or multiple sequencing chips of an amplified libray as HiFi reads and marks or removes duplicates.

[https://github.com/PacificBiosciences/pbmarkdup](https://github.com/PacificBiosciences/pbmarkdup) @@ -31,3 +30,4 @@ pbmarkdup: contents_re: LIBRARY +READS +UNIQUE MOLECULES +DUPLICATE READS num_lines: 5 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/peddy.md b/docs/markdown/modules/peddy.md index f2b7296397..bd6cf9aa65 100644 --- a/docs/markdown/modules/peddy.md +++ b/docs/markdown/modules/peddy.md @@ -2,7 +2,7 @@ title: Peddy displayed_sidebar: multiqcSidebar description: > -

Compares familial-relationships and sexes as reported in a PED file with those inferred from a VCF.

+

Compares familial-relationships and sexes as reported in a PED file with those inferred from a VCF.

--- :::note -

Compares familial-relationships and sexes as reported in a PED file with those inferred from a VCF.

[https://github.com/brentp/peddy](https://github.com/brentp/peddy) @@ -28,13 +27,14 @@ It does this very quickly by sampling, by using C for computationally intensive ```yaml peddy/background_pca: - fn: "*.background_pca.json" + fn: '*.background_pca.json' peddy/het_check: - fn: "*.het_check.csv" + fn: '*.het_check.csv' peddy/ped_check: - fn: "*.ped_check.csv" + fn: '*.ped_check.csv' peddy/sex_check: - fn: "*.sex_check.csv" + fn: '*.sex_check.csv' peddy/summary_table: - fn: "*.peddy.ped" + fn: '*.peddy.ped' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/percolator.md b/docs/markdown/modules/percolator.md index 9d6691895a..1675e2a2e7 100644 --- a/docs/markdown/modules/percolator.md +++ b/docs/markdown/modules/percolator.md @@ -2,7 +2,7 @@ title: Percolator displayed_sidebar: multiqcSidebar description: > -

Semi-supervised learning for peptide identification from shotgun proteomics datasets.

+

Semi-supervised learning for peptide identification from shotgun proteomics datasets.

--- :::note -

Semi-supervised learning for peptide identification from shotgun proteomics datasets.

[https://github.com/percolator/percolator](https://github.com/percolator/percolator) @@ -29,8 +28,7 @@ percolator ... > samples.percolator_feature_weights.tsv ``` The module accepts one configuration option: - -- `group_to_feature`: A dictionary mapping group names to feature names (empty per default), e.g. in `multiqc_config.yaml`: + - `group_to_feature`: A dictionary mapping group names to feature names (empty per default), e.g. in `multiqc_config.yaml`: ```yaml percolator: @@ -44,5 +42,6 @@ percolator: ```yaml percolator: - fn: "*percolator_feature_weights.tsv" + fn: '*percolator_feature_weights.tsv' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/phantompeakqualtools.md b/docs/markdown/modules/phantompeakqualtools.md index dc22bf6262..c8565240aa 100644 --- a/docs/markdown/modules/phantompeakqualtools.md +++ b/docs/markdown/modules/phantompeakqualtools.md @@ -2,7 +2,7 @@ title: phantompeakqualtools displayed_sidebar: multiqcSidebar description: > -

Computes informative enrichment and quality measures for ChIP-seq/DNase-seq/FAIRE-seq/MNase-seq data.

+

Computes informative enrichment and quality measures for ChIP-seq/DNase-seq/FAIRE-seq/MNase-seq data.

--- :::note -

Computes informative enrichment and quality measures for ChIP-seq/DNase-seq/FAIRE-seq/MNase-seq data.

[https://www.encodeproject.org/software/phantompeakqualtools](https://www.encodeproject.org/software/phantompeakqualtools) @@ -30,5 +29,6 @@ measure of library complexity. PBC is the ratio of (non-redundant, uniquely mapp ```yaml phantompeakqualtools/out: - fn: "*.spp.out" + fn: '*.spp.out' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/picard.md b/docs/markdown/modules/picard.md index ac4377c4ba..fc71ffce7b 100644 --- a/docs/markdown/modules/picard.md +++ b/docs/markdown/modules/picard.md @@ -2,7 +2,7 @@ title: Picard displayed_sidebar: multiqcSidebar description: > -

Tools for manipulating high-throughput sequencing data.

+

Tools for manipulating high-throughput sequencing data.

--- :::note -

Tools for manipulating high-throughput sequencing data.

[http://broadinstitute.github.io/picard/](http://broadinstitute.github.io/picard/) @@ -240,8 +239,8 @@ picard_config: ```yaml picard/alignment_metrics: - - contents: picard.analysis.AlignmentSummaryMetrics - - contents: --algo AlignmentStat +- contents: picard.analysis.AlignmentSummaryMetrics +- contents: --algo AlignmentStat picard/basedistributionbycycle: contents: BaseDistributionByCycleMetrics picard/collectilluminabasecallingmetrics: @@ -253,64 +252,65 @@ picard/crosscheckfingerprints: picard/extractilluminabarcodes: contents: ExtractIlluminaBarcodes picard/gcbias: - - contents: GcBiasDetailMetrics - - contents: GcBiasSummaryMetrics - - contents: --algo GCBias +- contents: GcBiasDetailMetrics +- contents: GcBiasSummaryMetrics +- contents: --algo GCBias picard/hsmetrics: - - contents: HsMetrics - - contents: --algo HsMetricAlgo +- contents: HsMetrics +- contents: --algo HsMetricAlgo picard/insertsize: - - contents: picard.analysis.InsertSizeMetrics - - contents: --algo InsertSizeMetricAlgo +- contents: picard.analysis.InsertSizeMetrics +- contents: --algo InsertSizeMetricAlgo picard/markdups: - - contents: picard.sam.MarkDuplicates - - contents: picard.sam.DuplicationMetrics - - contents: picard.sam.markduplicates.MarkDuplicates - - contents: markduplicates.DuplicationMetrics - - contents: MarkDuplicatesSpark - - contents: markduplicates.GATKDuplicationMetrics - - contents: --algo Dedup +- contents: picard.sam.MarkDuplicates +- contents: picard.sam.DuplicationMetrics +- contents: picard.sam.markduplicates.MarkDuplicates +- contents: markduplicates.DuplicationMetrics +- contents: MarkDuplicatesSpark +- contents: markduplicates.GATKDuplicationMetrics +- contents: --algo Dedup picard/markilluminaadapters: contents: MarkIlluminaAdapters picard/oxogmetrics: - - contents: "# picard.analysis.CollectOxoGMetrics" - - contents: "# CollectOxoGMetrics" - - contents_re: "# CollectMultipleMetrics .*OxoGMetrics" - shared: true +- contents: '# picard.analysis.CollectOxoGMetrics' +- contents: '# CollectOxoGMetrics' +- contents_re: '# CollectMultipleMetrics .*OxoGMetrics' + shared: true picard/pcr_metrics: - - contents: "# picard.analysis.directed.CollectTargetedPcrMetrics" - - contents_re: "# CollectMultipleMetrics .*TargetedPcrMetrics" - shared: true +- contents: '# picard.analysis.directed.CollectTargetedPcrMetrics' +- contents_re: '# CollectMultipleMetrics .*TargetedPcrMetrics' + shared: true picard/quality_by_cycle: - - contents: "# MeanQualityByCycle" - - contents: --algo MeanQualityByCycle - - contents_re: .*CollectMultipleMetrics.*MeanQualityByCycle - shared: true +- contents: '# MeanQualityByCycle' +- contents: --algo MeanQualityByCycle +- contents_re: .*CollectMultipleMetrics.*MeanQualityByCycle + shared: true picard/quality_score_distribution: - - contents: "# QualityScoreDistribution" - - contents: --algo QualDistribution - - contents_re: .*CollectMultipleMetrics.*QualityScoreDistribution - shared: true +- contents: '# QualityScoreDistribution' +- contents: --algo QualDistribution +- contents_re: .*CollectMultipleMetrics.*QualityScoreDistribution + shared: true picard/quality_yield_metrics: - - contents: "# CollectQualityYieldMetrics" - - contents_re: .*CollectMultipleMetrics.*QualityYieldMetrics - shared: true +- contents: '# CollectQualityYieldMetrics' +- contents_re: .*CollectMultipleMetrics.*QualityYieldMetrics + shared: true picard/rnaseqmetrics: - - contents: "# picard.analysis.Collectrnaseqmetrics" - - contents: "# picard.analysis.CollectRnaSeqMetrics" - - contents: "# CollectRnaSeqMetrics" - - contents_re: "# CollectMultipleMetrics .*RnaSeqMetrics" - shared: true +- contents: '# picard.analysis.Collectrnaseqmetrics' +- contents: '# picard.analysis.CollectRnaSeqMetrics' +- contents: '# CollectRnaSeqMetrics' +- contents_re: '# CollectMultipleMetrics .*RnaSeqMetrics' + shared: true picard/rrbs_metrics: - - contents: "# picard.analysis.CollectRrbsMetrics" - - contents_re: "# CollectMultipleMetrics .*RrbsMetrics" - shared: true +- contents: '# picard.analysis.CollectRrbsMetrics' +- contents_re: '# CollectMultipleMetrics .*RrbsMetrics' + shared: true picard/sam_file_validation: - fn: "*[Vv]alidate[Ss]am[Ff]ile*" + fn: '*[Vv]alidate[Ss]am[Ff]ile*' picard/variant_calling_metrics: - contents_re: "## METRICS CLASS.*VariantCallingDetailMetrics" + contents_re: '## METRICS CLASS.*VariantCallingDetailMetrics' picard/wgs_metrics: - - contents: --algo WgsMetricsAlgo - - contents_re: "## METRICS CLASS.*WgsMetrics" - shared: true +- contents: --algo WgsMetricsAlgo +- contents_re: '## METRICS CLASS.*WgsMetrics' + shared: true ``` + \ No newline at end of file diff --git a/docs/markdown/modules/porechop.md b/docs/markdown/modules/porechop.md index 442fe419e6..0964cfb665 100644 --- a/docs/markdown/modules/porechop.md +++ b/docs/markdown/modules/porechop.md @@ -2,7 +2,7 @@ title: Porechop displayed_sidebar: multiqcSidebar description: > -

Finds and removes adapters from Oxford Nanopore reads.

+

Finds and removes adapters from Oxford Nanopore reads.

--- :::note -

Finds and removes adapters from Oxford Nanopore reads.

[https://github.com/rrwick/Porechop](https://github.com/rrwick/Porechop) @@ -38,3 +37,4 @@ porechop: contents: Looking for known adapter sets num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/preseq.md b/docs/markdown/modules/preseq.md index 24227c7280..5714e1cd94 100644 --- a/docs/markdown/modules/preseq.md +++ b/docs/markdown/modules/preseq.md @@ -2,7 +2,7 @@ title: Preseq displayed_sidebar: multiqcSidebar description: > -

Estimates library complexity, showing how many additional unique reads are sequenced for increasing total read count.

+

Estimates library complexity, showing how many additional unique reads are sequenced for increasing total read count.

--- :::note -

Estimates library complexity, showing how many additional unique reads are sequenced for increasing total read count.

[http://smithlabresearch.org/software/preseq/](http://smithlabresearch.org/software/preseq/) @@ -90,10 +89,11 @@ echo "Sample_1.preseq.txt "$(samtools view -c -F 4 Sample_1.bam)" "$(samtools vi ```yaml preseq: - - contents: EXPECTED_DISTINCT - num_lines: 2 - - contents: distinct_reads - num_lines: 2 +- contents: EXPECTED_DISTINCT + num_lines: 2 +- contents: distinct_reads + num_lines: 2 preseq/real_counts: - fn: "*preseq_real_counts*" + fn: '*preseq_real_counts*' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/prinseqplusplus.md b/docs/markdown/modules/prinseqplusplus.md index 7d8aa83bfd..ae691809f3 100644 --- a/docs/markdown/modules/prinseqplusplus.md +++ b/docs/markdown/modules/prinseqplusplus.md @@ -2,7 +2,7 @@ title: PRINSEQ++ displayed_sidebar: multiqcSidebar description: > -

C++ implementation of the prinseq-lite.pl program. Filters, reformats, and trims genomic and metagenomic reads.

+

C++ implementation of the prinseq-lite.pl program. Filters, reformats, and trims genomic and metagenomic reads.

--- :::note -

C++ implementation of the prinseq-lite.pl program. Filters, reformats, and trims genomic and metagenomic reads.

[https://github.com/Adrian-Cantu/PRINSEQ-plus-plus](https://github.com/Adrian-Cantu/PRINSEQ-plus-plus) @@ -29,6 +28,7 @@ It uses the log file name as the sample name. ```yaml prinseqplusplus: - - contents: reads removed by - - num_lines: 2 +- contents: reads removed by - + num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/prokka.md b/docs/markdown/modules/prokka.md index b10b947015..1f48cf8ba7 100644 --- a/docs/markdown/modules/prokka.md +++ b/docs/markdown/modules/prokka.md @@ -2,7 +2,7 @@ title: Prokka displayed_sidebar: multiqcSidebar description: > -

Rapid annotation of prokaryotic genomes.

+

Rapid annotation of prokaryotic genomes.

--- :::note -

Rapid annotation of prokaryotic genomes.

[http://www.vicbioinformatics.com/software.prokka.shtml](http://www.vicbioinformatics.com/software.prokka.shtml) @@ -44,6 +43,7 @@ will instead use the log filename as the sample name. ```yaml prokka: - contents: "contigs:" + contents: 'contigs:' num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/purple.md b/docs/markdown/modules/purple.md index 4b4956f94c..a02553a11d 100644 --- a/docs/markdown/modules/purple.md +++ b/docs/markdown/modules/purple.md @@ -2,7 +2,7 @@ title: PURPLE displayed_sidebar: multiqcSidebar description: > -

A purity, ploidy and copy number estimator for whole genome tumor data.

+

A purity, ploidy and copy number estimator for whole genome tumor data.

--- :::note -

A purity, ploidy and copy number estimator for whole genome tumor data.

[https://github.com/hartwigmedical/hmftools/](https://github.com/hartwigmedical/hmftools/) @@ -30,7 +29,8 @@ load and burden, clonality and the whole genome duplication status. ```yaml purple/purity: - fn: "*.purple.purity.tsv" + fn: '*.purple.purity.tsv' purple/qc: - fn: "*.purple.qc" + fn: '*.purple.qc' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/pychopper.md b/docs/markdown/modules/pychopper.md index 7f0a83f760..b1cecd28c8 100644 --- a/docs/markdown/modules/pychopper.md +++ b/docs/markdown/modules/pychopper.md @@ -2,7 +2,7 @@ title: Pychopper displayed_sidebar: multiqcSidebar description: > -

Identifies, orients, trims and rescues full length Nanopore cDNA reads. Can also rescue fused reads.

+

Identifies, orients, trims and rescues full length Nanopore cDNA reads. Can also rescue fused reads.

--- :::note -

Identifies, orients, trims and rescues full length Nanopore cDNA reads. Can also rescue fused reads.

[https://github.com/nanoporetech/pychopper](https://github.com/nanoporetech/pychopper) @@ -36,3 +35,4 @@ pychopper: contents: "Classification\tRescue" num_lines: 6 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/pycoqc.md b/docs/markdown/modules/pycoqc.md index 88e3d3e8f4..7e039ef71e 100644 --- a/docs/markdown/modules/pycoqc.md +++ b/docs/markdown/modules/pycoqc.md @@ -2,7 +2,7 @@ title: pycoQC displayed_sidebar: multiqcSidebar description: > -

Computes metrics and generates interactive QC plots for Oxford Nanopore technologies sequencing data.

+

Computes metrics and generates interactive QC plots for Oxford Nanopore technologies sequencing data.

--- :::note -

Computes metrics and generates interactive QC plots for Oxford Nanopore technologies sequencing data.

[https://github.com/tleonardi/pycoQC](https://github.com/tleonardi/pycoQC) @@ -34,3 +33,4 @@ pycoqc: contents: '"pycoqc":' num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/qc3C.md b/docs/markdown/modules/qc3C.md index 53b0344333..517f2a26a7 100644 --- a/docs/markdown/modules/qc3C.md +++ b/docs/markdown/modules/qc3C.md @@ -2,7 +2,7 @@ title: qc3C displayed_sidebar: multiqcSidebar description: > -

Reference-free and BAM based quality control for Hi-C data.

+

Reference-free and BAM based quality control for Hi-C data.

--- :::note -

Reference-free and BAM based quality control for Hi-C data.

[http://github.com/cerebis/qc3C](http://github.com/cerebis/qc3C) @@ -32,5 +31,6 @@ their experimental aims. ```yaml qc3C: - fn: "*.qc3C.json" + fn: '*.qc3C.json' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/qorts.md b/docs/markdown/modules/qorts.md index 7eaa9e1a2d..8efe120915 100644 --- a/docs/markdown/modules/qorts.md +++ b/docs/markdown/modules/qorts.md @@ -2,7 +2,7 @@ title: QoRTs displayed_sidebar: multiqcSidebar description: > -

Toolkit for analysis, QC, and data management of RNA-Seq datasets.

+

Toolkit for analysis, QC, and data management of RNA-Seq datasets.

--- :::note -

Toolkit for analysis, QC, and data management of RNA-Seq datasets.

[http://hartleys.github.io/QoRTs/](http://hartleys.github.io/QoRTs/) @@ -29,4 +28,9 @@ Aids in the detection and identification of errors, biases, and artifacts produc qorts: contents: BENCHMARK_MinutesOnSamIteration num_lines: 100 +qorts/log: + contents: Starting QoRTs + fn: QC.*.log + num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/qualimap.md b/docs/markdown/modules/qualimap.md index a692711458..eea369b6ae 100644 --- a/docs/markdown/modules/qualimap.md +++ b/docs/markdown/modules/qualimap.md @@ -2,7 +2,7 @@ title: QualiMap displayed_sidebar: multiqcSidebar description: > -

Quality control of alignment data and its derivatives like feature counts.

+

Quality control of alignment data and its derivatives like feature counts.

--- :::note -

Quality control of alignment data and its derivatives like feature counts.

[http://qualimap.bioinfo.cipf.es/](http://qualimap.bioinfo.cipf.es/) @@ -71,10 +70,19 @@ qualimap/bamqc/genome_fraction: fn: genome_fraction_coverage.txt qualimap/bamqc/genome_results: fn: genome_results.txt +qualimap/bamqc/html: + contents: 'Qualimap report: BAM QC' + fn: qualimapReport.html + num_lines: 10 qualimap/bamqc/insert_size: fn: insert_size_histogram.txt qualimap/rnaseq/coverage: fn: coverage_profile_along_genes_(total).txt +qualimap/rnaseq/html: + contents: 'Qualimap report: RNA Seq QC' + fn: qualimapReport.html + num_lines: 10 qualimap/rnaseq/rnaseq_results: fn: rnaseq_qc_results.txt ``` + \ No newline at end of file diff --git a/docs/markdown/modules/quast.md b/docs/markdown/modules/quast.md index 17bceb3721..97ef2ea7c5 100644 --- a/docs/markdown/modules/quast.md +++ b/docs/markdown/modules/quast.md @@ -2,7 +2,7 @@ title: QUAST displayed_sidebar: multiqcSidebar description: > -

Quality assessment tool for genome assemblies.

+

Quality assessment tool for genome assemblies.

--- :::note -

Quality assessment tool for genome assemblies.

[http://quast.bioinf.spbau.ru/](http://quast.bioinf.spbau.ru/) @@ -72,3 +71,4 @@ quast: fn: report.tsv num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/ribotish.md b/docs/markdown/modules/ribotish.md new file mode 100644 index 0000000000..61c6aae6f8 --- /dev/null +++ b/docs/markdown/modules/ribotish.md @@ -0,0 +1,39 @@ +--- +title: Ribo-TISH +displayed_sidebar: multiqcSidebar +description: > +

Identifies translated ORFs from Ribo-seq data and reports reading frame quality metrics.

+--- + + + +:::note +

Identifies translated ORFs from Ribo-seq data and reports reading frame quality metrics.

+ +[https://github.com/zhpn1024/ribotish](https://github.com/zhpn1024/ribotish) +::: + +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 + +### File search patterns + +```yaml +ribotish/qual: + fn: '*_qual.txt' + num_lines: 10 +``` + \ No newline at end of file diff --git a/docs/markdown/modules/rna_seqc.md b/docs/markdown/modules/rna_seqc.md index 0e43a34532..2de63242b5 100644 --- a/docs/markdown/modules/rna_seqc.md +++ b/docs/markdown/modules/rna_seqc.md @@ -2,7 +2,7 @@ title: RNA-SeQC displayed_sidebar: multiqcSidebar description: > -

RNA-Seq metrics for quality control and process optimization.

+

RNA-Seq metrics for quality control and process optimization.

--- :::note -

RNA-Seq metrics for quality control and process optimization.

[https://github.com/getzlab/rnaseqc](https://github.com/getzlab/rnaseqc) @@ -40,10 +39,15 @@ rna_seqc/correlation: fn_re: corrMatrix(Pearson|Spearman)\.txt rna_seqc/coverage: fn_re: meanCoverageNorm_(high|medium|low)\.txt +rna_seqc/html: + contents: RNA-SeQC v + fn: index.html + num_lines: 200 rna_seqc/metrics_v1: contents: "Sample\tNote\t" - fn: "*metrics.tsv" + fn: '*metrics.tsv' rna_seqc/metrics_v2: contents: High Quality Ambiguous Alignment Rate - fn: "*metrics.tsv" + fn: '*metrics.tsv' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/rockhopper.md b/docs/markdown/modules/rockhopper.md index 57b9b68976..efeea9409f 100644 --- a/docs/markdown/modules/rockhopper.md +++ b/docs/markdown/modules/rockhopper.md @@ -2,7 +2,7 @@ title: Rockhopper displayed_sidebar: multiqcSidebar description: > -

Bacterial RNA-seq analysis: align reads to coding sequences, rRNAs, tRNAs, and miscellaneous RNAs.

+

Bacterial RNA-seq analysis: align reads to coding sequences, rRNAs, tRNAs, and miscellaneous RNAs.

--- :::note -

Bacterial RNA-seq analysis: align reads to coding sequences, rRNAs, tRNAs, and miscellaneous RNAs.

[https://cs.wellesley.edu/~btjaden/Rockhopper/](https://cs.wellesley.edu/~btjaden/Rockhopper/) @@ -31,3 +30,4 @@ rockhopper: fn: summary.txt max_filesize: 500000 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/rsem.md b/docs/markdown/modules/rsem.md index 14052862ab..c2c81cc5e4 100644 --- a/docs/markdown/modules/rsem.md +++ b/docs/markdown/modules/rsem.md @@ -2,7 +2,7 @@ title: RSEM displayed_sidebar: multiqcSidebar description: > -

Estimates gene and isoform expression levels from RNA-Seq data.

+

Estimates gene and isoform expression levels from RNA-Seq data.

--- :::note -

Estimates gene and isoform expression levels from RNA-Seq data.

[https://deweylab.github.io/RSEM/](https://deweylab.github.io/RSEM/) @@ -31,5 +30,6 @@ This module search for the file `.cnt` created by RSEM into directory named `PRE ```yaml rsem: - fn: "*.cnt" + fn: '*.cnt' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/rseqc.md b/docs/markdown/modules/rseqc.md index 3bacf459c3..56b535a0b3 100644 --- a/docs/markdown/modules/rseqc.md +++ b/docs/markdown/modules/rseqc.md @@ -2,7 +2,7 @@ title: RSeQC displayed_sidebar: multiqcSidebar description: > -

Evaluates high throughput RNA-seq data.

+

Evaluates high throughput RNA-seq data.

--- :::note -

Evaluates high throughput RNA-seq data.

[http://rseqc.sourceforge.net/](http://rseqc.sourceforge.net/) @@ -63,30 +62,31 @@ parable by MultiQC, redirect the stderr to a file using `2> mysample.log`. ```yaml rseqc/bam_stat: - contents: "Proper-paired reads map to different chrom:" + contents: 'Proper-paired reads map to different chrom:' max_filesize: 500000 rseqc/gene_body_coverage: - fn: "*.geneBodyCoverage.txt" + fn: '*.geneBodyCoverage.txt' rseqc/infer_experiment: - - fn: "*infer_experiment.txt" - - contents: Fraction of reads explained by - max_filesize: 500000 +- fn: '*infer_experiment.txt' +- contents: Fraction of reads explained by + max_filesize: 500000 rseqc/inner_distance: - fn: "*.inner_distance_freq.txt" + fn: '*.inner_distance_freq.txt' rseqc/junction_annotation: - contents: "Partial Novel Splicing Junctions:" + contents: 'Partial Novel Splicing Junctions:' max_filesize: 500000 rseqc/junction_saturation: - fn: "*.junctionSaturation_plot.r" + fn: '*.junctionSaturation_plot.r' rseqc/read_distribution: contents: Group Total_bases Tag_count Tags/Kb max_filesize: 500000 rseqc/read_duplication_pos: - fn: "*.pos.DupRate.xls" + fn: '*.pos.DupRate.xls' rseqc/read_gc: - fn: "*.GC.xls" + fn: '*.GC.xls' rseqc/tin: contents: TIN(median) - fn: "*.summary.txt" + fn: '*.summary.txt' num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/salmon.md b/docs/markdown/modules/salmon.md index 824a8dca06..6d2d50349f 100644 --- a/docs/markdown/modules/salmon.md +++ b/docs/markdown/modules/salmon.md @@ -2,7 +2,7 @@ title: Salmon displayed_sidebar: multiqcSidebar description: > -

Quantifies expression of transcripts using RNA-seq data.

+

Quantifies expression of transcripts using RNA-seq data.

--- :::note -

Quantifies expression of transcripts using RNA-seq data.

[https://combine-lab.github.io/salmon/](https://combine-lab.github.io/salmon/) @@ -41,3 +40,4 @@ salmon/meta: max_filesize: 50000 num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/sambamba.md b/docs/markdown/modules/sambamba.md index d55a677b08..395e5ed059 100644 --- a/docs/markdown/modules/sambamba.md +++ b/docs/markdown/modules/sambamba.md @@ -2,7 +2,7 @@ title: Sambamba displayed_sidebar: multiqcSidebar description: > -

Toolkit for interacting with BAM/CRAM files.

+

Toolkit for interacting with BAM/CRAM files.

--- :::note -

Toolkit for interacting with BAM/CRAM files.

[https://lomereiter.github.io/sambamba/](https://lomereiter.github.io/sambamba/) @@ -66,3 +65,4 @@ sambamba/markdup: contents: finding positions of the duplicate reads in the file num_lines: 50 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/samblaster.md b/docs/markdown/modules/samblaster.md index ba7625b246..b76ca91439 100644 --- a/docs/markdown/modules/samblaster.md +++ b/docs/markdown/modules/samblaster.md @@ -2,7 +2,7 @@ title: Samblaster displayed_sidebar: multiqcSidebar description: > -

Marks duplicates and extracts discordant and split reads from sam files.

+

Marks duplicates and extracts discordant and split reads from sam files.

--- :::note -

Marks duplicates and extracts discordant and split reads from sam files.

[https://github.com/GregoryFaust/samblaster](https://github.com/GregoryFaust/samblaster) @@ -25,5 +24,6 @@ File path for the source of this content: multiqc/modules/samblaster/samblaster. ```yaml samblaster: - contents: "samblaster: Version" + contents: 'samblaster: Version' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/samtools.md b/docs/markdown/modules/samtools.md index 6742944938..1ff9613ebe 100644 --- a/docs/markdown/modules/samtools.md +++ b/docs/markdown/modules/samtools.md @@ -2,7 +2,7 @@ title: Samtools displayed_sidebar: multiqcSidebar description: > -

Toolkit for interacting with BAM/CRAM files.

+

Toolkit for interacting with BAM/CRAM files.

--- :::note -

Toolkit for interacting with BAM/CRAM files.

[http://www.htslib.org](http://www.htslib.org) @@ -117,7 +116,6 @@ general_stats_columns: ``` Each samtools submodule has its own namespace in the configuration - - `samtools/ampliconclip` - `samtools/coverage` - `samtools/flagstats` @@ -131,8 +129,8 @@ Each samtools submodule has its own namespace in the configuration ```yaml samtools/ampliconclip: contents: - - "COMMAND:" - - samtools ampliconclip + - 'COMMAND:' + - samtools ampliconclip num_lines: 11 samtools/coverage: contents: "#rname\tstartpos\tendpos\tnumreads\tcovbases\tcoverage\tmeandepth\tmeanbaseq\t\ @@ -141,19 +139,20 @@ samtools/coverage: samtools/flagstat: contents: in total (QC-passed reads + QC-failed reads) samtools/idxstats: - fn: "*idxstat*" + fn: '*idxstat*' samtools/markdup_json: contents: - - '"COMMAND":' - - samtools markdup + - '"COMMAND":' + - samtools markdup num_lines: 10 samtools/markdup_txt: contents: - - "^COMMAND:" - - samtools markdup + - '^COMMAND:' + - samtools markdup num_lines: 2 samtools/rmdup: - contents: "[bam_rmdup" + contents: '[bam_rmdup' samtools/stats: contents: This file was produced by samtools stats ``` + \ No newline at end of file diff --git a/docs/markdown/modules/sargasso.md b/docs/markdown/modules/sargasso.md index b08b8b8c6a..e78863cea3 100644 --- a/docs/markdown/modules/sargasso.md +++ b/docs/markdown/modules/sargasso.md @@ -2,7 +2,7 @@ title: Sargasso displayed_sidebar: multiqcSidebar description: > -

Separates mixed-species RNA-seq reads according to their species of origin.

+

Separates mixed-species RNA-seq reads according to their species of origin.

--- :::note -

Separates mixed-species RNA-seq reads according to their species of origin.

[http://biomedicalinformaticsgroup.github.io/Sargasso/](http://biomedicalinformaticsgroup.github.io/Sargasso/) @@ -27,3 +26,4 @@ File path for the source of this content: multiqc/modules/sargasso/sargasso.py sargasso: fn: overall_filtering_summary.txt ``` + \ No newline at end of file diff --git a/docs/markdown/modules/seqera_cli.md b/docs/markdown/modules/seqera_cli.md index b8a68215ec..4aa3ed7388 100644 --- a/docs/markdown/modules/seqera_cli.md +++ b/docs/markdown/modules/seqera_cli.md @@ -2,7 +2,7 @@ title: Seqera Platform CLI displayed_sidebar: multiqcSidebar description: > -

Reports statistics generated by the Seqera Platform CLI.

+

Reports statistics generated by the Seqera Platform CLI.

--- :::note -

Reports statistics generated by the Seqera Platform CLI.

[https://github.com/seqeralabs/tower-cli](https://github.com/seqeralabs/tower-cli) @@ -49,3 +48,4 @@ seqera_cli/json: seqera_cli/run_dump: fn: runs_*.tar.gz ``` + \ No newline at end of file diff --git a/docs/markdown/modules/seqfu.md b/docs/markdown/modules/seqfu.md index c426367820..6c8c0eac54 100644 --- a/docs/markdown/modules/seqfu.md +++ b/docs/markdown/modules/seqfu.md @@ -2,7 +2,7 @@ title: Seqfu displayed_sidebar: multiqcSidebar description: > -

Manipulate FASTA/FASTQ files.

+

Manipulate FASTA/FASTQ files.

--- :::note -

Manipulate FASTA/FASTQ files.

[https://telatin.github.io/seqfu2](https://telatin.github.io/seqfu2) @@ -46,3 +45,4 @@ seqfu/stats: contents: "File\t#Seq\tTotal bp\tAvg\tN50\tN75\tN90\tauN\tMin\tMax" num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/seqkit.md b/docs/markdown/modules/seqkit.md new file mode 100644 index 0000000000..710d9401d3 --- /dev/null +++ b/docs/markdown/modules/seqkit.md @@ -0,0 +1,52 @@ +--- +title: SeqKit +displayed_sidebar: multiqcSidebar +description: > +

Cross-platform and ultrafast toolkit for FASTA/Q file manipulation.

+--- + + + +:::note +

Cross-platform and ultrafast toolkit for FASTA/Q file manipulation.

+ +[https://bioinf.shenwei.me/seqkit/](https://bioinf.shenwei.me/seqkit/) +::: + +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 +``` + +### File search patterns + +```yaml +seqkit/stats: + contents_re: ^file\s+format\s+type\s+num_seqs\s+sum_len + num_lines: 1 +``` + \ No newline at end of file diff --git a/docs/markdown/modules/sequali.md b/docs/markdown/modules/sequali.md index cee23865a9..b2d88c5ec7 100644 --- a/docs/markdown/modules/sequali.md +++ b/docs/markdown/modules/sequali.md @@ -2,7 +2,7 @@ title: Sequali displayed_sidebar: multiqcSidebar description: > -

Sequencing quality control for both long-read and short-read data.

+

Sequencing quality control for both long-read and short-read data.

--- :::note -

Sequencing quality control for both long-read and short-read data.

[https://github.com/rhpvorderman/sequali](https://github.com/rhpvorderman/sequali) ::: -Features adapter search, overrepresented sequence analysis and duplication analysis and supports +Features adapter search, overrepresented sequence analysis and duplication analysis and supports FASTQ and uBAM inputs. ### File search patterns @@ -29,6 +28,7 @@ FASTQ and uBAM inputs. ```yaml sequali: contents: '"sequali_version"' - fn: "*.json" + fn: '*.json' num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/seqwho.md b/docs/markdown/modules/seqwho.md index 84a366b831..c96875288a 100644 --- a/docs/markdown/modules/seqwho.md +++ b/docs/markdown/modules/seqwho.md @@ -2,7 +2,7 @@ title: SeqWho displayed_sidebar: multiqcSidebar description: > -

Determines FASTQ(A) sequencing file source protocol and the species of origin, to check that the composition of the library is expected.

+

Determines FASTQ(A) sequencing file source protocol and the species of origin, to check that the composition of the library is expected.

--- :::note -

Determines FASTQ(A) sequencing file source protocol and the species of origin, to check that the composition of the library is expected.

[https://daehwankimlab.github.io/seqwho/](https://daehwankimlab.github.io/seqwho/) @@ -28,3 +27,4 @@ seqwho: contents: ' "Per Base Seq": [' num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/seqyclean.md b/docs/markdown/modules/seqyclean.md index c746d43b10..9402d0d35e 100644 --- a/docs/markdown/modules/seqyclean.md +++ b/docs/markdown/modules/seqyclean.md @@ -2,7 +2,7 @@ title: SeqyClean displayed_sidebar: multiqcSidebar description: > -

Filters adapters, vectors, and contaminants while quality trimming.

+

Filters adapters, vectors, and contaminants while quality trimming.

--- :::note -

Filters adapters, vectors, and contaminants while quality trimming.

[https://github.com/ibest/seqyclean](https://github.com/ibest/seqyclean) @@ -30,5 +29,6 @@ The module parses the `*SummaryStatistics.tsv` files that results from a SeqyCle ```yaml seqyclean: - fn: "*_SummaryStatistics.tsv" + fn: '*_SummaryStatistics.tsv' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/sexdeterrmine.md b/docs/markdown/modules/sexdeterrmine.md index 33b243ab26..31400d7b99 100644 --- a/docs/markdown/modules/sexdeterrmine.md +++ b/docs/markdown/modules/sexdeterrmine.md @@ -2,7 +2,7 @@ title: SexDetErrmine displayed_sidebar: multiqcSidebar description: > -

Calculates relative coverage of X and Y chromosomes and their associated error bars from the depth of coverage at specified SNPs.

+

Calculates relative coverage of X and Y chromosomes and their associated error bars from the depth of coverage at specified SNPs.

--- :::note -

Calculates relative coverage of X and Y chromosomes and their associated error bars from the depth of coverage at specified SNPs.

[https://github.com/TCLamnidis/Sex.DetERRmine](https://github.com/TCLamnidis/Sex.DetERRmine) @@ -27,3 +26,4 @@ File path for the source of this content: multiqc/modules/sexdeterrmine/sexdeter sexdeterrmine: fn: sexdeterrmine.json ``` + \ No newline at end of file diff --git a/docs/markdown/modules/sickle.md b/docs/markdown/modules/sickle.md index 6b25536804..d2315b56e1 100644 --- a/docs/markdown/modules/sickle.md +++ b/docs/markdown/modules/sickle.md @@ -2,7 +2,7 @@ title: Sickle displayed_sidebar: multiqcSidebar description: > -

A windowed adaptive trimming tool for FASTQ files using quality.

+

A windowed adaptive trimming tool for FASTQ files using quality.

--- :::note -

A windowed adaptive trimming tool for FASTQ files using quality.

[https://github.com/najoshi/sickle](https://github.com/najoshi/sickle) @@ -32,3 +31,4 @@ sickle: contents_re: 'FastQ \w*\s?records kept: .*' num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/skewer.md b/docs/markdown/modules/skewer.md index c36d9d5a61..aa7b985bd8 100644 --- a/docs/markdown/modules/skewer.md +++ b/docs/markdown/modules/skewer.md @@ -2,7 +2,7 @@ title: Skewer displayed_sidebar: multiqcSidebar description: > -

Adapter trimming tool for NGS paired-end sequences.

+

Adapter trimming tool for NGS paired-end sequences.

--- :::note -

Adapter trimming tool for NGS paired-end sequences.

[https://github.com/relipmoc/skewer](https://github.com/relipmoc/skewer) @@ -25,5 +24,6 @@ File path for the source of this content: multiqc/modules/skewer/skewer.py ```yaml skewer: - contents: "maximum error ratio allowed (-r):" + contents: 'maximum error ratio allowed (-r):' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/slamdunk.md b/docs/markdown/modules/slamdunk.md index a38ce8b8a7..afde7e20e6 100644 --- a/docs/markdown/modules/slamdunk.md +++ b/docs/markdown/modules/slamdunk.md @@ -2,7 +2,7 @@ title: Slamdunk displayed_sidebar: multiqcSidebar description: > -

Tool to analyze SLAM-Seq data.

+

Tool to analyze SLAM-Seq data.

--- :::note -

Tool to analyze SLAM-Seq data.

[http://t-neumann.github.io/slamdunk/](http://t-neumann.github.io/slamdunk/) @@ -27,21 +26,22 @@ This module should be able to parse logs from v0.2.2-dev onwards. ```yaml slamdunk/PCA: - contents: "# slamdunk PCA" + contents: '# slamdunk PCA' num_lines: 1 slamdunk/rates: - contents: "# slamdunk rates" + contents: '# slamdunk rates' num_lines: 1 slamdunk/summary: - contents: "# slamdunk summary" + contents: '# slamdunk summary' num_lines: 1 slamdunk/tcperreadpos: - contents: "# slamdunk tcperreadpos" + contents: '# slamdunk tcperreadpos' num_lines: 1 slamdunk/tcperutrpos: - contents: "# slamdunk tcperutr" + contents: '# slamdunk tcperutr' num_lines: 1 slamdunk/utrrates: - contents: "# slamdunk utrrates" + contents: '# slamdunk utrrates' num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/snippy.md b/docs/markdown/modules/snippy.md index 2227d029b6..62a95b416f 100644 --- a/docs/markdown/modules/snippy.md +++ b/docs/markdown/modules/snippy.md @@ -2,7 +2,7 @@ title: Snippy displayed_sidebar: multiqcSidebar description: > -

Rapid haploid variant calling and core genome alignment.

+

Rapid haploid variant calling and core genome alignment.

--- :::note -

Rapid haploid variant calling and core genome alignment.

[https://github.com/tseemann/snippy](https://github.com/tseemann/snippy) @@ -40,3 +39,4 @@ snippy/snippy-core: contents_re: ID\tLENGTH\tALIGNED\tUNALIGNED\tVARIANT\tHET\tMASKED\tLOWCOV num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/snpeff.md b/docs/markdown/modules/snpeff.md index 220b764cc1..6b4a7267a3 100644 --- a/docs/markdown/modules/snpeff.md +++ b/docs/markdown/modules/snpeff.md @@ -2,7 +2,7 @@ title: SnpEff displayed_sidebar: multiqcSidebar description: > -

Annotates and predicts the effects of variants on genes (such as amino acid changes).

+

Annotates and predicts the effects of variants on genes (such as amino acid changes).

--- :::note -

Annotates and predicts the effects of variants on genes (such as amino acid changes).

[http://snpeff.sourceforge.net/](http://snpeff.sourceforge.net/) @@ -32,3 +31,4 @@ snpeff: contents: SnpEff_version max_filesize: 5000000 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/snpsplit.md b/docs/markdown/modules/snpsplit.md index 6990dc60bc..341f4c09d9 100644 --- a/docs/markdown/modules/snpsplit.md +++ b/docs/markdown/modules/snpsplit.md @@ -2,7 +2,7 @@ title: SNPsplit displayed_sidebar: multiqcSidebar description: > -

Allele-specific alignment sorter. Determines allelic origin of reads that cover known SNP positions.

+

Allele-specific alignment sorter. Determines allelic origin of reads that cover known SNP positions.

--- :::note -

Allele-specific alignment sorter. Determines allelic origin of reads that cover known SNP positions.

[https://www.bioinformatics.babraham.ac.uk/projects/SNPsplit/](https://www.bioinformatics.babraham.ac.uk/projects/SNPsplit/) @@ -33,8 +32,9 @@ Conversely, if the mates in a pair are tagged as arising from different genomes, ```yaml snpsplit/new: - fn: "*SNPsplit_report.yaml" + fn: '*SNPsplit_report.yaml' snpsplit/old: - contents: "Writing allele-flagged output file to:" + contents: 'Writing allele-flagged output file to:' num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/somalier.md b/docs/markdown/modules/somalier.md index faeb56ce2a..b7c2f2f57c 100644 --- a/docs/markdown/modules/somalier.md +++ b/docs/markdown/modules/somalier.md @@ -2,7 +2,7 @@ title: Somalier displayed_sidebar: multiqcSidebar description: > -

Genotype to pedigree correspondence checks from sketches derived from BAM/CRAM or VCF.

+

Genotype to pedigree correspondence checks from sketches derived from BAM/CRAM or VCF.

--- :::note -

Genotype to pedigree correspondence checks from sketches derived from BAM/CRAM or VCF.

[https://github.com/brentp/somalier](https://github.com/brentp/somalier) @@ -33,12 +32,13 @@ to be used for general QC. ```yaml somalier/pairs: contents: hom_concordance - fn: "*.pairs.tsv" + fn: '*.pairs.tsv' num_lines: 5 somalier/samples: - contents: "#family_id" - fn: "*.samples.tsv" + contents: '#family_id' + fn: '*.samples.tsv' num_lines: 5 somalier/somalier-ancestry: - fn: "*.somalier-ancestry.tsv" + fn: '*.somalier-ancestry.tsv' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/sompy.md b/docs/markdown/modules/sompy.md new file mode 100644 index 0000000000..c8ff1f16a0 --- /dev/null +++ b/docs/markdown/modules/sompy.md @@ -0,0 +1,31 @@ +--- +title: som.py +displayed_sidebar: multiqcSidebar +description: > +

Benchmarks somatic variant calls against gold standard truth datasets.

+--- + + + +:::note +

Benchmarks somatic variant calls against gold standard truth datasets.

+ +[https://github.com/Illumina/hap.py/blob/master/doc/sompy.md](https://github.com/Illumina/hap.py/blob/master/doc/sompy.md) +::: + +### File search patterns + +```yaml +sompy: + contents: ',sompyversion,sompycmd' + fn: '*.stats.csv' + num_lines: 2 +``` + \ No newline at end of file diff --git a/docs/markdown/modules/sortmerna.md b/docs/markdown/modules/sortmerna.md index ef50aa2d40..f69b32b2d1 100644 --- a/docs/markdown/modules/sortmerna.md +++ b/docs/markdown/modules/sortmerna.md @@ -2,7 +2,7 @@ title: SortMeRNA displayed_sidebar: multiqcSidebar description: > -

Program for filtering, mapping and OTU-picking NGS reads in metatranscriptomic and metagenomic data.

+

Program for filtering, mapping and OTU-picking NGS reads in metatranscriptomic and metagenomic data.

--- :::note -

Program for filtering, mapping and OTU-picking NGS reads in metatranscriptomic and metagenomic data.

[http://bioinfo.lifl.fr/RNA/sortmerna/](http://bioinfo.lifl.fr/RNA/sortmerna/) @@ -38,3 +37,4 @@ sortmerna: sortmerna: contents: Minimal SW score based on E-value ``` + \ No newline at end of file diff --git a/docs/markdown/modules/sourmash.md b/docs/markdown/modules/sourmash.md index 2c80e7f7ca..5a2089f8b0 100644 --- a/docs/markdown/modules/sourmash.md +++ b/docs/markdown/modules/sourmash.md @@ -2,7 +2,7 @@ title: Sourmash displayed_sidebar: multiqcSidebar description: > -

Quickly searches, compares, and analyzes genomic and metagenomic data sets.

+

Quickly searches, compares, and analyzes genomic and metagenomic data sets.

--- :::note -

Quickly searches, compares, and analyzes genomic and metagenomic data sets.

[https://github.com/sourmash-bio/sourmash](https://github.com/sourmash-bio/sourmash) @@ -46,8 +45,9 @@ sourmash: ```yaml sourmash/compare: - fn: "*.labels.txt" + fn: '*.labels.txt' sourmash/gather: contents: intersect_bp,f_orig_query,f_match,f_unique_to_query,f_unique_weighted, num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/spaceranger.md b/docs/markdown/modules/spaceranger.md index 73e6235535..ed6cb375a5 100644 --- a/docs/markdown/modules/spaceranger.md +++ b/docs/markdown/modules/spaceranger.md @@ -2,7 +2,7 @@ title: Space Ranger displayed_sidebar: multiqcSidebar description: > -

Tool to analyze 10x Genomics spatial transcriptomics data.

+

Tool to analyze 10x Genomics spatial transcriptomics data.

--- :::note -

Tool to analyze 10x Genomics spatial transcriptomics data.

[https://support.10xgenomics.com/spatial-gene-expression/software/pipelines/latest/what-is-space-ranger](https://support.10xgenomics.com/spatial-gene-expression/software/pipelines/latest/what-is-space-ranger) @@ -41,10 +40,11 @@ If present in the original report, any warning is reported as well. ```yaml spaceranger/count_html: - - contents: '"command":"Space Ranger","subcommand":"count"' - fn: "*.html" - num_lines: 20 - - contents: '"command": "Space Ranger", "subcommand": "count"' - fn: "*.html" - num_lines: 20 +- contents: '"command":"Space Ranger","subcommand":"count"' + fn: '*.html' + num_lines: 20 +- contents: '"command": "Space Ranger", "subcommand": "count"' + fn: '*.html' + num_lines: 20 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/stacks.md b/docs/markdown/modules/stacks.md index 52b03de9da..ab5a097b66 100644 --- a/docs/markdown/modules/stacks.md +++ b/docs/markdown/modules/stacks.md @@ -2,7 +2,7 @@ title: Stacks displayed_sidebar: multiqcSidebar description: > -

Analyzes restriction enzyme-based data (e.g. RAD-seq).

+

Analyzes restriction enzyme-based data (e.g. RAD-seq).

--- :::note -

Analyzes restriction enzyme-based data (e.g. RAD-seq).

[http://catchenlab.life.illinois.edu/stacks/](http://catchenlab.life.illinois.edu/stacks/) @@ -38,6 +37,7 @@ stacks/populations: fn: populations.log.distribs stacks/sumstats: contents: "# Pop ID\tPrivate\tNum_Indv\tVar\tStdErr\tP\tVar" - fn: "*.sumstats_summary.tsv" + fn: '*.sumstats_summary.tsv' max_filesize: 1000000 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/star.md b/docs/markdown/modules/star.md index fedb39134d..1975ba3182 100644 --- a/docs/markdown/modules/star.md +++ b/docs/markdown/modules/star.md @@ -2,7 +2,7 @@ title: STAR displayed_sidebar: multiqcSidebar description: > -

Universal RNA-seq aligner.

+

Universal RNA-seq aligner.

--- :::note -

Universal RNA-seq aligner.

[https://github.com/alexdobin/STAR](https://github.com/alexdobin/STAR) @@ -33,7 +32,8 @@ files generated with `--quantMode GeneCounts`, if found. ```yaml star: - fn: "*Log.final.out" + fn: '*Log.final.out' star/genecounts: - fn: "*ReadsPerGene.out.tab" + fn: '*ReadsPerGene.out.tab' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/supernova.md b/docs/markdown/modules/supernova.md index f0ef611cd0..3fab496fea 100644 --- a/docs/markdown/modules/supernova.md +++ b/docs/markdown/modules/supernova.md @@ -2,7 +2,7 @@ title: Supernova displayed_sidebar: multiqcSidebar description: > -

De novo genome assembler of 10X Genomics linked-reads.

+

De novo genome assembler of 10X Genomics linked-reads.

--- :::note -

De novo genome assembler of 10X Genomics linked-reads.

[https://www.10xgenomics.com/](https://www.10xgenomics.com/) @@ -58,11 +57,12 @@ supernova/molecules: fn: histogram_molecules.json num_lines: 10 supernova/report: - contents: "- assembly checksum =" - fn: "*report*.txt" + contents: '- assembly checksum =' + fn: '*report*.txt' num_lines: 100 supernova/summary: contents: '"lw_mean_mol_len":' fn: summary.json num_lines: 120 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/sylphtax.md b/docs/markdown/modules/sylphtax.md new file mode 100644 index 0000000000..81ab48ac07 --- /dev/null +++ b/docs/markdown/modules/sylphtax.md @@ -0,0 +1,49 @@ +--- +title: Sylph-tax +displayed_sidebar: multiqcSidebar +description: > +

Taxonomic profiling of metagenomic reads.

+--- + + + +:::note +

Taxonomic profiling of metagenomic reads.

+ +[https://sylph-docs.github.io/](https://sylph-docs.github.io/), [https://sylph-docs.github.io/sylph-tax/](https://sylph-docs.github.io/sylph-tax/) +::: + +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 +``` + +### File search patterns + +```yaml +sylphtax: + fn: '*.sylphmpa' +``` + \ No newline at end of file diff --git a/docs/markdown/modules/telseq.md b/docs/markdown/modules/telseq.md index ce20204126..07241a740d 100644 --- a/docs/markdown/modules/telseq.md +++ b/docs/markdown/modules/telseq.md @@ -2,7 +2,7 @@ title: telseq displayed_sidebar: multiqcSidebar description: > -

Estimates telomere length from whole genome sequencing data (BAMs).

+

Estimates telomere length from whole genome sequencing data (BAMs).

--- :::note -

Estimates telomere length from whole genome sequencing data (BAMs).

[https://github.com/zd1/telseq](https://github.com/zd1/telseq) @@ -31,3 +30,4 @@ telseq: contents: "ReadGroup\tLibrary\tSample\tTotal\tMapped\tDuplicates\tLENGTH_ESTIMATE" num_lines: 3 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/theta2.md b/docs/markdown/modules/theta2.md index 60b8fce3e8..91301e8130 100644 --- a/docs/markdown/modules/theta2.md +++ b/docs/markdown/modules/theta2.md @@ -2,7 +2,7 @@ title: THetA2 displayed_sidebar: multiqcSidebar description: > -

Estimates tumour purity and clonal / subclonal copy number.

+

Estimates tumour purity and clonal / subclonal copy number.

--- :::note -

Estimates tumour purity and clonal / subclonal copy number.

[http://compbio.cs.brown.edu/projects/theta/](http://compbio.cs.brown.edu/projects/theta/) @@ -30,5 +29,6 @@ Also note that if there are more than 5 tumour subclones, their percentages are ```yaml theta2: - fn: "*.BEST.results" + fn: '*.BEST.results' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/tophat.md b/docs/markdown/modules/tophat.md index 852be063dd..31050ae207 100644 --- a/docs/markdown/modules/tophat.md +++ b/docs/markdown/modules/tophat.md @@ -2,7 +2,7 @@ title: Tophat displayed_sidebar: multiqcSidebar description: > -

Splice junction RNA-Seq reads mapper for mammalian-sized genomes.

+

Splice junction RNA-Seq reads mapper for mammalian-sized genomes.

--- :::note -

Splice junction RNA-Seq reads mapper for mammalian-sized genomes.

[https://ccb.jhu.edu/software/tophat/](https://ccb.jhu.edu/software/tophat/) @@ -25,6 +24,7 @@ File path for the source of this content: multiqc/modules/tophat/tophat.py ```yaml tophat: - fn: "*align_summary.txt" + fn: '*align_summary.txt' shared: true ``` + \ No newline at end of file diff --git a/docs/markdown/modules/trimmomatic.md b/docs/markdown/modules/trimmomatic.md index b6ca36ba36..c09698b9c9 100644 --- a/docs/markdown/modules/trimmomatic.md +++ b/docs/markdown/modules/trimmomatic.md @@ -2,7 +2,7 @@ title: Trimmomatic displayed_sidebar: multiqcSidebar description: > -

Read trimming tool for Illumina NGS data.

+

Read trimming tool for Illumina NGS data.

--- :::note -

Read trimming tool for Illumina NGS data.

[http://www.usadellab.org/cms/?page=trimmomatic](http://www.usadellab.org/cms/?page=trimmomatic) @@ -43,3 +42,4 @@ Note: The old `trimmomatic.s_name_filenames` option is deprecated and will be re trimmomatic: contents_re: ^Trimmomatic ``` + \ No newline at end of file diff --git a/docs/markdown/modules/truvari.md b/docs/markdown/modules/truvari.md index 55614ff819..3f9df75889 100644 --- a/docs/markdown/modules/truvari.md +++ b/docs/markdown/modules/truvari.md @@ -2,7 +2,7 @@ title: Truvari displayed_sidebar: multiqcSidebar description: > -

Benchmarking, merging, and annotating structural variants.

+

Benchmarking, merging, and annotating structural variants.

--- :::note -

Benchmarking, merging, and annotating structural variants.

[https://github.com/ACEnglish/truvari](https://github.com/ACEnglish/truvari) @@ -33,3 +32,4 @@ truvari/bench: fn: log.txt num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/umicollapse.md b/docs/markdown/modules/umicollapse.md index e6ccdce0eb..bfc9230bb6 100644 --- a/docs/markdown/modules/umicollapse.md +++ b/docs/markdown/modules/umicollapse.md @@ -2,7 +2,7 @@ title: UMICollapse displayed_sidebar: multiqcSidebar description: > -

Algorithms for efficiently collapsing reads with Unique Molecular Identifiers.

+

Algorithms for efficiently collapsing reads with Unique Molecular Identifiers.

--- :::note -

Algorithms for efficiently collapsing reads with Unique Molecular Identifiers.

[https://github.com/Daniel-Liu-c0deb0t/UMICollapse](https://github.com/Daniel-Liu-c0deb0t/UMICollapse) @@ -38,6 +37,7 @@ used for the -i flag, we fallback to the log file name. ```yaml umicollapse: - contents: "UMI collapsing finished in " + contents: 'UMI collapsing finished in ' num_lines: 100 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/umitools.md b/docs/markdown/modules/umitools.md index b5467a70b0..f8395eaed9 100644 --- a/docs/markdown/modules/umitools.md +++ b/docs/markdown/modules/umitools.md @@ -2,7 +2,7 @@ title: UMI-tools displayed_sidebar: multiqcSidebar description: > -

Tools for dealing with Unique Molecular Identifiers (UMIs)/(RMTs) and scRNA-Seq barcodes.

+

Tools for dealing with Unique Molecular Identifiers (UMIs)/(RMTs) and scRNA-Seq barcodes.

--- :::note -

Tools for dealing with Unique Molecular Identifiers (UMIs)/(RMTs) and scRNA-Seq barcodes.

[https://github.com/CGATOxford/UMI-tools](https://github.com/CGATOxford/UMI-tools) @@ -46,9 +45,10 @@ assumption fails, we extract the sample name from the log file name. ```yaml umitools/dedup: - contents: "# output generated by dedup" + contents: '# output generated by dedup' num_lines: 100 umitools/extract: - contents: "# output generated by extract" + contents: '# output generated by extract' num_lines: 100 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/varscan2.md b/docs/markdown/modules/varscan2.md index 8653ce86a1..7a2478f7ca 100644 --- a/docs/markdown/modules/varscan2.md +++ b/docs/markdown/modules/varscan2.md @@ -2,7 +2,7 @@ title: VarScan2 displayed_sidebar: multiqcSidebar description: > -

Variant detection in massively parallel sequencing data.

+

Variant detection in massively parallel sequencing data.

--- :::note -

Variant detection in massively parallel sequencing data.

[http://dkoboldt.github.io/varscan/](http://dkoboldt.github.io/varscan/) @@ -24,7 +23,6 @@ File path for the source of this content: multiqc/modules/varscan2/varscan2.py VarScan is a platform-independent mutation caller for targeted, exome, and whole-genome resequencing data generated on Illumina, SOLiD, Life/PGM, Roche/454, and similar instruments. VarScan can be used to detect different types of variation: - - Germline variants (SNPs an dindels) in individual samples or pools of samples. - Multi-sample variants (shared or private) in multi-sample datasets (with mpileup). - Somatic mutations, LOH events, and germline variants in tumor-normal pairs. @@ -45,3 +43,4 @@ varscan2/mpileup2snp: contents: Only SNPs will be reported num_lines: 10 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/vcftools.md b/docs/markdown/modules/vcftools.md index db36f97d90..f6eb1fa17a 100644 --- a/docs/markdown/modules/vcftools.md +++ b/docs/markdown/modules/vcftools.md @@ -2,7 +2,7 @@ title: VCFTools displayed_sidebar: multiqcSidebar description: > -

Program to analyse and reporting on VCF files.

+

Program to analyse and reporting on VCF files.

--- :::note -

Program to analyse and reporting on VCF files.

[https://vcftools.github.io](https://vcftools.github.io) @@ -63,11 +62,12 @@ or (better still), would like to contribute! ```yaml vcftools/relatedness2: - fn: "*.relatedness2" + fn: '*.relatedness2' vcftools/tstv_by_count: - fn: "*.TsTv.count" + fn: '*.TsTv.count' vcftools/tstv_by_qual: - fn: "*.TsTv.qual" + fn: '*.TsTv.qual' vcftools/tstv_summary: - fn: "*.TsTv.summary" + fn: '*.TsTv.summary' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/vep.md b/docs/markdown/modules/vep.md index 5023c28358..b169b9ba72 100644 --- a/docs/markdown/modules/vep.md +++ b/docs/markdown/modules/vep.md @@ -2,7 +2,7 @@ title: VEP displayed_sidebar: multiqcSidebar description: > -

Determines the effect of variants on genes, transcripts and protein sequences, as well as regulatory regions.

+

Determines the effect of variants on genes, transcripts and protein sequences, as well as regulatory regions.

--- :::note -

Determines the effect of variants on genes, transcripts and protein sequences, as well as regulatory regions.

[https://www.ensembl.org/info/docs/tools/vep/index.html](https://www.ensembl.org/info/docs/tools/vep/index.html) @@ -39,11 +38,12 @@ documentation for more information. ```yaml vep/vep_html: contents: VEP summary - fn: "*.html" + fn: '*.html' max_filesize: 1000000 num_lines: 10 vep/vep_txt: - contents: "[VEP run statistics]" + contents: '[VEP run statistics]' max_filesize: 100000 num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/verifybamid.md b/docs/markdown/modules/verifybamid.md index dc74b8dc53..640f407081 100644 --- a/docs/markdown/modules/verifybamid.md +++ b/docs/markdown/modules/verifybamid.md @@ -2,7 +2,7 @@ title: VerifyBAMID displayed_sidebar: multiqcSidebar description: > -

Detects sample contamination and/or sample swaps.

+

Detects sample contamination and/or sample swaps.

--- :::note -

Detects sample contamination and/or sample swaps.

[https://genome.sph.umich.edu/wiki/VerifyBamID](https://genome.sph.umich.edu/wiki/VerifyBamID) @@ -60,5 +59,6 @@ This was designed to work with verifyBamID 1.1.3 January 2018 ```yaml verifybamid/selfsm: - fn: "*.selfSM" + fn: '*.selfSM' ``` + \ No newline at end of file diff --git a/docs/markdown/modules/vg.md b/docs/markdown/modules/vg.md index 5e2d76331d..47523caf8a 100644 --- a/docs/markdown/modules/vg.md +++ b/docs/markdown/modules/vg.md @@ -2,7 +2,7 @@ title: VG displayed_sidebar: multiqcSidebar description: > -

Toolkit to manipulate and analyze graphical genomes, including read alignment.

+

Toolkit to manipulate and analyze graphical genomes, including read alignment.

--- :::note -

Toolkit to manipulate and analyze graphical genomes, including read alignment.

[https://github.com/vgteam/vg](https://github.com/vgteam/vg) @@ -65,9 +64,10 @@ The graphical reports are designed to mimic a samtools stats report, including: ```yaml vg/stats: contents: - - "Total perfect:" - - "Total gapless (softclips allowed):" - - "Total time:" - - "Speed:" + - 'Total perfect:' + - 'Total gapless (softclips allowed):' + - 'Total time:' + - 'Speed:' num_lines: 30 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/whatshap.md b/docs/markdown/modules/whatshap.md index 778da929ef..1e4aace8bf 100644 --- a/docs/markdown/modules/whatshap.md +++ b/docs/markdown/modules/whatshap.md @@ -2,7 +2,7 @@ title: WhatsHap displayed_sidebar: multiqcSidebar description: > -

Phasing genomic variants using DNA reads (aka read-based phasing, or haplotype assembly).

+

Phasing genomic variants using DNA reads (aka read-based phasing, or haplotype assembly).

--- :::note -

Phasing genomic variants using DNA reads (aka read-based phasing, or haplotype assembly).

[https://whatshap.readthedocs.io/](https://whatshap.readthedocs.io/) @@ -30,3 +29,4 @@ whatshap/stats: contents: "#sample\tchromosome\tfile_name\tvariants\tphased\tunphased\tsingletons" num_lines: 1 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/xengsort.md b/docs/markdown/modules/xengsort.md index a50ae563ed..5fac684127 100644 --- a/docs/markdown/modules/xengsort.md +++ b/docs/markdown/modules/xengsort.md @@ -2,7 +2,7 @@ title: Xengsort displayed_sidebar: multiqcSidebar description: > -

Fast xenograft read sorter based on space-efficient k-mer hashing.

+

Fast xenograft read sorter based on space-efficient k-mer hashing.

--- :::note -

Fast xenograft read sorter based on space-efficient k-mer hashing.

[https://gitlab.com/genomeinformatics/xengsort](https://gitlab.com/genomeinformatics/xengsort) @@ -24,10 +23,10 @@ File path for the source of this content: multiqc/modules/xengsort/xengsort.py The module parses results generated by the `xengsort classify` command. **Note**: MultiQC parses the standard output from xengsort, hence one has to redirect -command line output to a file in order to use it with the MultiQC module. Also note that +command line output to a file in order to use it with the MultiQC module. Also note that the tool does not register any sample name information in the output, so MultiQC attempts to fetch the sample name from the file name by default. Example command that -would help MultiQC recognize data for a sample named "SAMPLE": + would help MultiQC recognize data for a sample named "SAMPLE": ```sh xengsort classify --index myindex --fastq paired.1.fq.gz --pairs paired.2.fq.gz --prefix myresults --classification count > SAMPLE.txt @@ -37,6 +36,7 @@ xengsort classify --index myindex --fastq paired.1.fq.gz --pairs paired.2. ```yaml xengsort: - contents: "# Xengsort classify" + contents: '# Xengsort classify' num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/modules/xenium.md b/docs/markdown/modules/xenium.md index 7a3684fcde..b58d2241cd 100644 --- a/docs/markdown/modules/xenium.md +++ b/docs/markdown/modules/xenium.md @@ -2,7 +2,7 @@ title: Xenium displayed_sidebar: multiqcSidebar description: > -

Spatial transcriptomics platform from 10x Genomics that provides subcellular resolution.

+

Spatial transcriptomics platform from 10x Genomics that provides subcellular resolution.

--- :::note -

Spatial transcriptomics platform from 10x Genomics that provides subcellular resolution.

[https://www.10xgenomics.com/platforms/xenium](https://www.10xgenomics.com/platforms/xenium) @@ -24,13 +23,25 @@ File path for the source of this content: multiqc/modules/xenium/xenium.py 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). -``` -log_filesize_limit: 5000000000 # 5GB +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 ``` +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 @@ -39,10 +50,6 @@ not supported and may even cause MultiQC to crash (see https://github.com/MultiQ ### File search patterns ```yaml -xenium/cell_feature_matrix: - fn: cell_feature_matrix.h5 -xenium/cells: - fn: cells.parquet xenium/experiment: fn: experiment.xenium num_lines: 50 @@ -50,6 +57,5 @@ xenium/metrics: contents: num_cells_detected fn: metrics_summary.csv num_lines: 5 -xenium/transcripts: - fn: transcripts.parquet ``` + \ No newline at end of file diff --git a/docs/markdown/modules/xenome.md b/docs/markdown/modules/xenome.md index 7335d52abe..3d789cc3b3 100644 --- a/docs/markdown/modules/xenome.md +++ b/docs/markdown/modules/xenome.md @@ -2,7 +2,7 @@ title: Xenome displayed_sidebar: multiqcSidebar description: > -

Classifies reads from xenograft sources.

+

Classifies reads from xenograft sources.

--- :::note -

Classifies reads from xenograft sources.

[https://github.com/data61/gossamer/blob/master/docs/xenome.md](https://github.com/data61/gossamer/blob/master/docs/xenome.md) @@ -27,7 +26,7 @@ The module parsed results generated by the `xenome classify` command. command line output to a file in order to use it with the MultiQC module. Also note that the tool does not register any sample name information in the output, so MultiQC attempts to fetch the sample name from the file name by default. Example command that -would help MultiQC recognize data for a sample named "SAMPLE": + would help MultiQC recognize data for a sample named "SAMPLE": ```sh xenome classify -P idx --pairs -i in_1.fastq -i in_2.fastq > SAMPLE.txt @@ -40,3 +39,4 @@ xenome: contents: "B\tG\tH\tM\tcount\tpercent\tclass" num_lines: 2 ``` + \ No newline at end of file diff --git a/docs/markdown/reports/customisation.md b/docs/markdown/reports/customisation.md index 45d763dc17..789aec9c2c 100644 --- a/docs/markdown/reports/customisation.md +++ b/docs/markdown/reports/customisation.md @@ -52,13 +52,16 @@ three lines to your MultiQC configuration file: ```yaml custom_logo: "/abs/path/to/logo.png" +custom_logo_dark: "/abs/path/to/logo-for-dark-mode.png" custom_logo_url: "https://www.example.com" custom_logo_title: "Our Institute Name" +custom_logo_width: 200 # Width in pixels ``` Only `custom_logo` is needed. The URL will make the logo open up a new web browser tab with your address and the title sets the mouse -hover title text. +hover title text. Width allows you to adjust the logo size and dark +lets you have an alternate variant shown when the report is in dark mode. ## Project level information @@ -509,6 +512,17 @@ Alternatively, you can set the following config flag in your MultiQC config: skip_generalstats: true ``` +#### General Statistics Help Text + +You can add a help button with collapsible explanatory text to the General Statistics table +using the `general_stats_helptext` config option: + +```yaml +general_stats_helptext: "This table shows key metrics for all samples. Click column headers to sort." +``` + +When set, a "Help" button will appear that users can click to expand a help text box. + ## Order of modules By default, modules are included in the report as in the order specified in `config.module_order`. @@ -910,7 +924,7 @@ Note that the formatting is done in a specific order - `pass`/`warn`/`fail` by d To find the unique ID for your table / column, right click it in a report and inspect it's HTML (_Inpsect_ in Chrome). -- Tables should look something like ``, where `general_stats_table` is the ID. +- Tables should look something like `
`, where `general_stats_table` is the ID. - Table cells should look something like `\n \n \n `);$(".download-citations-btn").click(function(t){if(t.preventDefault(),"bibtex"==$(this).data("format")){var o="",i=[];for(var n in e)i.push($.get("https://api.crossref.org/works/"+n+"/transform/application/x-bibtex",function(e){o+=e+"\n"}));$.when.apply(null,i).then(function(){var e=new Blob([o],{type:"text/plain;charset=utf-8"});saveAs(e,"multiqc_references.bib")})}else{var s="";for(var n in e)s+=n+new Array(50-n.length).join(" ")+" # "+e[n]+"\n";var a=new Blob([s],{type:"text/plain;charset=utf-8"});saveAs(a,"multiqc_dois.txt")}})},window.initHelp=function(){function e(){var e=$(".regex_example_demo input").val();console.log("Testing "+e),$(".regex_example_demo pre span").each(function(){$(this).removeClass(),$(this).find("hl").contents().unwrap();var t=$(this).text(),o=t.match(e);if(o){console.log("Matches "+t);var i=t.indexOf(o[0]),n=t.substring(0,i),s=o[0],a=t.substring(i+s.length);$(this).html($("").text(n).addClass("text-muted").prop("outerHTML")+$("").text(s).addClass("mark text-success").prop("outerHTML")+$("").text(a).addClass("text-muted").prop("outerHTML"))}else console.log("Matches "+t),$(this).addClass("text-muted")})}$(".regex_example_buttons button").click(function(t){t.preventDefault(),$(".regex_example_demo input").val($(this).data("example")),e()}),$(".regex_example_demo input").keyup(function(t){e()})},window.mqc_toolbox_openclose=function(e,t=!1){const o=document.getElementById("mqc-toolbox"),i=bootstrap.Offcanvas.getOrCreateInstance(o),n=o.classList.contains("show");if(t&&!n)i.show();else{if(!t&&n)return void i.hide();t||n||i.show()}e&&setTimeout(()=>{const t=$(`.mqc-toolbox-buttons a[href="${e}"]`)[0];if(t){new bootstrap.Tab(t).show()}},100)},$(function(){const e=document.getElementById("mqc-toolbox"),t=new bootstrap.Offcanvas("#mqc-toolbox");$(".mqc-toolbox-buttons a").click(function(o){e.classList.contains("show")||t.show();new bootstrap.Tab(this).show()}),$(".mobile-nav-toolbox-btns a[href^='#mqc_']").click(function(o){o.preventDefault();const i=$(this).attr("href");e.classList.contains("show")||t.show();const n=$(`.mqc-toolbox-buttons a[href="${i}"]`)[0];new bootstrap.Tab(n).show();const s=document.getElementById("mqc-nav-collapse");if(s&&s.classList.contains("show")){new bootstrap.Collapse(s).hide()}}),e.addEventListener("hidden.bs.offcanvas",e=>{mqc_toolbox_confirmapply(),$(".mqc-toolbox-buttons .list-group-item").removeClass("active"),$("#mqc-toolbox > .offcanvas-body > .tab-content > .tab-pane").removeClass("active show")}),$(".modal").on("show.bs.modal",function(o){e.classList.contains("show")&&t.hide()}),$(document).on("mqc_config_loaded",function(e){$(".hc-plot:not(.not_rendered)").each(function(){let e=$(this).attr("id");renderPlot(e)})}),initHighlights(),initRename(),initHideSamples(),initSaveLoad(),initExport(),initAI(),initFilters(),initCitations(),initHelp(),initAICookies()});let Ps=class extends Plot{constructor(e){super(e),this.filteredSettings=[],this.groupSettingsMap=null}activeDatasetSize(){if(0===this.datasets.length)return 0;let e=this.datasets[this.activeDatasetIdx].cats;return 0===e.length?0:e[0].data.length}prepData(e){let t=(e=e??this.datasets[this.activeDatasetIdx]).cats,o=e.samples,i=e.group_labels||null,n=e.offset_groups||null,s=applyToolboxSettings(o);if(i&&i.length>0){let e=[...new Set(i)],s=applyToolboxSettings(e);this.groupSettingsMap=Object.fromEntries(e.map((e,t)=>[e,s[t]]));let a=i.map(e=>{let t=this.groupSettingsMap[e];return!(t&&t.hidden)});return this.filteredSettings=o.filter((e,t)=>a[t]).map(e=>({name:e})),this.originalGroupLabels=i.filter((e,t)=>a[t]),this.filteredGroupLabels=this.originalGroupLabels.map(e=>this.groupSettingsMap[e]?.name||e),this.offsetGroups=n,t=t.map(e=>({data:(this.pActive?e.data_pct:e.data).filter((e,t)=>a[t]),color:e.color,name:e.name})),[t]}return this.groupSettingsMap=null,this.originalGroupLabels=null,this.filteredSettings=s.filter(e=>!e.hidden),this.filteredGroupLabels=null,this.offsetGroups=n,t=t.map(e=>({data:(this.pActive?e.data_pct:e.data).filter((e,t)=>!s[t].hidden),color:e.color,name:e.name})),[t]}plotAiHeader(){let e=super.plotAiHeader();return this.pconfig.ylab&&(e+=`Values: ${this.pconfig.ylab}\n`),e}formatDatasetForAiPrompt(e){let t="",o=e.cats,i=e.samples,n=applyToolboxSettings(i);if(n.every(e=>e.hidden))return t+="All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n",t;t+="|Sample|"+o.map(e=>e.name).join("|")+"|\n",t+="|---|"+o.map(()=>"---").join("|")+"|\n";let s="";return this.pActive?(s+="%",this.layout.xaxis.ticksuffix&&"%"!==this.layout.xaxis.ticksuffix&&(s+=" "+this.layout.xaxis.ticksuffix)):this.layout.xaxis.ticksuffix&&(s+=" "+this.layout.xaxis.ticksuffix),n.forEach((e,i)=>{e.hidden||(t+=`|${e.pseudonym??e.name}|`+o.map(e=>{let t=this.pActive?e.data_pct[i]:e.data[i];return t=Number.isFinite(t)?Number.isInteger(t)?t:parseFloat(t.toFixed(2)):"",""!==t&&s&&(t+=s),t}).join("|")+"|\n")}),t}resize(e){if(this.layout.height=e,!(this.filteredGroupLabels&&this.filteredGroupLabels.length>0)){const e=(this.layout.height-140)/12;this.recalculateTicks(this.filteredSettings,this.layout.yaxis,e)}super.resize(e)}buildTraces(){let[e]=this.prepData();if(0===e.length||0===this.filteredSettings.length)return[];let t=this.filteredGroupLabels&&this.filteredGroupLabels.length>0;if(!t){const e=(this.layout.height-140)/12;this.recalculateTicks(this.filteredSettings,this.layout.yaxis,e)}let o=this.filteredSettings.filter(e=>e.highlight),i=this.firstHighlightedSample(this.filteredSettings),n=this.datasets[this.activeDatasetIdx].trace_params;if(t){let t=Object.values(this.groupSettingsMap||{}).some(e=>e.highlight),o=[],i=new Set,s={},a={};this.filteredSettings.forEach((t,n)=>{let r=t.name,l=this.originalGroupLabels[n],c=this.filteredGroupLabels[n];i.has(r)||(i.add(r),o.push(r),s[r]={},a[r]=[]),a[r].push({originalGroupLabel:l,displayGroupLabel:c,dataIdx:n}),s[r][l]||(s[r][l]=e.map(e=>({name:e.name,value:e.data[n]})))});let r=this.layout.xaxis?.hoverformat||".2f",l={};e.forEach(t=>{let o=["%{customdata.sampleName}"];e.forEach((e,i)=>{let n=e.name===t.name?"► ":" ",s=e.name===t.name?"":"";o.push(n+e.name+": %{customdata.catData["+i+"].value:"+r+"}"+s)}),l[t.name]=o.join("
")+""});let c=[];return o.forEach((o,i)=>{e.forEach(e=>{let r=[],d=[],h=[];if(a[o].forEach(({originalGroupLabel:i,displayGroupLabel:n,dataIdx:a})=>{let l=this.groupSettingsMap[i]?.highlight,c=t&&!l?.1:1;r.push(n),d.push(e.data[a]),h.push({sampleName:o,catData:s[o][i],alpha:c})}),r.length>0){let t=h.map(e=>e.alpha).map(t=>"rgba("+e.color+","+t+")");c.push({type:"bar",x:d,y:r,customdata:h,name:e.name,meta:e.name,offsetgroup:this.offsetGroups?this.offsetGroups[o]:o,legendgroup:e.name,showlegend:0===i,...n,marker:{...n.marker,color:t},hovertemplate:l[e.name]})}})}),c}return e.map(e=>{if("group"!==this.layout.barmode)return this.filteredSettings.map((t,s)=>{let a=JSON.parse(JSON.stringify(n)),r=o.length>0&&null===t.highlight?.1:1;return a.marker.color="rgba("+e.color+","+r+")",{type:"bar",x:[e.data[s]],y:[t.name],name:e.name,meta:e.name,showlegend:s===i,legendgroup:e.name,...a}});{let t=JSON.parse(JSON.stringify(n)),o=this.filteredSettings.map(e=>e.name);return t.marker.color="rgb("+e.color+")",{type:"bar",x:e.data,y:o,name:e.name,meta:e.name,...t}}})}afterPlotCreated(){if(super.afterPlotCreated(),!this.groupSettingsMap)return;const e=document.getElementById(this.anchor);e&&e.querySelectorAll(".yaxislayer-above .ytick text, .yaxislayer-above .ytick tspan").forEach(e=>{const t=e.textContent;let o=Object.keys(this.groupSettingsMap).find(e=>this.groupSettingsMap[e].name===t),i=this.groupSettingsMap[o];i?.highlight&&(e.style.fill=i.highlight,e.style.fontWeight="bold")})}exportData(e){let[t]=this.prepData(),o="tsv"===e?"\t":",";if(this.filteredGroupLabels&&this.filteredGroupLabels.length>0){let e="Group"+o+"Sample"+o+t.map(e=>e.name).join(o)+"\n";for(let i=0;ie.data[i]).join(o)+"\n";return e}let i="Sample"+o+t.map(e=>e.name).join(o)+"\n";for(let n=0;ne.data[n]).join(o)+"\n";return i}};window.BarPlot=Ps;let Ls=class extends Plot{constructor(e){super(e),this.filteredSettings=[],this.sortSwitchSortedActive=e.sort_switch_sorted_active,this.isStatsData=!!(e.datasets&&e.datasets.length>0)&&e.datasets[0].is_stats_data}activeDatasetSize(){return 0===this.datasets.length?0:this.datasets[this.activeDatasetIdx].samples.length}prepData(e){e=e??this.datasets[this.activeDatasetIdx];let t=this.sortSwitchSortedActive&&e.data_sorted?e.data_sorted:e.data,o=this.sortSwitchSortedActive&&e.samples_sorted?e.samples_sorted:e.samples,i=applyToolboxSettings(o);return this.filteredSettings=i.filter(e=>!e.hidden),o=this.filteredSettings.map(e=>e.name),t=t.filter((e,t)=>!i[t].hidden),[t,o]}formatDatasetForAiPrompt(e){let t="",[o,i]=this.prepData(e);if(0===i.length)return"All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n";t+="|Sample|Min|Q1|Median|Q3|Max|Mean|\n",t+="|---|---|---|---|---|---|---|\n";const n=this.layout.xaxis.ticksuffix?" "+this.layout.xaxis.ticksuffix:"";let s=e=>{if(!Number.isFinite(e))return"";return(Number.isInteger(e)?e:parseFloat(e.toFixed(2)))+n};return i.forEach((e,i)=>{if(this.isStatsData){const n=o[i],a=n.min||0,r=n.max||0,l=n.median||0,c=n.q1||a,d=n.q3||r,h=n.mean||l;t+=`|${e}|${s(a)}|${s(c)}|${s(l)}|${s(d)}|${s(r)}|${s(h)}|\n`}else{const n=o[i].filter(e=>Number.isFinite(e)).sort((e,t)=>e-t);if(0===n.length)return;let a=n.length,r=n[0],l=n[a-1],c=a%2==1?n[Math.floor(a/2)]:(n[a/2-1]+n[a/2])/2,d=a>=4?n[Math.floor(a/4)]:n[0],h=a>=4?n[Math.floor(3*a/4)]:n[a-1],p=n.reduce((e,t)=>e+t,0)/a;t+=`|${e}|${s(r)}|${s(d)}|${s(c)}|${s(h)}|${s(l)}|${s(p)}|\n`}}),t}resize(e){this.layout.height=e;const t=(this.layout.height-140)/12;this.recalculateTicks(this.filteredSettings,this.layout.yaxis,t),super.resize(e)}buildTraces(){let[e,t]=this.prepData();if(0===e.length||0===t.length)return[];const o=(this.layout.height-140)/12;this.recalculateTicks(this.filteredSettings,this.layout.yaxis,o);let i=this.filteredSettings.filter(e=>e.highlight),n=this.datasets[this.activeDatasetIdx].trace_params;return this.filteredSettings.map((t,o)=>{let s=JSON.parse(JSON.stringify(n));i.length>0&&(null!==t.highlight?s.marker.color=t.highlight:s.marker.color="grey");let a=e[o];return this.isStatsData?{type:"box",q1:[a.q1||0],median:[a.median||0],q3:[a.q3||0],lowerfence:[a.min||0],upperfence:[a.max||0],mean:[a.mean||a.median||0],y:[t.name],name:t.name,...s}:{type:"box",x:a,name:t.name,...s}})}exportData(e){let[t,o]=this.prepData(),i="tsv"===e?"\t":",",n="";if(this.isStatsData){n="Sample"+i+"Min"+i+"Q1"+i+"Median"+i+"Q3"+i+"Max"+i+"Mean\n";for(let e=0;ee.name),i=applyToolboxSettings(o);return this.filtSampleSettings=i.filter(e=>!e.hidden),t=t.filter((e,t)=>!i[t].hidden),t=t.map((e,t)=>(e.highlight=i[t].highlight,e.pseudonym=i[t].pseudonym,e)),[o,t]}plotAiHeader(){let e=super.plotAiHeader();return this.pconfig.xlab&&(e+=`X axis: ${this.pconfig.xlab}\n`),this.pconfig.ylab&&(e+=`Y axis: ${this.pconfig.ylab}\n`),e}formatDatasetForAiPrompt(e){let[t,o]=this.prepData(e);if(0===t.length)return"All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n";const i=this.layout.xaxis.ticksuffix||"",n=this.layout.yaxis.ticksuffix||"";let s="Samples: "+t.join(", ")+"\n\n";n&&(s+=`Y values are in ${n}\n\n`),i&&(s+=`X values are in ${i}\n\n`);return s+"\n\n"+o.map(e=>({name:e.pseudonym??e.name,pairs:e.pairs.map(e=>e.map((e,t)=>Number.isFinite(e)?Number.isInteger(e)?e:parseFloat(e.toFixed(2)):""))})).map(e=>e.name+" "+e.pairs.map(e=>e.join(": ")).join(", ")).join("\n\n")}buildTraces(){let e=this.datasets[this.activeDatasetIdx],[t,o]=this.prepData();if(0===o.length||0===t.length)return[];let i=o.filter(e=>e.highlight),n=o.filter(e=>!e.highlight);return o=n.concat(i),o.map(t=>{let o=t.color;i.length>0&&(o=t.highlight??"#cccccc");let n={line:{color:o,dash:t.dash,width:t.width},marker:{color:o},showlegend:t.showlegend??null,mode:t.mode??null},s=t.marker??null;return s&&(n.mode="lines+markers",n.marker={symbol:s.symbol,color:s.fill_color??s.color??o,line:{width:s.width,color:s.line_color??s.color??o}}),updateObject(n,e.trace_params,!0),{type:"scatter",x:t.pairs.map(e=>e[0]),y:t.pairs.map(e=>e[1]),name:t.name,text:t.pairs.map(()=>t.name),...n}})}exportData(e){let[t,o]=this.prepData(),i=!0,n=null;o.forEach(e=>{let t;t=e.pairs.map(e=>e[0]),null===n?n=t:(n.length!==t.length||n.some((e,o)=>e!==t[o]))&&(i=!1)});let s="tsv"===e?"\t":",",a="";return i?(a+="Sample"+s+n.join(s)+"\n",o.forEach(e=>{a+=e.name+s+e.pairs.map(e=>e[1]).join(s)+"\n"})):o.forEach(e=>{a+=e.name+s+"X"+s+e.pairs.map(e=>e[0]).join(s)+"\n",a+=e.name+s+"Y"+s+e.pairs.map(e=>e[1]).join(s)+"\n"}),a}};window.LinePlot=Ns;let Ms=class extends Plot{activeDatasetSize(){return 0===this.datasets.length?0:this.datasets[this.activeDatasetIdx].points}prepData(e){let t=(e=e??this.datasets[this.activeDatasetIdx]).points,o=t.map(e=>e.name),i=applyToolboxSettings(o);return t=t.map((e,t)=>{if(e.pseudonym=i[t].pseudonym,e.name=i[t].name??e.name,e.highlight=i[t].highlight,!i[t].hidden)return e}),[o,t]}plotAiHeader(){let e=super.plotAiHeader();return this.pconfig.xlab&&(e+=`X axis: ${this.pconfig.xlab}\n`),this.pconfig.ylab&&(e+=`Y axis: ${this.pconfig.ylab}\n`),this.pconfig.categories&&(e+=`X categories: ${this.pconfig.categories.join(", ")}\n`),e}formatDatasetForAiPrompt(e){let[t,o]=this.prepData(e,!0);if(0===t.length)return"All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n";const i=this.layout.xaxis.ticksuffix,n=this.layout.yaxis.ticksuffix;return o=o.map(e=>({name:e.name,x:Number.isFinite(e.x)?(Number.isInteger(e.x)?e.x:parseFloat(e.x.toFixed(2)))+(i??""):"",y:Number.isFinite(e.y)?(Number.isInteger(e.y)?e.y:parseFloat(e.y.toFixed(2)))+(n??""):""})),o.map(e=>`${e.name} (${e.x}, ${e.y})`).join("\n")}buildTraces(){let e=this.datasets[this.activeDatasetIdx],[t,o]=this.prepData();if(0===o.length||0===t.length)return[];let i=o.filter(e=>e.highlight),n=o.filter(e=>!e.highlight);o=n.concat(i);let s=new Set;return this.pconfig.groups&&o.sort((e,t)=>{let o=this.pconfig.groups.indexOf(e.group),i=this.pconfig.groups.indexOf(t.group);return-1===o&&(o=this.pconfig.groups.length),-1===i&&(i=this.pconfig.groups.length),o-i}),o.map(t=>{let o=JSON.parse(JSON.stringify(e.trace_params));o.marker.size=t.marker_size??o.marker.size,o.marker.line={width:t.marker_line_width??o.marker.line.width},o.marker.opacity=t.opacity??o.marker.opacity,o.marker.color=t.color??o.marker.color,o.marker.symbol=t.marker_symbol??o.marker.symbol,i.length>0&&(o.marker.color=t.highlight??"#cccccc");let n=!1,a=t.name;if(!t.hide_in_legend&&t.group){let e=t.group;s.has(e)||(s.add(e),n=!0,a=t.group)}else t.hide_in_legend||t.group||(n=!0,a=t.name);let r={type:"scatter",x:[t.x],y:[t.y],name:a,text:[t.annotation??t.name],showlegend:n,...o};return t.group&&(r.legendgroup=t.group),r})}exportData(e){let[t,o]=this.prepData(),i="tsv"===e?"\t":",",n=["Name","X","Y"].join(i)+"\n";for(let s=0;st.filter((t,o)=>!e[o].hidden)),this.filtXCatsSettings=e.filter(e=>!e.hidden),o=this.filtXCatsSettings.map(e=>e.name)}if(this.yCatsAreSamples){let e=applyToolboxSettings(i);if(null===e)return;t=t.filter((t,o)=>!e[o].hidden),this.filtYCatsSettings=e.filter(e=>!e.hidden),i=this.filtYCatsSettings.map(e=>e.name)}return[t,o,i]}plotAiHeader(){let e=super.plotAiHeader();return this.pconfig.xlab&&(e+=`X axis: ${this.pconfig.xlab}\n`),this.pconfig.ylab&&(e+=`Y axis: ${this.pconfig.ylab}\n`),this.pconfig.zlab&&(e+=`Z axis: ${this.pconfig.zlab}\n`),e}formatDatasetForAiPrompt(e){let t="",[o,i,n]=this.prepData(e);if(0===i.length||0===n.length)return t+="All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n",t;if(i){if(n&&(t="|",this.yCatsAreSamples&&(t+="Sample")),this.xCatsAreSamples){t+="|"+this.filtXCatsSettings.map(e=>e.pseudonym??e.name).join("|")+"|\n"}else t+="|"+i.join("|")+"|\n";n&&(t+="|---"),t+="|"+i.map(()=>"---").join("|")+"|\n"}for(let s=0;se.pseudonym??e.name)[s]}else t+="|"+n[s];t+="|"+o[s].map(e=>Number.isFinite(e)?Number.isInteger(e)?e:parseFloat(e.toFixed(2)):"").join("|")+"|\n"}return t}buildTraces(){let[e,t,o]=this.prepData();if(0===e.length||0===t.length||0===o.length)return[];if(this.filtYCatsSettings.length>0){const e=(this.layout.height-200)/12;this.recalculateTicks(this.filtYCatsSettings,this.layout.yaxis,e)}if(this.filtXCatsSettings.length>0){const e=(this.layout.width-250)/18;this.recalculateTicks(this.filtXCatsSettings,this.layout.xaxis,e)}let i=this.datasets[this.activeDatasetIdx];return[{type:"heatmap",z:e,x:t,y:o,...JSON.parse(JSON.stringify(i.trace_params))}]}exportData(e){let[t,o,i]=this.prepData(),n="tsv"===e?"\t":",",s=[".",...o].join(n)+"\n";for(let a=0;a{let o=i[e],n=$(`#${this.tableAnchor}_config_modal_table .mqc_table_col_visible[value="${e}"]`),s=n.length>0&&!n.is(":checked");return(!0!==o.hidden||t)&&!s});let n=e.violin_value_by_sample_by_metric,s={};o.forEach(t=>{let o=i[t],a={};o.show_points&&(a=o.show_only_outliers?e.scatter_value_by_sample_by_metric[t]:n[t]),s[t]=a});let a=e.all_samples,r=applyToolboxSettings(a);if(r.filter(e=>e.hidden).length>0){let e={},t={};o.map(t=>{e[t]={},Object.keys(n[t]).map(o=>{r[a.indexOf(o)].hidden||(e[t][o]=n[t][o])})}),n=e,o.forEach(e=>{let o,s=i[e];o=s.show_points&&s.show_only_outliers?t[e]:n[e],t[e]={},Object.keys(o).map(i=>{r[a.indexOf(i)].hidden||(t[e][i]=o[i])})})}return[o,i,a,r,n,s]}plotAiHeader(e){let t="";return t+="table"===e?"Plot type: table\n":"Plot type: violin plot\n",t}formatDatasetForAiPrompt(e){let[t,o,i,n,s,a]=this.prepData(e,!0),r="Number of samples: "+i.length+"\n";return this.isDownsampled&&(r+="Note: sample number "+i.length+" is greater than the threshold so data points were downsampled to fit the context window. However, outliers for each metric were identified and kept in the datasets.\n"),r+="\n",0===t.length?(r+='All columns are hidden by user, so no data to analyse. Please inform user to use the "Configure columns" button to make some columns visible.\n',r):n.every(e=>e.hidden)?(r+="All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n",r):(r+="Metrics:\n",r+=t.map(e=>`${o[e].title} - ${o[e].description}`).join("\n"),r+="\n\n",r+=`|${this.pconfig.col1_header}|`+t.map(e=>o[e].title).join("|")+"|\n",r+="|---|"+t.map(()=>"---").join("|")+"|\n",r+=n.map(e=>e.hidden||t.every(t=>void 0===s[t][e.originalName]&&void 0===a[t][e.originalName])?"":`|${e.pseudonym??e.name}|`+t.map(t=>{const i=s[t][e.originalName]??a[t][e.originalName],n=o[t].suffix;return null==i?"":"string"==typeof i?i+(n??""):Number.isFinite(i)?Number.isInteger(i)?i:i.toFixed(2):""}).join("|")+"|\n").join(""),r)}buildTraces(){let e=this.datasets[this.activeDatasetIdx],[t,o,i,n,s,a]=this.prepData(),r=!0;t.forEach(e=>{let t=o[e];t.show_points&&t.show_only_outliers||(r=!1)}),r&&$("#table-violin-info-"+this.anchor).append(" For efficiency, separate points are shown only for outliers.");let l=this.layout;if(l.height=this.violinHeight*t.length+this.extraHeight,$("#"+this.anchor+"-wrapper").css("height",l.height+"px"),0===t.length)return[];l.grid.rows=t.length,l.grid.subplots=t.map((e,t)=>{let o=0===t?"":t+1;return["x"+o+"y"+o]}),t.map((e,t)=>{let i=o[e];l["yaxis"+(t+1)]={automargin:l.yaxis.automargin,color:l.yaxis.color,gridcolor:l.yaxis.gridcolor,zerolinecolor:l.yaxis.zerolinecolor,hoverformat:l.yaxis.hoverformat,tickfont:{size:l.yaxis.tickfont.size,color:l.yaxis.tickfont.color}},l["xaxis"+(t+1)]={automargin:l.xaxis.automargin,color:l.xaxis.color,gridcolor:l.xaxis.gridcolor,zerolinecolor:l.xaxis.zerolinecolor,hoverformat:l.xaxis.hoverformat,tickfont:{size:l.xaxis.tickfont.size,color:l.xaxis.tickfont.color}},void 0!==i.xaxis&&null!==i.xaxis&&(l["xaxis"+(t+1)]=Object.assign(l["xaxis"+(t+1)],i.xaxis));let n=i.title+" ";i.namespace&&(n=i.namespace+"
"+n),l["yaxis"+(t+1)].tickmode="array",l["yaxis"+(t+1)].tickvals=[t],l["yaxis"+(t+1)].ticktext=[n],void 0!==i.hoverformat&&null!==i.hoverformat&&(l["xaxis"+(t+1)].hoverformat=i.hoverformat)}),l.xaxis=l.xaxis1,l.yaxis=l.yaxis1;let c=[];t.map((t,i)=>{let n=o[t],a=JSON.parse(JSON.stringify(e.trace_params));const r=e=>{if(!e)return null;if(/^\d+,\s*\d+,\s*\d+$/.test(e))return e;if(e.startsWith("#")){const t=e.replace("#","");if(6===t.length){return`${parseInt(t.substr(0,2),16)},${parseInt(t.substr(2,2),16)},${parseInt(t.substr(4,2),16)}`}}return null};let l=r(n.color)||r(a.fillcolor);const d="dark"===document.documentElement.getAttribute("data-bs-theme")?.3:.5;l&&(a.fillcolor=`rgba(${l},${d})`);let h=s[t],p=[],m=[];Object.entries(h).map(([e,t])=>{p.push(e),m.push(t)});let u=0===i?"":i+1;c.push({type:"violin",x:m,name:i,text:p,xaxis:"x"+u,yaxis:"y"+u,...a})});let d=c.length,h={},p={},m=[];t.map((e,t)=>{let o=0===t?"":t+1,i=a[e],n=[];Object.entries(i).map(([e,t])=>{n.push([e,t]),h[e]||(h[e]=[],p[e]=[]),h[e].push(d++),p[e].push("x"+o+"y"+o)}),m.push(n)});let u=n.filter(e=>e.highlight).length>0,f=42.1231;function g(){let e=1e4*Math.sin(f++);return e-Math.round(e)}let _=[];return m.map((t,o)=>{let s=0===o?"":o+1;t.map(([t,a])=>{let r=n[i.indexOf(t)],l=JSON.parse(JSON.stringify(e.scatter_trace_params)),c=l.marker.color,d=l.marker.size;const m="dark"===document.documentElement.getAttribute("data-bs-theme");m&&!u&&("#000000"===c?c="#ffffff":"#0b79e6"===c&&(c="#5dade2")),u&&(c=r.highlight??"#ddd",d=null!==r.highlight?8:d);let f={curveNumbers:h[t],curveAxis:p[t]};_.push({type:"scatter",x:[a],y:[o+.3*g()],text:[r.name??t],xaxis:"x"+s,yaxis:"y"+s,customdata:f,...l,marker:{color:c,size:d},hoverlabel:{bgcolor:m?"rgba(40,40,40,1)":"rgba(255,255,255,1)",bordercolor:"rgba(100,100,100,1)",font:{color:m?"rgba(220,220,220,1)":"rgba(30,30,30,1)"}}})})}),c=c.concat(_),c}exportData(e){let[t,o,i,n,s,a]=this.prepData(),r="tsv"===e?"\t":",",l=t.map(e=>o[e].title);l=l.map(e=>e.includes(r)?`"${e}"`:e);let c="Sample"+r+l.join(r)+"\n";for(let d=0;d{let o=s[t][e];return void 0===o&&(o="."),o}).join(r),c+="\n")}return c}afterPlotCreated(){let e=this.anchor;document.getElementById(e).on("plotly_hover",function(t){if(!t.points)return;let o=t.points[0];if("scatter"===o.data.type){let t=o.data.customdata.curveNumbers,i=o.data.customdata.curveAxis,n=t.map(e=>({curveNumber:e,pointNumber:0}));Plotly.Fx.hover(e,n,i)}})}};function zs(e){return e.toString().includes("Failed to fetch")&&(e="Failed to connect to AI provider. Please check your internet connection and the API key, and try again."),e}function Rs(e){if(!e)return!1;return["o1","o1-preview","o1-mini","o3","o3-mini","o3-pro","o4-mini","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"].some(t=>e.toLowerCase().startsWith(t))}function Hs(){return localStorage.getItem("ai_reasoning_effort")||"medium"}function Bs(){const e=localStorage.getItem("ai_max_completion_tokens");if(e)try{return parseInt(e)}catch(t){console.error("Error parsing stored max completion tokens",t)}return 4e3}function Ws(){const e=localStorage.getItem("ai_thinking_budget_tokens");if(e)try{return parseInt(e)}catch(t){console.error("Error parsing stored thinking budget tokens",t)}return 1e4}function Us(){return"true"===localStorage.getItem("ai_extended_thinking")}function Vs(e,t,o,i,n,s){const a=new TextDecoder;let r,l,c="",d=!1;return function h(){return t.read().then(({value:t,done:p})=>{if(p)return void s(l);c+=a.decode(t,{stream:!0});const m=c.split("\n");return c=m.reduce((t,a)=>{if(!(a=a.trim()))return t;if(!a.startsWith("data: "))return a.includes('"type":"invalid_request_error"')?(n(a),t):t;let c,h,p,m,u,f=a.slice(6);if("[DONE]"===f)return s(),t;try{c=JSON.parse(f)}catch(g){return t}switch("seqera"===e?(h=c.type,p=c.content,r=c.metadata?.ls_model_name,l=l||c.thread_id,u=c.response_metadata?.stop_reason,u&&"end_turn"!==u&&(h="error",error=`Streaming unexpectedly stopped. Reason: ${u}`)):"openai"===e||"custom"===e?(p=c.choices[0].delta.content,r=c.model,m=c.choices[0].delta.role,u=c.choices[0].finish_reason,p?h="on_chat_model_stream":"stop"===u?h="on_chat_model_end":"assistant"===m?h="on_chat_model_start":u&&"stop"!==u?(h="error",error=`Streaming unexpectedly stopped. Reason: ${u}`):h="unknown"):"anthropic"===e&&(h=c.type,"content_block_delta"===h&&"text_delta"===c.delta.type?(p=c.delta.text,h="on_chat_model_stream"):"message_start"===h?(r=c.message.model,m=c.message.role,h="on_chat_model_start"):c.finish_reason?"end_turn"===c.finish_reason?h="on_chat_model_end":(h="error",error=`Streaming unexpectedly stopped. Reason: ${c.finish_reason}`,c.error&&(error+=`. Error: ${c.error}`)):c.error&&(h="error",error=`Streaming unexpectedly stopped. Error: ${c.error}`)),h){case"on_chat_model_start":d=!0,o(r);break;case"on_chat_model_stream":d||(d=!0,o(r)),p&&i(p);break;case"on_chat_model_end":return s(l),t;case"error":n(error)}return t},""),h()}).catch(e=>n(e))}()}function Ys(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function Qs(e,t=!1,o="table"){let i="",n=e??0;const s=$("#ai-provider").val(),a=window.AI_PROVIDERS[s],r=Gs($("#ai-model").val());i+="\n----------------------\n\n",i+="Tools used in the report:\n\n",Object.entries(aiReportMetadata.tools).forEach(([e,t],o)=>{const n=`${o+1}. ${t.name}`+(t.info?`\nDescription: ${t.info}`:"")+(t.href&&t.href.length>0?`\nLinks: ${t.href}`:"")+(t.comment?`\nComment: ${t.comment}`:"")+"\n\n";i+=n}),i+="\n----------------------\n",n+=Ks(i);const l=mqc_plots.general_stats_table;if(l){const e=`\nMultiQC General Statistics (overview of key QC metrics for each sample, across all tools)\n${l.formatForAiPrompt(o)}\n----------------------\n`;i+=e;const t=Ks(e);if(!(n+t<=r))return console.error(`General stats alone would already exceed the token limit of ${a.name} (${n+t} > ${r}). Cannot summarize the report`),i;n+=t}let c="";if(!t)for(const[d,h]of Object.entries(aiReportMetadata.sections)){c+=`\nTool: ${aiReportMetadata.tools[h.module_anchor].name}\n`,c+=Js(d),c+="\n\n----------------------";const e=Ks(c);if(n+e>r)return console.warn(`Truncating prompt to only the general stats to fit within the context window of ${a.name} (${r} tokens). Tokens estimate: ${n}, with sections: at least ${n+e}`),i}return i+=c,i}function Js(e,t,o){let i=function(e,t){if("general_stats_table"===e)return"";let o="";t&&(o+=function(e){let t=`Tool that produced data: ${e.name}`;return e.info&&(t+=`\nTool description: ${e.info}`),e.href&&e.href.length>0&&(t+=`\nTool URL: ${e.href}`),e.comment&&(t+=`\nTool comment: ${e.comment}`),t}(aiReportMetadata.tools[t])+"\n\n");return o+=function(e){let t=`Section: ${e.name}`;return e.description&&(t+=`\nSection description: ${e.description}`),e.comment&&(t+=`\nSection comment: ${e.comment}`),e.helptext&&(t+=`\nSection help text: ${e.helptext}`),t}(aiReportMetadata.sections[e]),o}(e,t);i&&(i+="\n");const n=aiReportMetadata.sections[e];n.content_before_plot&&(i+=n.content_before_plot+"\n\n"),n.content&&(i+=n.content+"\n\n");const s=n.plot_anchor;let a=mqc_plots[s];return a?(a.pconfig&&a.pconfig.title&&(i+=`Title: ${a.pconfig.title}\n`),i+="\n"+a.formatForAiPrompt(o),i):i}function Gs(e){let t=$("#ai-context-window").val();if(t)try{return parseInt(t)}catch(o){console.error("Error parsing custom context window",o)}return e.startsWith("claude")?2e5:128e3}async function Xs(e){e.preventDefault();const t=$(e.currentTarget),o=t.hasClass("ai-generate-button-more"),i=t.data("action"),n=$("#"+t.data("response-div")),s=$("#"+t.data("detailed-analysis-div")),a=$("#"+t.data("error-div")),r=$("#"+t.data("wrapper-div")),l=t.data("original-html"),c=t.data("plot-anchor")||"global",d=$("#"+t.data("continue-in-chat-button"));"clear"===i?(e.preventDefault(),localStorage.removeItem(`ai_response_${reportUuid}_${c}${o?"_more":""}`),n.html("").hide(),s.html("").hide(),a.html("").hide(),r&&r.hide(),d.hide(),t.html(l).data("action","generate").removeClass("ai-local-content")):async function(e){const t=e.hasClass("ai-generate-button-global"),o=e.hasClass("ai-generate-button-more"),i=$("#"+e.data("response-div")),n=$("#"+e.data("error-div")).hide(),s=$("#"+e.data("disclaimer-div")),a=$("#"+e.data("wrapper-div")),r=$("#"+e.data("continue-in-chat-button")),l=e.data("section-anchor")||"global",c=e.data("module-anchor"),d=e.data("plot-view"),h=e.data("clear-text");let p,m,u="None"==configTitle?"":configTitle+": ";if(t)m=o?window.systemPromptReportFull:window.systemPromptReportShort,p=Qs(Ks(m)),u+="MultiQC report";else if("general_stats_table"===l)m=window.systemPromptPlot,p=Qs(Ks(m),!0,d),u+="MultiQC General Statistics";else{m=window.systemPromptPlot,p=Js(l,c,d);const e=aiReportMetadata.sections[l];u+=`MultiQC ${e.name}`}const f=Ks(m+p),g=$("#ai-provider").val(),_=window.AI_PROVIDERS[g];let b=$("#ai-model").val(),v=$("#ai-api-key").val(),w=$("#ai-endpoint").val();if(!b&&_.defaultModel&&(b=_.defaultModel,$("#ai-model").val(b),storeModelName(g,b)),"custom"===g&&!w||"seqera"!==g&&!b||!v){const e=$("#ai_endpoint_group").find("input"),t=$("#ai_model_group"),o=t.find("input"),i=$("#ai_api_key_group"),n=i.find("input");if("custom"===g&&!w){const e=$("#ai_endpoint_group"),t=e.find("input"),o=e.find("label"),i=o.css("color");o.css("color","#a94442"),e.addClass("has-error"),t.one("change",function(){e.removeClass("has-error"),o.css("color",i)})}if(!b){const e=t.find("label");t.addClass("has-error");const i=e.css("color");e.css("color","#a94442"),o.focus(),o.one("change",function(){t.removeClass("has-error"),e.css("color",i)})}if(!v){const e=i.find("label");i.addClass("has-error");const t=i.find("#ai_api_key_info_required"),o=t.css("color"),s=e.css("color");t.css("color","#a94442"),e.css("color","#a94442"),n.focus(),n.one("input",function(){i.removeClass("has-error"),t.css("color",o),e.css("color",s)})}return mqc_toolbox_openclose("#mqc_ai",!0),void("custom"!==g||w?b?v||n.focus():o.focus():e.focus())}const y=Gs(b);if(f>y)return n.html(`Content exceeds the token limit of ${_.name} (${f} > ${y})`).show(),void(a&&a.show());const x="custom"===g?"custom endpoint":_.name;function q(){const t=$("#ai-endpoint").val(),o="custom"===g?t:_.name;s.find(".ai-summary-disclaimer-provider").text(o),s.find(".ai-summary-disclaimer-model").text(b),s.show(),e.data("action","clear").prop("disabled",!1).html(h).addClass("ai-local-content")}e.prop("disabled",!0).html(`Requesting ${x}...`);const k=performance.now();await(async()=>{let n="";window.runStreamGeneration({title:u+`, created on ${configCreationDate}`,systemPrompt:m,userMessage:p,tags:["multiqc"],onStreamStart:t=>{b=t,e.html("Starting generation...")},onStreamNewToken:t=>{i.show(),a&&a.show(),n+=t,i.html(window.markdownToHtml(n)),e.html("Generating...")},onStreamError:e=>{showToast("Error generating summary",e,"error"),q(_.name),!o&&t&&$("#global_ai_summary_more_button_and_disclaimer").hide(),s.hide()},onStreamComplete:a=>{q(_.name),a&&r.attr("href",`${seqeraWebsite}/ask-ai/chat/${a}`).show(),i.find('[data-bs-toggle="tooltip"]').each(function(){new bootstrap.Tooltip(this)});const l=e.data("plot-anchor")||"global";localStorage.setItem(`ai_response_${reportUuid}_${l}${o?"_more":""}`,JSON.stringify({text:n,provider:g,model:b,timestamp:Date.now(),threadId:a,endpoint:w}));const c=performance.now();console.log(`Time to generate more: ${c-k}ms`),!o&&t&&$("#global_ai_summary_more_button_and_disclaimer").show(),s.show()}})})()}(t)}function Ks(e){return Math.ceil(e.length/1.5)}window.ViolinPlot=Fs,$(function(){$("#ai-reasoning-effort").change(function(){var e;e=$(this).val(),localStorage.setItem("ai_reasoning_effort",e)}),$("#ai-max-completion-tokens").change(function(){var e;e=$(this).val(),localStorage.setItem("ai_max_completion_tokens",e.toString())}),$("#ai-thinking-budget-tokens").change(function(){var e;e=$(this).val(),localStorage.setItem("ai_thinking_budget_tokens",e.toString())}),$("#ai-extended-thinking").change(function(){var e;e=$(this).is(":checked"),localStorage.setItem("ai_extended_thinking",e.toString())});const e=Hs();$("#ai-reasoning-effort").length&&e&&$("#ai-reasoning-effort").val(e);const t=Bs();$("#ai-max-completion-tokens").length&&t&&$("#ai-max-completion-tokens").val(t);const o=Ws();$("#ai-thinking-budget-tokens").length&&o&&$("#ai-thinking-budget-tokens").val(o);const i=Us();$("#ai-extended-thinking").length&&$("#ai-extended-thinking").prop("checked",i),$("#ai-model").change(function(){const e=$(this).val(),t=Rs(e),o=t&&(e.toLowerCase().startsWith("claude-opus-4")||e.toLowerCase().startsWith("claude-sonnet-4")||e.toLowerCase().startsWith("claude-haiku-4")),i=t&&!o;$("#ai_reasoning_effort_group").toggle(i),$("#ai_max_completion_tokens_group").toggle(i),$("#ai_extended_thinking_group").toggle(o),$("#ai_thinking_budget_tokens_group").toggle(o),t&&console.log(`Reasoning model selected: ${e} (${o?"Claude extended thinking":"OpenAI reasoning"})`)});const n=$("#ai-model").val();if(n){const e=Rs(n),t=e&&(n.toLowerCase().startsWith("claude-opus-4")||n.toLowerCase().startsWith("claude-sonnet-4")||n.toLowerCase().startsWith("claude-haiku-4")),o=e&&!t;$("#ai_reasoning_effort_group").toggle(o),$("#ai_max_completion_tokens_group").toggle(o),$("#ai_extended_thinking_group").toggle(t),$("#ai_thinking_budget_tokens_group").toggle(t)}}),window.runStreamGeneration=function({onStreamStart:e,onStreamNewToken:t,onStreamError:o,onStreamComplete:i,systemPrompt:n,userMessage:s,tags:a=[],title:r=""}){const l=$("#ai-provider").val(),c=window.AI_PROVIDERS[l],d=$("#ai-model").val(),h=$("#ai-api-key").val(),p=$("#ai-endpoint").val(),m={method:"POST",headers:{"Content-Type":"application/json"}};if("Seqera AI"===c.name)h&&(m.headers.Authorization=`Bearer ${h}`),m.body=JSON.stringify({message:r+"\n\n:::details\n\n"+n+"\n\n"+s+"\n\n:::\n\n",stream:!0,tags:["multiqc",...a],title:r}),fetch(`${seqeraApiUrl}/internal-ai/query`,m).then(e=>e.ok?e.body.getReader():e.json().then(t=>{const i=`HTTP ${e.status}: ${e.statusText} ${t.error?.message||"Unknown error"}`;throw o(i),new Error(i)})).then(n=>Vs(l,n,e,t,o,i)).catch(e=>{o(zs(e))});else if("OpenAI"===c.name||"Custom"===c.name){let a={};if("Custom"===c.name)try{a=JSON.parse($("#ai-query-options").val())}catch(u){console.error("Error parsing extra query options",u)}const r=Rs(d),f=r&&(d.toLowerCase().startsWith("claude-opus-4")||d.toLowerCase().startsWith("claude-sonnet-4")||d.toLowerCase().startsWith("claude-haiku-4"));if(r&&!f){console.log(`Using OpenAI reasoning model: ${d}`);const e=[{role:"developer",content:"You are a helpful assistant expert in bioinformatics. Please provide clear, well-formatted responses using markdown where appropriate."},{role:"user",content:n+"\n\n"+s}];a={...a,model:d,messages:e,stream:!0,max_completion_tokens:Bs(),reasoning_effort:Hs()},delete a.temperature,delete a.top_p,delete a.presence_penalty,delete a.frequency_penalty,delete a.max_tokens,console.log(`OpenAI reasoning model parameters: max_completion_tokens=${a.max_completion_tokens}, reasoning_effort=${a.reasoning_effort}`)}else if(f){if(Us()){console.log(`Using Claude 4 model with extended thinking: ${d}`);const e=Ws();a={...a,model:d,messages:[{role:"user",content:n+"\n\n"+s}],stream:!0,thinking:{type:"enabled",budget_tokens:e}},console.log(`Extended thinking parameters: budget_tokens=${e}`)}else console.log(`Using Claude 4 model without extended thinking: ${d}`),a={...a,model:d,messages:[{role:"user",content:n+"\n\n"+s}],stream:!0}}else a={...a,model:d,messages:[{role:"user",content:n+"\n\n"+s}],stream:!0};m.body=JSON.stringify(a),m.headers.Authorization=`Bearer ${h}`;const g="Custom"===c.name?p:"https://api.openai.com/v1/chat/completions";fetch(g,m).then(async e=>{if(!e.ok){const t=await e.json();throw o(`HTTP ${e.status}: ${e.statusText} ${t.error?.message||"Unknown error"}`),new Error(t.error?.message||"Unknown error")}return e.body.getReader()}).then(n=>Vs(l,n,e,t,o,i)).catch(e=>{o(zs(e))})}else"Anthropic"===c.name?(m.headers={...m.headers,"x-api-key":h,"anthropic-version":"2023-06-01","anthropic-dangerous-direct-browser-access":"true"},m.body=JSON.stringify({model:d,max_tokens:4096,messages:[{role:"user",content:n+"\n\n"+s}],stream:!0}),fetch("https://api.anthropic.com/v1/messages",m).then(async e=>{if(!e.ok){const t=await e.json(),i=t.error?`${t.error.type}: ${t.error.message}`:"Unknown error";throw o(`HTTP ${e.status}: ${e.statusText} ${i}`),new Error(i)}return e.body.getReader()}).then(n=>Vs(l,n,e,t,o,i)).catch(e=>{o(zs(e))})):o(`Unsupported AI provider: ${c.name}`)},window.markdownToHtml=function(e){if(!e)return"";e=(e=(e=function(e){if(!aiPseudonymMap)return e;if(!getStoredSampleAnonymizationEnabled())return e;const t=Object.fromEntries(Object.entries(aiPseudonymMap).map(([e,t])=>[t,e]));for(const[o,i]of Object.entries(t))e=(e=e.replace(new RegExp(`:sample\\[${Ys(o)}\\](\\{[^}]+\\})`,"g"),`:sample[${i}]$1`)).replace(new RegExp(`\\b${Ys(o)}\\b`,"g"),i);return e}(e)).replace(/:span\[([^\]]+?)\]\{\.text-(green|red|yellow)\}/g,(e,t,o)=>`${t}`)).replace(/:sample\[([^\]]+?)\]\{\.text-(green|red|yellow)\}/g,(e,t,o)=>`${t}`);try{return new showdown.Converter({literalMidWordUnderscores:!0}).makeHtml(e)}catch(t){return e}},window.multiqcDescription='You are an expert in bioinformatics, sequencing technologies, genomics data analysis, and adjacent fields.\n\nYou are given findings from a MultiQC report, generated by a bioinformatics workflow.\nMultiQC supports various bioinformatics tools that output QC metrics, and aggregates those metrics\ninto a single report. It outputs a "General Statistics" table with key metrics for each sample across\nall tools. That table is followed by more detailed sections from specific tools, that can include tables,\nas well as plots of different types (bar plot, line plot, scatter plot, heatmap, etc.)\n',window.systemPromptReport=window.multiqcDescription+"\nYou are given data from such a report. Your task is to analyse this data and\ngenerate an overall summary for the results.\n\nPlease don't print any introductory words, just get to the point.\nYou task is to just generate a concise summary of the report, nothing else.\nDon't waste words: mention only the important QC issues. If there are no issues, just say so.\nTry to format the response with bullet points. Please do not add any extra headers to the response.\n\nUse markdown to format your reponse for readability. Use directives with pre-defined classes\n.text-green, .text-red, and .text-yellow to highlight severity, e.g. :span[39.2%]{.text-red}.\nIf there are any sample names mentioned, or sample name prefixes or suffixes, you must warp them in\na sample directive, making sure to use same color classes as for severity, for example: :sample[A1001.2003]{.text-yellow}\nor :sample[A1001]{.text-yellow}. But never put multiple sample names inside one directive.\n\nYou must use only multiples of 4 spaces to indent nested lists.\n",window.systemPromptReportShort=window.systemPromptReport+"\nLimit the response to 1-2 bullet points. Two such examples of short summaries:\n\n- :span[11/13 samples]{.text-green} show consistent metrics within expected ranges.\n- :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} exhibit extremely high percentage of :span[duplicates]{.text-red} (:span[65.54%]{.text-red} and :span[83.14%]{.text-red}, respectively).\n\n- All samples show good quality metrics with :span[75.7-77.0%]{.text-green} CpG methylation and :span[76.3-86.0%]{.text-green} alignment rates\n- :sample[2wk]{.text-yellow} samples show slightly higher duplication (:span[11-15%]{.text-yellow}) compared to :sample[1wk]{.text-green} samples (:span[6-9%]{.text-green})'\n",window.systemPromptReportFull=window.systemPromptReport+"\nFollow up with recommendations for the next steps.\n\nThis is the example response:\n\n##### Analysis\n\n- :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.\n- :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} show severe quality issues:\n - Extremely high duplicate rates (:span[65.54%]{.text-red} and :span[83.14%]{.text-red})\n - Low percentages of valid pairs (:span[37.2%]{.text-red} and :span[39.2%]{.text-red})\n - High percentages of failed modules in FastQC (:span[33.33%]{.text-red})\n - Significantly higher total sequence counts (:span[141.9M]{.text-red} and :span[178.0M]{.text-red}) compared to other samples\n - FastQC results indicate that :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} have a slight :span[GC content]{.text-red} bias at 39.5% against most other samples having 38.0%, which indicates a potential contamination that could be the source of other anomalies in quality metrics.\n\n- :sample[A1002-1007]{.text-yellow} shows some quality concerns:\n - Low percentage of valid pairs (:span[48.08%]{.text-yellow})\n - Low percentage of passed Di-Tags (:span[22.51%]{.text-yellow})\n\n- 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).\n- 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.\n\n##### Recommendations\n\n- Remove :sample[A1001.2003]{.text-red} and :sample[A1200.2004]{.text-red} from further analysis due to severe quality issues.\n- 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.\n- Perform adapter trimming on all samples, particularly focusing on :sample[A1001]{.text-red} group.\n- Re-run the Hi-C analysis pipeline after removing problematic samples and performing adapter trimming.\n- Investigate the cause of higher duplication rates in :sample[A1002]{.text-yellow} group compared to :sample[A1003]{.text-green} group, although they are still within acceptable ranges.\n- Consider adjusting the Hi-C protocol or library preparation steps to improve the percentage of valid pairs, especially for :sample[A1002]{.text-yellow} group.\n",window.systemPromptPlot=window.multiqcDescription+"\nYou are given a single MultiQC report section with a plot or a table.\nYour task is to analyse the data and give a very short and concise overall summary of the results.\nDon't waste words: mention only the important QC issues. If there are no issues, just say so.\nLimit it to 1-2 sentences.\n\nMake sure to use markdown to format your reponse for readability. Use directives with pre-defined classes\n.text-green, .text-red, and .text-yellow to highlight severity, e.g. :span[39.2%]{.text-red}.\nIf there are any sample names mentioned, or sample name prefixes or suffixes, you must warp them in\na sample directive, making sure to use same color classes as for severity, for example: :sample[A1001.2003]{.text-yellow}\nor :sample[A1001]{.text-yellow}. But never put multiple sample names inside one directive.\n\nPlease do not add any extra headers to the response.\n\nMake sure to use a multiple of 4 spaces to indent nested lists.",window.continueInSeqeraChatHandler=function(e){let t=$(e.currentTarget),o=t.data("seqera-website"),i=t.data("thread-id"),n=o+"/ask-ai/";i&&(n+="/chat/"+i),window.open(n,"_blank")},$(function(){function e(e,t){e.preventDefault();const o=$(e.currentTarget),{wholeReport:i,table:n}=t,s=o.data("section-anchor"),a=o.data("module-anchor"),r=o.data("plot-view");let l,c;i?(c="You are given data of a MultiQC report",l=Qs()):"general_stats_table"===s?(c="You are given the general statistics report table",l=Qs(0,!0,r)):n?(c="You are given a single MultiQC report table",l=Js(s,a,r)):(c="You are given data of a single MultiQC report section with a plot",l=Js(s,a,r)),c+=". Your task is to analyse the data and give a concise summary.";const d=window.multiqcDescription+"\n"+c+"\n\n"+l;navigator.clipboard.writeText(d);const h=o.find(".button-text").text();o.find(".button-text").text("Copied!"),setTimeout(()=>{o.find(".button-text").text(h)},2e3)}$("#global_ai_summary_expand").each(function(){const e=$("#global_ai_summary_detailed_analysis_response"),t=e.hasClass("ai-local-content"),o=$("#global_ai_summary_expand"),i=o.find("svg"),n=o.find("span");let s=e.is(":visible");const a=localStorage.getItem("mqc_ai_global_summary_expanded");"expanded"===a&&(s=!0),"collapsed"===a&&(s=!1),s&&!t?(e.show(),i.css("transform","rotate(180deg)"),n.text("Hide full summary")):(e.hide(),i.css("transform","rotate(0deg)"),n.text("View full summary")),o.on("click",t=>{t.preventDefault(),s=!s,s?(e.show(),i.css("transform","rotate(180deg)"),n.text("Hide full summary")):(e.hide(),i.css("transform","rotate(0deg)"),n.text("View full summary")),localStorage.setItem("mqc_ai_global_summary_expanded",s?"expanded":"collapsed")}),e.on("click",function(e){e.preventDefault()})}),$("button.ai-generate-button").each(function(){const e=$(this),t=e.html();e.data("original-html",t).removeClass("ai-local-content");const o=e.data("clear-text"),i=e.hasClass("ai-generate-button-more"),n=e.data("action"),s=$("#"+e.data("response-div")),a=$("#"+e.data("disclaimer-div")),r=$("#"+e.data("wrapper-div")),l=$("#"+e.data("continue-in-chat-button"));if(r&&r.addClass("ai-local-content"),"clear"===n)e.html(o).addClass("ai-local-content");else{const t=e.data("plot-anchor")||"global",n=localStorage.getItem(`ai_response_${reportUuid}_${t}${i?"_more":""}`);if(n){const t=JSON.parse(n);s.show().html(window.markdownToHtml(t.text)),r&&r.show();const i=window.AI_PROVIDERS[t.provider];a.find(".ai-summary-disclaimer-provider").text("Custom"==i.name?t.endpoint:i.name),a.find(".ai-summary-disclaimer-model").text(t.model),a.show(),e.html(o).data("action","clear").prop("disabled",!1).addClass("ai-local-content");const c=t.threadId;c&&(l.attr("href",`${seqeraWebsite}/ask-ai/chat/${c}`),l.show())}}e.on("click",Xs)}),$(document).on("click","sample",function(e){e.preventDefault();let t=$(this).text();t.includes("*")&&(t=t.replace(/\*/g,".*"),$(".mqc_regex_mode input").prop("checked",!0));let o=$(this).css("color");window.mqc_highlight_f_texts.includes(t)?($("#mqc_col_filters li").each(function(){$(this).children("input").attr("value")===t&&$(this).children(".close").click()}),$(this).css("font-weight","normal"),$("sample").each(function(){$(this).text().indexOf(t)>-1&&$(this).css("font-weight","normal")})):($("#mqc_colour_filter").val(t),$("#mqc_colour_filter_color").val(rgbToHex(o)),$(this).css("font-weight","bold"),$("sample").each(function(){$(this).text().indexOf(t)>-1&&$(this).css("font-weight","bold")})),$("#mqc_color_form").trigger("submit"),$("#mqc_cols_apply").click()}),$("button.ai-copy-content-report").click(t=>e(t,{wholeReport:!0,table:!1})),$("button.ai-copy-content-plot").click(t=>e(t,{wholeReport:!1,table:!1})),$("button.ai-copy-content-table").click(t=>e(t,{wholeReport:!1,table:!0}))}),$(function(){decompressPlotData(mqc_compressed_plotdata,(e,t)=>{t?console.error(t):window.callAfterDecompressed.forEach(function(t){t(e)})})}),window.bootstrap=bs; diff --git a/multiqc/templates/default/content.html b/multiqc/templates/default/content.html index f4733847f3..ca22431f2a 100644 --- a/multiqc/templates/default/content.html +++ b/multiqc/templates/default/content.html @@ -6,6 +6,8 @@ the output from each module and print it in sections. #} +{# prettier-ignore-start #} +{# Prettier doesn't like the conditional in this, so just skip the loop logic (keep the inner). #} {% for m in report.modules %} {% if not m.hidden %} @@ -14,8 +16,8 @@ {% for s in m.sections %} {% if loop.first %}
-
-

{{ m.name }}

+
+

{{ m.name }}

{% if m.versions %}
{% for tool, versions in m.versions.items() %} @@ -41,65 +43,86 @@

{{ m.name }}

{{ m.intro if m.intro }} {% if m['comment'] %}
{{ m['comment'] }}
{% endif %} {% endif %} - {% if s['print_section'] %} -
-

- {% if s['name'] is not none and s['name'] | length > 0 %} - {{ s['name'] }} - {% endif %} - {% if s['helptext'] is not none and s['helptext'] | length > 0 %} - - {% endif %} -

- {% if s['description'] is not none and s['description'] | length > 0 %}
{{ s['description'] }}
{% endif %} - {% if s['comment'] is not none and s['comment'] | length > 0 %}
{{ s['comment'] }}
{% endif %} - {% if s['helptext'] is not none and s['helptext'] | length > 0 %} -
-
{{ s['helptext'] }}
-
- {% endif %} - - {{ s['content_before_plot'] if s['content_before_plot'] }} - {% if s['plot'] is not none %}
{{ s['plot'] }}
{% endif %} - {{ s['content'] if s['content'] }} - - {{ '
' if not loop.last }} -
- {% endif %} + {# prettier-ignore-end #} +{% if s['print_section'] %} +
+

+ {% if s['name'] is not none and s['name'] | length > 0 %} + {{ s['name'] }} + {% endif %} + {% if s['status_bar_html'] is not none and s['status_bar_html'] | length > 0 %} + {{ s['status_bar_html'] }} + {% endif %} + {% if s['helptext'] is not none and s['helptext'] | length > 0 %} + + {% endif %} +

+ {% if s['description'] is not none and s['description'] | length > 0 %} +
{{ s['description'] }}
+ {% endif %} + {% if s['comment'] is not none and s['comment'] | length > 0 %} +
{{ s['comment'] }}
+ {% endif %} + {% if s['helptext'] is not none and s['helptext'] | length > 0 %} +
+
{{ s['helptext'] }}
+
+ {% endif %} + + {{ s['content_before_plot'] if s['content_before_plot'] }} + {% if s['plot'] is not none %}
{{ s['plot'] }}
{% endif %} + {{ s['content'] if s['content'] }} +
+{% endif %} +{# prettier-ignore-start #} {% if loop.first %}
{% endif %} {% endfor %}
- {{ '
' if not loop.last }} {% endif %} {% endif %} {% endfor %} + +{# prettier-ignore-end #} diff --git a/multiqc/templates/default/foot.html b/multiqc/templates/default/foot.html index bf17e7a559..52a15321dd 100644 --- a/multiqc/templates/default/foot.html +++ b/multiqc/templates/default/foot.html @@ -7,14 +7,13 @@ #} - - -
+ + + `); + } + + // Download DOIs + $(".download-citations-btn").click(function (e) { + e.preventDefault(); + var format = $(this).data("format"); + // Get BibTeX + if (format == "bibtex") { + var bibtex_string = ""; + // Kick off crossref api calls + var ajax_promises = []; + for (var doi in doi_list) { + ajax_promises.push( + $.get("https://api.crossref.org/works/" + doi + "/transform/application/x-bibtex", function (data) { + bibtex_string += data + "\n"; + }), + ); + } + // Wait until all API calls are done + $.when.apply(null, ajax_promises).then(function () { + var blob = new Blob([bibtex_string], { type: "text/plain;charset=utf-8" }); + saveAs(blob, "multiqc_references.bib"); + }); + } + // Download list of DOIs + else { + var doi_string = ""; + for (var doi in doi_list) { + doi_string += doi + new Array(50 - doi.length).join(" ") + " # " + doi_list[doi] + "\n"; + } + var blob = new Blob([doi_string], { type: "text/plain;charset=utf-8" }); + saveAs(blob, "multiqc_dois.txt"); + } + }); +}; diff --git a/multiqc/templates/default/src/js/toolbox/constants.js b/multiqc/templates/default/src/js/toolbox/constants.js new file mode 100644 index 0000000000..2199d2c50e --- /dev/null +++ b/multiqc/templates/default/src/js/toolbox/constants.js @@ -0,0 +1,60 @@ +//////////////////////////////////////////////// +// MultiQC Report Toolbox Constants +//////////////////////////////////////////////// + +// Make constants available globally +window.mqc_colours = [ + "#e41a1c", + "#377eb8", + "#4daf4a", + "#984ea3", + "#ff7f00", + "#a9a904", + "#a65628", + "#f781bf", + "#999999", +]; +window.zip_threshold = 8; + +// Add these constants at the top of the file +window.AI_PROVIDERS = { + seqera: { + name: "Seqera AI", + apiKeysUrl: "https://cloud.seqera.io/tokens", + }, + anthropic: { + name: "Anthropic", + defaultModel: "claude-3-5-sonnet-latest", + suggestedModels: ["claude-3-5-sonnet-latest", "claude-3-5-haiku-latest"], + apiKeysUrl: "https://console.anthropic.com/settings/keys", + modelsUrl: "https://docs.anthropic.com/en/docs/intro-to-claude#model-options", + }, + openai: { + name: "OpenAI", + defaultModel: "gpt-4o", + suggestedModels: ["gpt-4o", "gpt-4o-mini"], + apiKeysUrl: "https://platform.openai.com/api-keys", + modelsUrl: "https://platform.openai.com/docs/models", + }, + aws_bedrock: { + name: "AWS Bedrock", + modelsUrl: "https://docs.anthropic.com/en/docs/intro-to-claude#model-options", + }, + custom: { + name: "Custom", + defaultModel: "", + }, + clipboard: { + name: "Copy prompts", + }, + none: { + name: "Remove AI buttons", + }, +}; + +window.AI_PROVIDER_GROUPS = { + "In-report summaries": ["seqera", "anthropic", "openai", "custom"], + Alternatives: ["clipboard", "none"], +}; + +window.AUTO_SAVE_PREFIX = "autosave_"; diff --git a/multiqc/templates/default/src/js/toolbox/export.js b/multiqc/templates/default/src/js/toolbox/export.js new file mode 100644 index 0000000000..13f8de8667 --- /dev/null +++ b/multiqc/templates/default/src/js/toolbox/export.js @@ -0,0 +1,209 @@ +//////////////////////////////////////////////// +// MultiQC Report Toolbox - Export Functionality +//////////////////////////////////////////////// + +// Make functions available globally +window.initExport = function () { + // Change text on download button + $('#mqc_exportplots a[data-bs-toggle="tab"]').on("shown.bs.tab", function (e) { + if ($(e.target).attr("href") === "#mqc_data_download") { + $("#mqc-dl-plot-txt").text("Data"); + } else { + $("#mqc-dl-plot-txt").text("Images"); + } + }); + + // Load the plot exporter + if ($(".hc-plot").length > 0) { + $(".hc-plot").each(function () { + var fname = $(this).attr("id"); + $("#mqc_export_selectplots").append( + `
+ + +
`, + ); + }); + + // Select all / none for checkboxes + $("#mqc_export_sall").click(function (e) { + e.preventDefault(); + $("#mqc_export_selectplots input").prop("checked", true); + }); + $("#mqc_export_snone").click(function (e) { + e.preventDefault(); + $("#mqc_export_selectplots input").prop("checked", false); + }); + + // Aspect ratio fixed + var mqc_exp_aspect_ratio = $("#mqc_exp_width").val() / $("#mqc_exp_height").val(); + $("#mqc_export_aspratio").change(function () { + if ($(this).is(":checked")) { + mqc_exp_aspect_ratio = $("#mqc_exp_width").val() / $("#mqc_exp_height").val(); + } + }); + $("#mqc_exp_width").keyup(function () { + if ($("#mqc_export_aspratio").is(":checked")) { + $("#mqc_exp_height").val($(this).val() / mqc_exp_aspect_ratio); + } + }); + $("#mqc_exp_height").keyup(function () { + if ($("#mqc_export_aspratio").is(":checked")) { + $("#mqc_exp_width").val($(this).val() * mqc_exp_aspect_ratio); + } + }); + + // Export the plots + $("#mqc_exportplots").submit(function (e) { + e.preventDefault(); + let checked_plots = $("#mqc_export_selectplots input:checked"); + let zip = new JSZip(); + let promises = []; + ////// + // EXPORT PLOT IMAGES + ////// + if ($("#mqc_image_download").is(":visible")) { + let mime = $("#mqc_export_ft").val(); + let format = mime.replace("image/", "").split("+")[0]; + let f_width = parseInt($("#mqc_exp_width").val()); + let f_height = parseInt($("#mqc_exp_height").val()); + const font_scale = parseFloat($("#mqc_export_scaling").val()); + checked_plots.each(function () { + const target = $(this).val(); + + promises.push( + Plotly.toImage(target, { + format: format, + width: f_width / font_scale, + height: f_height / font_scale, + scale: font_scale, + }).then(function (img) { + if (format === "svg") { + Plotly.Snapshot.downloadImage(target, { + format: format, + width: f_width / font_scale, + height: f_height / font_scale, + scale: font_scale, + filename: target, + }); + // if (checked_plots.length <= zip_threshold) { + // // Not many plots to export, just trigger a download for each + // const data = img.replace(/^data:image\/svg\+xml;base64,/, ""); + // const blob = new Blob([data], { type: mime }); + // saveAs(blob, target + "." + format); + // } else { + // // Lots of plots - add to a zip file for download + // const fname = target + "." + format; + // zip.file(fname, img, { base64: false }); + // } + } else { + // Can add logo to a PNG image + addLogo(img, function (imageWithLogo) { + if (checked_plots.length <= zip_threshold) { + // Not many plots to export, just trigger a download for each:" + const blob = dataUrlToBlob(imageWithLogo, mime); + saveAs(blob, target + "." + format); + } else { + // Lots of plots - add to a zip file for download: + const fname = target + "." + format; + const data = imageWithLogo.replace(/^data:image\/png;base64,/, ""); + zip.file(fname, data, { base64: true }); + } + }); + } + }), + ); + }); + if (checked_plots.length > zip_threshold) { + // Wait for all promises to resolve + Promise.all(promises).then(() => { + zip.generateAsync({ type: "blob" }).then(function (content) { + saveAs(content, "multiqc_plots.zip"); + }); + }); + } + } + ////// + // EXPORT PLOT DATA + ////// + else if ($("#mqc_data_download").is(":visible")) { + const format = $("#mqc_export_data_ft").val(); + console.log("Exporting data in " + format + " format"); + let skipped_plots = 0; + checked_plots.each(function () { + try { + const target = $(this).val(); + const fname = target + "." + format; + // If JSON then just dump everything + if (format === "json") { + const json_str = JSON.stringify(mqc_plots[target], null, 2); + const blob = new Blob([json_str], { type: "text/plain;charset=utf-8" }); + if (checked_plots.length <= zip_threshold) { + // Not many plots to export, just trigger a download for each + saveAs(blob, fname); + } else { + // Lots of plots - add to a zip file for download + zip.file(fname, blob); + } + } else if (format === "tsv" || format === "csv") { + let plot = mqc_plots[target]; + if (plot !== undefined) { + let text = plot.exportData(format); + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + if (checked_plots.length <= zip_threshold) { + // Not many plots to export, just trigger a download for each + saveAs(blob, fname); + } else { + // Lots of plots - generate a zip file for download. + // Add to a zip archive + zip.file(fname, blob); + } + } else { + skipped_plots += 1; + } + } else { + skipped_plots += 1; + } + } catch (e) { + console.error(e); + skipped_plots += 1; + } + }); + if (skipped_plots > 0) { + alert("Warning: Could not export data from " + skipped_plots + " plots."); + } + // Save the zip and trigger a download + if (checked_plots.length > zip_threshold) { + zip.generateAsync({ type: "blob" }).then(function (content) { + saveAs(content, "multiqc_data.zip"); + }); + } + } else { + alert("Error - don't know what to export!"); + } + }); + } else { + $("#mqc_exportplots").hide(); + $(".mqc-toolbox-buttons a[href=#mqc_exportplots]").parent().hide(); + } + + // Export plot buttons + $(".export-plot").click(function (e) { + e.preventDefault(); + // Get the id of the span element that was clicked + let id = e.target.dataset.plotAnchor; + let isTable = e.target.dataset.type === "table"; + // Tick only this plot in the toolbox and slide out + $("#mqc_export_selectplots input").prop("checked", false); + $('#mqc_export_selectplots input[value="' + id + '"]').prop("checked", true); + // Special case - Table scatter plots are in a modal, need to close this first + if (id === "table_scatter_plot") { + $("#table_scatter_modal").modal("hide"); + } + mqc_toolbox_openclose( + "#mqc_exportplots", + true, + isTable, // no image export for table, go directly to data download + ); + }); +}; diff --git a/multiqc/templates/default/src/js/toolbox/filters.js b/multiqc/templates/default/src/js/toolbox/filters.js new file mode 100644 index 0000000000..022fb23754 --- /dev/null +++ b/multiqc/templates/default/src/js/toolbox/filters.js @@ -0,0 +1,72 @@ +//////////////////////////////////////////////// +// MultiQC Report Common Filter Handling +// +// This file contains common code for handling filter rows across different +// toolbox sections (highlights, rename samples, hide samples). The filter +// functionality includes adding/removing filter rows, handling input events, +// and managing regex mode toggles. +//////////////////////////////////////////////// + +// Make function available globally +window.initFilters = function () { + // Filter text is changed + $(".mqc_filters").on("blur", "li input", function () { + var target = $(this).parent().parent().attr("id"); + if (target == "mqc_col_filters") { + $("#mqc_cols_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + } + if (target == "mqc_renamesamples_filters") { + $("#mqc_renamesamples_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + } + if (target == "mqc_hidesamples_filters") { + $("#mqc_hidesamples_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + } + }); + + // 'Enter' key pressed whilst editing a filter + $(".mqc_filters").on("keyup", "li input", function (e) { + if (e.keyCode == 13) { + // Pressed enter + $(this).blur(); + $(this).parent().next("li").find("input").focus().select(); + } + }); + + // Remove filter button + $(".mqc_filters").on("click", "li button", function () { + var target = $(this).parent().parent().attr("id"); + $(this).parent().remove(); + if (target == "mqc_col_filters") { + $("#mqc_cols_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + } + if (target == "mqc_hidesamples_filters") { + $("#mqc_hidesamples_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + } + if (target == "mqc_renamesamples_filters") { + $("#mqc_renamesamples_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + } + }); + + // Clear all filters button + $(".mqc_toolbox_clear").click(function () { + var target = $(this).closest(".mqc_filter_section").find(".mqc_filters").attr("id"); + $("#" + target).empty(); + if (target == "mqc_col_filters") { + $("#mqc_cols_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + } + if (target == "mqc_hidesamples_filters") { + $("#mqc_hidesamples_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + } + if (target == "mqc_renamesamples_filters") { + $("#mqc_renamesamples_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + } + }); + + // Regex mode text + $(".mqc_regex_mode input").on("change", function () { + $(`#${$(this).data("bs-target")}_apply`) + .attr("disabled", false) + .removeClass("btn-default") + .addClass("btn-primary"); + }); +}; diff --git a/multiqc/templates/default/src/js/toolbox/help.js b/multiqc/templates/default/src/js/toolbox/help.js new file mode 100644 index 0000000000..0dcebfd827 --- /dev/null +++ b/multiqc/templates/default/src/js/toolbox/help.js @@ -0,0 +1,46 @@ +//////////////////////////////////////////////// +// MultiQC Report Help Modal Functionality +//////////////////////////////////////////////// + +// Make function available globally +window.initHelp = function () { + ///////////////////////// + // REGEX HELP MODAL + ///////////////////////// + $(".regex_example_buttons button").click(function (e) { + e.preventDefault(); + $(".regex_example_demo input").val($(this).data("example")); + regex_example_test(); + }); + $(".regex_example_demo input").keyup(function (e) { + regex_example_test(); + }); + + function regex_example_test() { + var re = $(".regex_example_demo input").val(); + console.log("Testing " + re); + $(".regex_example_demo pre span").each(function () { + // Remove any existing highlighting + $(this).removeClass(); + $(this).find("hl").contents().unwrap(); + + var text = $(this).text(); + var match = text.match(re); + if (match) { + console.log("Matches " + text); + var matchStart = text.indexOf(match[0]); + var beforeMatch = text.substring(0, matchStart); + var matchText = match[0]; + var afterMatch = text.substring(matchStart + matchText.length); + $(this).html( + $("").text(beforeMatch).addClass("text-muted").prop("outerHTML") + + $("").text(matchText).addClass("mark text-success").prop("outerHTML") + + $("").text(afterMatch).addClass("text-muted").prop("outerHTML"), + ); + } else { + console.log("Matches " + text); + $(this).addClass("text-muted"); + } + }); + } +}; diff --git a/multiqc/templates/default/src/js/toolbox/hide.js b/multiqc/templates/default/src/js/toolbox/hide.js new file mode 100644 index 0000000000..5a5aa327c3 --- /dev/null +++ b/multiqc/templates/default/src/js/toolbox/hide.js @@ -0,0 +1,155 @@ +//////////////////////////////////////////////// +// MultiQC Report Toolbox - Hide Samples +//////////////////////////////////////////////// + +// Make functions available globally +window.apply_mqc_hidesamples = function (mode) { + // Collect the filters into an array + if (mode === undefined) { + mode = $(".mqc_hidesamples_showhide:checked").val() === "show" ? "show" : "hide"; + } + let regex_mode = $("#mqc_hidesamples .mqc_regex_mode input").prop("checked"); + let f_texts = []; + let num_errors = 0; + $("#mqc_hidesamples_filters li").each(function () { + let pattern = $(this).find(".f_text").val(); + // Validate RegExp + $(this).removeClass("bg-danger"); + if (regex_mode && !validate_regexp(pattern)) { + $(this).addClass("bg-danger"); + num_errors++; + } + f_texts.push(pattern); + }); + if (num_errors > 0) { + return false; + } + + // If something was hidden, highlight the toolbox icon + if (f_texts.length > 0) { + $('.mqc-toolbox-buttons a[href="#mqc_hidesamples"]').addClass("in_use"); + } else { + $('.mqc-toolbox-buttons a[href="#mqc_hidesamples"]').removeClass("in_use"); + } + + window.mqc_hide_mode = mode; + window.mqc_hide_f_texts = f_texts; + window.mqc_hide_regex_mode = regex_mode; + + // Fire off a custom jQuery event for other javascript chunks to tie into + $(document).trigger("mqc_hidesamples", [f_texts, regex_mode]); + + return true; +}; + +window.initHideSamples = function () { + // Initialize hide samples functionality + + // Show/hide buttons + $(".mqc_hide_switches").click(function (e) { + e.preventDefault(); + if ($(this).hasClass("active")) { + return false; + } + $("#mqc_hide_switches button").removeClass("active"); + $(this).addClass("active"); + + // Clear previous show/hide group + $("#mqc_hidesamples_filters").empty(); + + // Get requested pattern and whether to show or hide the pattern + var j = $(this).data("index"); + var pattern = window.mqc_config["show_hide_patterns"][j]; + var show_hide_mode = window.mqc_config["show_hide_mode"][j]; + var regex = window.mqc_config["show_hide_regex"][j]; + if (!Array.isArray(pattern)) { + pattern = [pattern]; + } + if (show_hide_mode === undefined) { + show_hide_mode = "show"; + } + + // Set the regex checkbox if we want it turned on/off + var checkbox = document.getElementById("re_mode_mqc_hidesamples"); + if (checkbox) { + if (checkbox.checked && !regex) { + checkbox.click(); + } + if (!checkbox.checked && regex) { + checkbox.click(); + } + } + + // Apply the changes + $(".mqc_hidesamples_showhide[value=" + show_hide_mode + "]").prop("checked", true); + $(pattern).each(function (idx, val) { + $("#mqc_hidesamples_filters").append(window.make_hidesamples_filter(val)); + }); + apply_mqc_hidesamples(show_hide_mode); + }); + + // Hide sample filters + $("#mqc_hidesamples_form").submit(function (e) { + e.preventDefault(); + var f_text = $("#mqc_hidesamples_filter").val().trim(); + if (f_text.length == 0) { + alert("Error - filter text must not be blank."); + return false; + } + $("#mqc_hidesamples_filters").append(window.make_hidesamples_filter(f_text)); + $("#mqc_hidesamples_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + $("#mqc_hidesamples_filter").val(""); + }); + + $(".mqc_hidesamples_showhide").change(function (e) { + $("#mqc_hidesamples_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + }); + + $("#mqc_hidesamples_apply").click(function (e) { + if (apply_mqc_hidesamples()) { + $(this).attr("disabled", true).removeClass("btn-primary").addClass("btn-default"); + mqc_auto_save_config(); + } + }); + + // Apply pre-configured hide samples from config only if no local storage values + let has_hide_filters = $("#mqc_hidesamples_filters").children().length > 0; + if (!has_hide_filters && window.mqc_config.show_hide_patterns && window.mqc_config.show_hide_patterns.length > 0) { + // Add each pattern + for (let i = 1; i < window.mqc_config.show_hide_patterns.length; i++) { + // Skip first (Show all) + const pattern = window.mqc_config.show_hide_patterns[i]; + $("#mqc_hidesamples_filters").append(window.make_hidesamples_filter(pattern)); + } + + // Set regex mode if specified for the first non-empty pattern + for (let i = 1; i < window.mqc_config.show_hide_regex.length; i++) { + if (window.mqc_config.show_hide_regex[i]) { + $("#mqc_hidesamples .mqc_regex_mode input").prop("checked", true); + break; + } + } + + // Set show/hide mode based on the first non-empty pattern + for (let i = 1; i < window.mqc_config.show_hide_mode.length; i++) { + if (window.mqc_config.show_hide_mode[i] === "show") { + $(".mqc_hidesamples_showhide[value=show]").prop("checked", true); + break; + } + } + + // Apply the hide/show + apply_mqc_hidesamples(); + } +}; + +window.mqc_hidesamples_idx = 200; +window.make_hidesamples_filter = function (f_text) { + let row = ` +
  • + + +
  • `; + window.mqc_hidesamples_idx += 2; + return row; +}; diff --git a/multiqc/templates/default/src/js/toolbox/highlights.js b/multiqc/templates/default/src/js/toolbox/highlights.js new file mode 100644 index 0000000000..2802b97560 --- /dev/null +++ b/multiqc/templates/default/src/js/toolbox/highlights.js @@ -0,0 +1,116 @@ +//////////////////////////////////////////////// +// MultiQC Report Toolbox - Highlight Samples +//////////////////////////////////////////////// + +// Make functions available globally +window.apply_mqc_highlights = function () { + // Collect the filters into an array + var f_texts = []; + var f_cols = []; + var regex_mode = $("#mqc_cols .mqc_regex_mode input").prop("checked"); + var num_errors = 0; + + $("#mqc_col_filters li").each(function () { + var inputElement = $(this).find(".f_text"); + var pattern = inputElement.val(); + // Validate RegExp + $(this).removeClass("bg-danger"); + if (regex_mode && !validate_regexp(pattern)) { + $(this).addClass("bg-danger"); + num_errors++; + } + + // Only add pattern if it hasn't already been added + if (pattern.length > 0 && f_texts.indexOf(pattern) < 0) { + f_texts.push(pattern); + f_cols.push(inputElement.css("color")); + } else { + f_cols[f_texts.indexOf(pattern)] = inputElement.css("color"); + } + }); + if (num_errors > 0) { + return false; + } + + // Apply a 'background' highlight to remove default colouring first + // Also highlight toolbox drawer icon + if (f_texts.length > 0 && f_texts.indexOf("") < 0) { + f_texts.unshift(""); + f_cols.unshift(null); + $('.mqc-toolbox-buttons a[href="#mqc_cols"]').addClass("in_use"); + } else { + $('.mqc-toolbox-buttons a[href="#mqc_cols"]').removeClass("in_use"); + } + + window.mqc_highlight_f_texts = f_texts; + window.mqc_highlight_f_cols = f_cols; + window.mqc_highlight_regex_mode = regex_mode; + + // Fire off a custom jQuery event for other javascript chunks to tie into + $(document).trigger("mqc_highlights", [f_texts, f_cols, regex_mode]); + + return true; +}; + +// Make functions available globally +window.initHighlights = function () { + // Initialize highlight functionality + + // Highlight colour filters + $("#mqc_color_form").submit(function (e) { + e.preventDefault(); + let f_text = $("#mqc_colour_filter").val().trim(); + let f_col = $("#mqc_colour_filter_color").val().trim(); + $("#mqc_col_filters").append(window.make_colorsamples_filter(f_text, f_col)); + $("#mqc_cols_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + $("#mqc_colour_filter").val(""); + $("#mqc_colour_filter_color").val(mqc_colours[window.mqc_colours_idx % mqc_colours.length]); + }); + + $("#mqc_cols_apply").click(function (e) { + if (apply_mqc_highlights()) { + $(this).attr("disabled", true).removeClass("btn-primary").addClass("btn-default"); + mqc_auto_save_config(); + } + }); + + // Use jQuery UI to make the colour filters sortable + $("#mqc_col_filters").sortable(); + $("#mqc_col_filters").on("sortstop", function (event, ui) { + $("#mqc_cols_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + }); + + // Apply pre-configured highlight patterns from config only if no local storage values + let has_highlight_filters = $("#mqc_col_filters").children().length > 0; + if (!has_highlight_filters && mqc_config.highlight_patterns && mqc_config.highlight_patterns.length > 0) { + // Set regex mode if specified + if (mqc_config.highlight_regex) { + $("#mqc_cols .mqc_regex_mode input").prop("checked", true); + } + + // Add each pattern with its color + for (let i = 0; i < mqc_config.highlight_patterns.length; i++) { + const pattern = mqc_config.highlight_patterns[i]; + let color = + mqc_config.highlight_colors && mqc_config.highlight_colors[i] ? mqc_config.highlight_colors[i] : "#e41a1c"; + + // Add to the filters list + $("#mqc_col_filters").append(window.make_colorsamples_filter(pattern, color)); + } + + // Apply the highlights + apply_mqc_highlights(); + } +}; + +window.mqc_colours_idx = 0; +window.make_colorsamples_filter = function (f_text, f_col) { + let row = ` +
  • + + + +
  • `; + window.mqc_colours_idx += 1; + return row; +}; diff --git a/multiqc/templates/default/src/js/toolbox/rename.js b/multiqc/templates/default/src/js/toolbox/rename.js new file mode 100644 index 0000000000..354e7ecc83 --- /dev/null +++ b/multiqc/templates/default/src/js/toolbox/rename.js @@ -0,0 +1,168 @@ +//////////////////////////////////////////////// +// MultiQC Report Toolbox - Rename Samples +//////////////////////////////////////////////// + +// Make functions available globally +window.apply_mqc_renamesamples = function () { + let valid_from_texts = []; + let valid_to_texts = []; + let regex_mode = $("#mqc_renamesamples .mqc_regex_mode input").prop("checked"); + let num_errors = 0; + // Collect filters + $("#mqc_renamesamples_filters > li").each(function () { + let from_text = $(this).find(".from_text").val(); + // Validate RegExp + $(this).removeClass("bg-danger"); + if (regex_mode) { + if (!validate_regexp(from_text)) { + $(this).addClass("bg-danger"); + num_errors++; + return; + } + from_text = new RegExp(from_text, "g"); + } + valid_from_texts.push(from_text); + let to_text = $(this).find(".to_text").val(); + valid_to_texts.push(to_text); + }); + if (num_errors > 0) { + return false; + } + + // If something was renamed, highlight the toolbox icon + if (valid_from_texts.length > 0) { + $('.mqc-toolbox-buttons a[href="#mqc_renamesamples"]').addClass("in_use"); + } else { + $('.mqc-toolbox-buttons a[href="#mqc_renamesamples"]').removeClass("in_use"); + } + + window.mqc_rename_f_texts = valid_from_texts; + window.mqc_rename_t_texts = valid_to_texts; + + // Fire off a custom jQuery event for other javascript chunks to tie into + $(document).trigger("mqc_renamesamples", [window.mqc_rename_f_texts, window.mqc_rename_t_texts]); + + return true; +}; + +window.initRename = function () { + // Initialize rename functionality + + // Batch sample renaming buttons + $(".mqc_sname_switches").click(function (e) { + e.preventDefault(); + if ($(this).hasClass("active")) { + return false; + } + $("#mqc_sname_switches button").removeClass("active"); + $(this).addClass("active"); + // Clear previous bulk renaming entries + $("#mqc_renamesamples_filters li").remove(); + // Build new renaming entries and apply + var j = $(this).data("index"); + if (j == 0) { + apply_mqc_renamesamples(); + } else { + for (i = 0; i < window.mqc_config["sample_names_rename"].length; i++) { + var ft = window.mqc_config["sample_names_rename"][i][0]; + var tt = window.mqc_config["sample_names_rename"][i][j]; + $("#mqc_renamesamples_filters").append(window.make_renamesamples_filter(ft, tt)); + } + apply_mqc_renamesamples(); + } + }); + + // Rename samples + $("#mqc_renamesamples_form").submit(function (event) { + event.preventDefault(); + + let mqc_renamesamples_from = $("#mqc_renamesamples_from"); + let mqc_renamesamples_to = $("#mqc_renamesamples_to"); + let from_text = mqc_renamesamples_from.val().trim(); + let to_text = mqc_renamesamples_to.val().trim(); + + if (from_text.length === 0) { + alert('Error - "From" text must not be blank.'); + return false; + } + + $("#mqc_renamesamples_filters").append(window.make_renamesamples_filter(from_text, to_text)); + $("#mqc_renamesamples_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + + // Reset form + mqc_renamesamples_from.val(""); + mqc_renamesamples_to.val(""); + mqc_renamesamples_idx += 2; + $("#mqc_renamesamples_form input:first").focus(); + }); + + $("#mqc_renamesamples_apply").click(function (e) { + if (apply_mqc_renamesamples()) { + $(this).attr("disabled", true).removeClass("btn-primary").addClass("btn-default"); + mqc_auto_save_config(); + } + }); + + // Bulk rename samples + $("#mqc_renamesamples_bulk_collapse").on("shown.bs.collapse", function () { + $("#mqc_renamesamples_bulk_form textarea").focus(); + }); + $("#mqc_renamesamples_bulk_form").submit(function (e) { + e.preventDefault(); + var raw = $(this).find("textarea").val(); + var lines = raw.match(/^.*([\n\r]+|$)/gm); + $.each(lines, function (i, l) { + var sections = l.split("\t", 2); + if (sections.length < 2) { + return true; + } + var from_text = sections[0].trim(); + var to_text = sections[1].trim(); + if (from_text.length == 0) { + return true; + } + $("#mqc_renamesamples_filters").append(window.make_renamesamples_filter(from_text, to_text)); + }); + $("#mqc_renamesamples_apply").attr("disabled", false).removeClass("btn-default").addClass("btn-primary"); + $(this).find("textarea").val(""); + $("#mqc_renamesamples_bulk_collapse").collapse("hide"); + }); + + // Apply pre-configured sample renaming patterns from config only if no local storage values + let has_rename_filters = $("#mqc_renamesamples_filters").children().length > 0; + if ( + !has_rename_filters && + window.mqc_config.sample_names_rename && + window.mqc_config.sample_names_rename.length > 0 + ) { + let mqc_renamesamples_idx = 300; + + // Add each pattern + for (let i = 0; i < window.mqc_config.sample_names_rename.length; i++) { + const pattern = window.mqc_config.sample_names_rename[i]; + if (!Array.isArray(pattern) || pattern.length < 2) continue; + + const from_text = pattern[0]; + const to_text = pattern[1]; + + // Add to the filters list + $("#mqc_renamesamples_filters").append(window.make_renamesamples_filter(from_text, to_text)); + } + + // Apply the renames + apply_mqc_renamesamples(); + } +}; + +window.mqc_renamesamples_idx = 300; +window.make_renamesamples_filter = function (ft, tt) { + let row = ` +
  • + + » + + +
  • `; + window.mqc_renamesamples_idx += 2; + return row; +}; diff --git a/multiqc/templates/default/src/js/toolbox/save-load.js b/multiqc/templates/default/src/js/toolbox/save-load.js new file mode 100644 index 0000000000..206e918296 --- /dev/null +++ b/multiqc/templates/default/src/js/toolbox/save-load.js @@ -0,0 +1,447 @@ +//////////////////////////////////////////////// +// MultiQC Report Toolbox - Save/Load Configurations +//////////////////////////////////////////////// + +// Add these helper functions +window.getConfigObject = function () { + return { + highlights_f_texts: window.mqc_highlight_f_texts, + highlights_f_cols: window.mqc_highlight_f_cols, + highlight_regex: window.mqc_highlight_regex_mode, + rename_from_texts: window.mqc_rename_f_texts, + rename_to_texts: window.mqc_rename_t_texts, + rename_regex: window.mqc_rename_regex_mode, + hidesamples_mode: window.mqc_hide_mode, + hidesamples_f_texts: window.mqc_hide_f_texts, + hidesamples_regex: window.mqc_hide_regex_mode, + }; +}; + +// Save the current configuration setup +function mqc_save_config(name, clear, as_default) { + if (name === undefined) { + return false; + } + const config = getConfigObject(); + var prev_config = {}; + // Load existing configs (inc. from other reports) + try { + try { + prev_config = localStorage.getItem("mqc_config"); + if (prev_config !== null && prev_config !== undefined) { + prev_config = JSON.parse(prev_config); + } else { + prev_config = {}; + } + + // Update config obj with current config + if (clear == true) { + delete prev_config[name]; + } else { + prev_config[name] = config; + prev_config[name]["last_updated"] = Date(); + if (as_default) { + for (var c in prev_config) { + if (prev_config.hasOwnProperty(c)) { + prev_config[c]["default"] = false; + } + } + } + prev_config[name]["default"] = as_default; + if (as_default) console.log("Set new default config!"); + } + localStorage.setItem("mqc_config", JSON.stringify(prev_config)); + } catch (e) { + console.log("Could not access localStorage"); + } + + if (clear === true) { + // Remove from load select box + $("#mqc_loadconfig_form select option:contains('" + name + "')").remove(); + // Successfully deleted message + $('

    Settings deleted.

    ') + .hide() + .insertBefore($("#mqc_loadconfig_form .actions")) + .slideDown(function () { + setTimeout(function () { + $("#mqc-cleared-success").slideUp(function () { + $(this).remove(); + }); + }, 5000); + }); + } else { + // Remove from load select box + $("#mqc_loadconfig_form select option:contains('" + name + "')").remove(); + // Add new name to load select box and select it + $("#mqc_loadconfig_form select") + .prepend(``) + .val(name + (as_default ? " [default]" : "")); + // Success message + $('

    Settings saved.

    ') + .hide() + .insertBefore($("#mqc_saveconfig_form")) + .slideDown(function () { + setTimeout(function () { + $("#mqc-save-success").slideUp(function () { + $(this).remove(); + }); + }, 5000); + }); + } + } catch (e) { + console.log("Error updating localstorage: " + e); + } +} + +// Clear current default configuration +function mqc_clear_default_config() { + try { + var config = localStorage.getItem("mqc_config"); + if (!config) { + return; + } else { + config = JSON.parse(config); + } + for (var c in config) { + if (config.hasOwnProperty(c)) { + config[c]["default"] = false; + } + } + localStorage.setItem("mqc_config", JSON.stringify(config)); + $('

    Unset default.

    ') + .hide() + .insertBefore($("#mqc_loadconfig_form .actions")) + .slideDown(function () { + setTimeout(function () { + $("#mqc-cleared-success").slideUp(function () { + $(this).remove(); + }); + }, 5000); + var name = $('#mqc_loadconfig_form select option:contains("default")').text(); + $('#mqc_loadconfig_form select option:contains("default")').remove(); + name = name.replace(" [default]", ""); + $("#mqc_loadconfig_form select").append(``).val(name); + }); + } catch (e) { + console.log("Could not access localStorage"); + } +} + +////////////////////////////////////////////////////// +// LOAD TOOLBOX SAVE NAMES +////////////////////////////////////////////////////// +function populate_mqc_saveselect() { + var default_config = ""; + try { + var local_config = localStorage.getItem("mqc_config"); + if (local_config !== null && local_config !== undefined) { + local_config = JSON.parse(local_config); + default_name = false; + for (var name in local_config) { + if (local_config[name]["default"]) { + console.log("Loaded default config!"); + load_mqc_config(name); + default_config = name; + name = name + " [default]"; + default_name = name; + } + $("#mqc_loadconfig_form select") + .append("") + .val(name); + } + // Set the selected select option + if (default_name !== false) { + $('#mqc_loadconfig_form select option:contains("' + default_name + '")').prop("selected", true); + } else { + $("#mqc_loadconfig_form select option:first").prop("selected", true); + } + } + } catch (e) { + console.log("Could not load local config: " + e); + $("#mqc_saveconfig").html( + "

    Error accessing localStorage

    " + + '

    This feature uses a web browser feature called "localStorage". ' + + "We're not able to access this at the moment, which probably means that " + + 'you have the "Block third-party cookies and site data" setting ticked (Chrome) ' + + "or equivalent in other browsers.

    Please " + + 'change this browser setting' + + " to save MultiQC report configs.

    ", + ); + } +} + +////////////////////////////////////////////////////// +// LOAD TOOLBOX SETTINGS +////////////////////////////////////////////////////// +function load_mqc_config(name, config_obj) { + if (name === undefined) { + return false; + } + var config = {}; + if (config_obj) { + // Use provided config object + config = config_obj; + } else { + // Load from localStorage + try { + var local_config = localStorage.getItem("mqc_config"); + if (local_config !== null && local_config !== undefined) { + local_config = JSON.parse(local_config); + config = local_config[name]; + } + } catch (e) { + console.log("Could not access localStorage"); + } + } + + // Apply config - rename samples + if (notEmptyObj(config["rename_regex"])) { + if (config["rename_regex"] === true) { + $("#mqc_renamesamples .mqc_regex_mode input").prop("checked", true); + window.mqc_rename_regex_mode = true; + } + } + if (notEmptyObj(config["rename_from_texts"]) && notEmptyObj(config["rename_to_texts"])) { + window.mqc_rename_f_texts = []; + window.mqc_rename_t_texts = []; + $.each(config["rename_from_texts"], function (idx, from_text) { + var to_text = config["rename_to_texts"][idx]; + if (from_text.length === 0) { + return true; + } + window.mqc_rename_f_texts.push(from_text); + window.mqc_rename_t_texts.push(to_text); + $("#mqc_renamesamples_filters").append(window.make_renamesamples_filter(from_text, to_text)); + }); + $(document).trigger("mqc_renamesamples", [ + window.mqc_rename_f_texts, + window.mqc_rename_t_texts, + config["rename_regex"], + ]); + } + + // Apply config - highlights + if (notEmptyObj(config["highlight_regex"])) { + if (config["highlight_regex"] === true) { + $("#mqc_cols .mqc_regex_mode input").prop("checked", true); + window.mqc_highlight_regex_mode = true; + } + } + if (notEmptyObj(config["highlights_f_texts"]) && notEmptyObj(config["highlights_f_cols"])) { + window.mqc_highlight_f_texts = []; + window.mqc_highlight_f_cols = []; + $.each(config["highlights_f_texts"], function (idx, f_text) { + if (f_text.length === 0) { + return true; + } + var f_col = config["highlights_f_cols"][idx]; + $("#" + hashCode(f_text + f_col)).remove(); + $("#mqc_col_filters").append(window.make_colorsamples_filter(f_text, f_col)); + window.mqc_highlight_f_texts.push(f_text); + window.mqc_highlight_f_cols.push(f_col); + }); + $("#mqc_colour_filter_color").val(mqc_colours[window.mqc_colours_idx % mqc_colours.length]); + $(document).trigger("mqc_highlights", [ + window.mqc_highlight_f_texts, + window.mqc_highlight_f_cols, + config["highlight_regex"], + ]); + } + + // Apply config - hide samples + if (notEmptyObj(config["hidesamples_regex"])) { + if (config["hidesamples_regex"] == true) { + $("#mqc_hidesamples .mqc_regex_mode input").prop("checked", true); + window.mqc_hide_regex_mode = true; + } + } + if (notEmptyObj(config["hidesamples_mode"])) { + if (config["hidesamples_mode"] == "show") { + $(".mqc_hidesamples_showhide").prop("checked", false); + $(".mqc_hidesamples_showhide[val=show]").prop("checked", true); + window.mqc_hide_mode = "show"; + } + } + if (notEmptyObj(config["hidesamples_f_texts"])) { + window.mqc_hide_f_texts = []; + $.each(config["hidesamples_f_texts"], function (idx, f_text) { + if (f_text.length === 0) { + return true; + } + $("#mqc_hidesamples_filters").append(window.make_hidesamples_filter(f_text)); + window.mqc_hide_f_texts.push(f_text); + }); + $(document).trigger("mqc_hidesamples", [window.mqc_hide_f_texts, config["hidesamples_regex"]]); + } + + // Trigger loaded event to initialise plots + $(document).trigger("mqc_config_loaded"); + return true; +} + +////////////////////////////////////////////////////// +// SAVE SETTINGS TO FILE +////////////////////////////////////////////////////// +function downloadConfigFile(name) { + const config = getConfigObject(); + const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" }); + const filename = `multiqc_config_${name || "settings"}.json`; + saveAs(blob, filename); +} + +function loadConfigFromFile(file) { + const reader = new FileReader(); + reader.onload = function (e) { + try { + const config = JSON.parse(e.target.result); + + // Validate that this config matches the current report + if (config.report_id && config.report_id !== window.reportUuid) { + if (!confirm("This configuration is from a different report. Load it anyway?")) { + return; + } + } + + // Load the config + load_mqc_config("custom_file_load", config); + + // Auto-save the newly loaded config + mqc_auto_save_config(); + + // Show success message + showToast("Configuration Loaded", "Settings have been loaded from file successfully", "success"); + } catch (err) { + showToast("Error Loading Configuration", "Could not parse the configuration file: " + err.message, "error"); + } + }; + reader.readAsText(file); +} + +////////////////////////////////////////////////////// +// SAVE SETTINGS AUTOMATICALLY +////////////////////////////////////////////////////// +window.mqc_auto_save_config = function () { + // Get the current config + var config = getConfigObject(); + config.last_updated = Date(); + + try { + // Save to localStorage with report UUID + var prev_config = {}; + try { + prev_config = localStorage.getItem("mqc_config"); + prev_config = prev_config ? JSON.parse(prev_config) : {}; + } catch (e) { + console.log("Could not access localStorage"); + } + + // Add/update the auto-saved config + const autoSaveKey = AUTO_SAVE_PREFIX + window.reportUuid; + prev_config[autoSaveKey] = config; + localStorage.setItem("mqc_config", JSON.stringify(prev_config)); + } catch (e) { + console.log("Error auto-saving config:", e); + } +}; + +// Make functions available globally +window.initSaveLoad = function () { + // Load the saved setting names + populate_mqc_saveselect(); + + // Save config + $("#mqc_saveconfig_form").submit(function (e) { + e.preventDefault(); + var name = $(this).find("input").val().trim(); + if (name == "") { + alert("Error - you must name the saved settings."); + } else { + mqc_save_config(name); + } + }); + + // Load config + $("#mqc_loadconfig_form").submit(function (e) { + e.preventDefault(); + var name = $(this).find("select").val().trim(); + if (name == "") { + alert("Error - No saved setting selected."); + } else { + if (load_mqc_config(name)) { + // Show success message + showToast("Configuration Loaded", "Settings have been loaded successfully", "success"); + } else { + showToast("Error Loading Configuration", "Could not load the configuration", "error"); + } + } + }); + + // Delete config + $(".mqc_config_clear").click(function (e) { + e.preventDefault(); + var name = $("#mqc_loadconfig_form select").val().trim(); + if (name == "") { + alert("Error - no saved settings selected."); + } else { + if (confirm("Delete saved settings '" + name + "'?")) { + mqc_save_config(name, true); + } + } + }); + + // Set current config as default + $(".mqc_config_set_default").click(function (e) { + e.preventDefault(); + var name = $("#mqc_loadconfig_form select").val().trim(); + if (name == "") { + alert("Error - no saved settings selected."); + } else { + load_mqc_config(name); + mqc_save_config(name, false, true); + } + }); + + // Clear current config default + $(".mqc_config_clear_default").click(function (e) { + e.preventDefault(); + mqc_clear_default_config(); + }); + + // Lazy load file input handler + let fileInputInitialized = false; + $("#mqc_saveconfig").on("mouseenter", function () { + if (!fileInputInitialized) { + // Initialize file input handler only when user interacts with the save section + $("#mqc_load_config_file_wrapper").append( + '', + ); + $("#mqc_load_config_file").on("change", function (e) { + if (this.files && this.files[0]) { + loadConfigFromFile(this.files[0]); + } + }); + fileInputInitialized = true; + } + }); + + // Add download handler + $("#mqc_download_config").click(function (e) { + e.preventDefault(); + const name = $("#mqc_saveconfig_form input").val().trim() || "settings"; + downloadConfigFile(name); + }); + + // Load auto-saved config on page load + try { + const autoSaveKey = AUTO_SAVE_PREFIX + window.reportUuid; + const local_config = localStorage.getItem("mqc_config"); + if (local_config) { + const configs = JSON.parse(local_config); + if (configs[autoSaveKey]) { + load_mqc_config(autoSaveKey); + } + } + } catch (e) { + console.log("Could not load auto-saved config:", e); + } +}; diff --git a/multiqc/templates/default/src/js/toolbox/utils.js b/multiqc/templates/default/src/js/toolbox/utils.js new file mode 100644 index 0000000000..948c9cb907 --- /dev/null +++ b/multiqc/templates/default/src/js/toolbox/utils.js @@ -0,0 +1,131 @@ +//////////////////////////////////////////////// +// MultiQC Report Toolbox Utility Functions +//////////////////////////////////////////////// + +// Make functions available globally +window.hashCode = function (str) { + var hash = 0; + if (str.length == 0) return hash; + for (i = 0; i < str.length; i++) { + char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return hash; +}; + +// Convert RGB color to hex format +window.rgbToHex = function (rgb) { + // Extract numbers from rgb(r, g, b) format + const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); + if (!matches) return rgb; // Return original if not RGB format + + // Convert each component to hex + function componentToHex(c) { + const hex = parseInt(c).toString(16); + return hex.length === 1 ? "0" + hex : hex; + } + + // Combine components with # prefix + return "#" + componentToHex(matches[1]) + componentToHex(matches[2]) + componentToHex(matches[3]); +}; + +window.validate_regexp = function (pattern) { + try { + new RegExp(pattern, "g"); + return true; + } catch (error) { + showToast( + "Invalid Regular Expression!", + "Apologies, your regular expression pattern is invalid: " + + pattern + + "

    " + + 'For more help and testing, try it out at regex101.com.', + "error", + ); + return false; + } +}; + +window.mqc_toolbox_confirmapply = function () { + // Check if there's anything waiting to be applied + if ($("#mqc_cols_apply").is(":enabled") && $("#mqc_cols").is(":visible")) { + showToast( + "Highlights Not Applied", + "Careful - your changes haven't been applied yet! Click the Apply button in the toolbox to set your changes.", + "warning", + ); + } + if ($("#mqc_renamesamples_apply").is(":enabled") && $("#mqc_renamesamples").is(":visible")) { + showToast( + "Rename Patterns Not Applied", + "Careful - your changes haven't been applied yet! Click the Apply button in the toolbox to set your changes.", + "warning", + ); + } + if ($("#mqc_hidesamples_apply").is(":enabled") && $("#mqc_hidesamples").is(":visible")) { + showToast( + "Hide Samples Not Applied", + "Careful - your changes haven't been applied yet! Click the Apply button in the toolbox to set your changes.", + "warning", + ); + } +}; + +// Storage helper functions +window.saveToLocalStorage = function (key, value) { + try { + localStorage.setItem(key, value); + } catch (e) { + console.warn("Failed to save to localStorage:", e); + } +}; + +window.getFromLocalStorage = function (key) { + try { + return localStorage.getItem(key); + } catch (e) { + console.warn("Failed to read from localStorage:", e); + return null; + } +}; + +window.notEmptyObj = function (obj) { + return obj !== null && obj !== undefined && obj !== ""; +}; + +window.dataUrlToBlob = function (dataUrl, mime) { + // Split the data URL at the comma + const byte_str = atob(dataUrl.split(",")[1]); + const byte_numbers = new Array(byte_str.length); + for (let i = 0; i < byte_str.length; i++) { + byte_numbers[i] = byte_str.charCodeAt(i); + } + const byte_array = new Uint8Array(byte_numbers); + return new Blob([byte_array], { type: mime }); +}; + +window.addLogo = function (imageDataUrl, callback) { + // Append "watermark" to the image + let plotlyImage = new Image(); + plotlyImage.onload = function () { + let canvas = document.createElement("canvas"); + let ctx = canvas.getContext("2d"); + + // Set canvas size to double for retina display + canvas.width = plotlyImage.width; + canvas.height = plotlyImage.height; // additional height for the text + + ctx.drawImage(plotlyImage, 0, 0, plotlyImage.width, plotlyImage.height); + + // Text properties + ctx.font = "9px system-ui"; // Set the desired font-size and type + ctx.textAlign = "right"; + ctx.fillStyle = "#9f9f9f"; // Semi-transparent black text + + ctx.fillText("Created with MultiQC", plotlyImage.width - 15, plotlyImage.height - 6); + // Callback with the combined image + callback(canvas.toDataURL()); + }; + plotlyImage.src = imageDataUrl; +}; diff --git a/multiqc/templates/default/src/scss/_colors.scss b/multiqc/templates/default/src/scss/_colors.scss new file mode 100644 index 0000000000..acf28357d9 --- /dev/null +++ b/multiqc/templates/default/src/scss/_colors.scss @@ -0,0 +1,13 @@ +// Theme colour overrides +$info: #5bc0de; + +$font-size-base: 0.85rem; + +$btn-font-size-sm: 0.8rem; +$btn-padding-y-sm: 0.2rem; +$btn-padding-x-sm: 0.45rem; + +$btn-white-space: nowrap; + +// Needed for value bars to show +$table-bg: transparent; diff --git a/multiqc/templates/default/src/scss/_variables.scss b/multiqc/templates/default/src/scss/_variables.scss new file mode 100644 index 0000000000..3cee055bc5 --- /dev/null +++ b/multiqc/templates/default/src/scss/_variables.scss @@ -0,0 +1,8 @@ +// Bootstrap variables that build on others +// These won't cascade, but can use other variables + +$code-color: $body-secondary-color; +$code-color-dark: $body-secondary-color-dark; + +// Don't use semi-transparent toast background colours +$toast-background-color: var(--#{$prefix}body-bg); diff --git a/multiqc/templates/default/src/scss/custom.scss b/multiqc/templates/default/src/scss/custom.scss new file mode 100644 index 0000000000..6b984277d5 --- /dev/null +++ b/multiqc/templates/default/src/scss/custom.scss @@ -0,0 +1,1052 @@ +/* ========================================================================== + CSS Styles for Default MultiQC Report Template + ========================================================================== */ + +/* ========================================================================== + Base & General Styles + ========================================================================== */ + +// Used for thousands-separator span in tables +.mqc_small_space { + padding: 0 1px; +} + +/* ========================================================================== + Page Layout & Structure + ========================================================================== */ + +// Main page area +.mainpage { + @include media-breakpoint-up(md) { + margin-left: 250px; + transition: margin-left 0.5s; + + &.hidden-nav { + margin-left: 0; + } + } +} + +/* ========================================================================== + Side Navigation + ========================================================================== */ + +.side-nav-wrapper { + height: 100%; + background-color: var(--#{$prefix}secondary-bg); + + @include media-breakpoint-up(md) { + position: fixed; + } +} + +.side-nav { + border-bottom: $border-width $border-style var(--#{$prefix}border-color); + + @include media-breakpoint-up(md) { + height: 100%; + width: 250px; + border-bottom: none; + border-right: $border-width $border-style var(--#{$prefix}border-color); + overflow: auto; + padding-bottom: $spacer; + transition: margin-left 0.5s; + margin-left: 0; + + &.hidden-nav { + margin-left: -250px; + } + } + + .navbar-toggler { + position: absolute; + right: 10px; + top: 15px; + } + + // Side nav title, when --title is set + .side-nav-title { + text-align: center; + font-size: $font-size-base * 1.2; + font-weight: $font-weight-normal; + border-top: $border-width $border-style var(--#{$prefix}border-color); + margin: 0; + a { + color: var(--#{$prefix}body-color); + text-decoration: none; + padding: $spacer * 0.75 0; + display: block; + } + } + + // Navigation menu + .mqc-nav { + // Border for the last nav item, only on desktop + @include media-breakpoint-up(sm) { + border-bottom: $border-width $border-style var(--#{$prefix}border-color); + } + + &, + ul { + margin: 0; + padding: 0; + list-style-type: none; + } + + li { + border-top: $border-width $border-style var(--#{$prefix}border-color); + a { + display: block; + text-decoration: none; + + &:hover, + &:active, + &:focus { + background-color: var(--#{$prefix}secondary-bg); + } + + // Section links + &.nav-l1 { + padding: $spacer * 0.6; + } + + // Subsection links + &.nav-l2 { + padding: ($spacer * 0.3) ($spacer * 0.6) ($spacer * 0.3) ($spacer * 0.9); + font-size: $font-size-base * 0.9; + color: var(--#{$prefix}secondary-emphasis); + } + } + } + .mobile-toolbox-nav { + background-color: var(--#{$prefix}tertiary-bg); + &:hover, + &:active, + &:focus { + background-color: var(--#{$prefix}secondary-bg); + } + } + } +} + +// Side navigation toggle handle +#side-nav-handle { + position: absolute; + top: 50%; + right: -14px; + height: 50px; + width: 15px; + padding-top: 14px; + background-color: var(--#{$prefix}tertiary-bg); + cursor: pointer; + svg { + color: var(--#{$prefix}tertiary-color); + transition: transform 0.6s ease; + } +} + +/* ========================================================================== + Header & Page Title + ========================================================================== */ + +// "don't show again" text button +#mqc_hide_welcome_btn { + color: inherit; + opacity: 0.4; + transition: $transition-fade; + + &:hover, + &:active, + &:focus { + opacity: 1; + } +} + +// Analysis directories - can be a huge list so limit page space +#analysis_dirs_wrapper { + max-height: 80px; + overflow: auto; + margin-bottom: 10px; +} + +/* ========================================================================== + Report Comments & Sections + ========================================================================== */ + +.mqc-section h3 { + margin-top: 1.5rem; +} +.mqc-module-section-first .mqc-section h3 { + margin-top: 0; +} + +.report_comment, +.mqc-section-comment { + border-left: 5px solid var(--#{$prefix}info); + background-color: var(--#{$prefix}info-bg-subtle); + padding: ($spacer * 0.5) $spacer; + margin: 0 0 $spacer; + font-size: $font-size-base * 1.2; +} + +// Markdown paragraphs in the description +.mqc-section-description > p { + margin-bottom: $spacer * 0.5; +} + +/* ========================================================================== + Footer + ========================================================================== */ + +// Back-to-top link +.mqc-toplink, +.mqc-toplink:visited { + position: fixed; + bottom: $spacer * 1.1; + right: $spacer * 0.8; + padding: 2px 1px; + z-index: 99999; + background-color: var(--#{$prefix}tertiary-bg); + color: var(--#{$prefix}tertiary-color); + text-decoration: none; + + span { + display: none; + padding-left: 0.5rem; + } + + &:hover { + background-color: var(--#{$prefix}secondary-bg); + color: var(--#{$prefix}body-color); + + span { + display: inline; + color: var(--#{$prefix}tertiary-color); + } + } +} + +.footer { + color: var(--#{$prefix}tertiary-color); + background-color: var(--#{$prefix}tertiary-bg); + font-size: $font-size-base * 0.9; + + // Avoid the "jump to top" button + .container-fluid { + padding-right: 3.5rem; + } + + p { + margin: 0; + a { + color: var(--#{$prefix}tertiary-color); + text-decoration: underline; + } + } + + .seqera-logo { + width: 120px; + } +} + +// Also used for the logo in the toolbox +.seqera-logo { + svg { + color: var(--#{$prefix}emphasis-color); + transition: + margin 0.3s, + opacity 0.3s; + opacity: 80%; + margin-bottom: 0; + } + + &:hover svg { + margin-bottom: 0.2rem; + opacity: 100%; + } +} + +/* ========================================================================== + Custom Scrollbar + ========================================================================== */ + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--#{$prefix}body-bg); + border-left: $border-width $border-style var(--#{$prefix}border-color); + border-right: $border-width $border-style var(--#{$prefix}border-color); +} + +::-webkit-scrollbar-thumb { + background: var(--#{$prefix}secondary-bg); + &:active { + background: var(--#{$prefix}tertiary-bg); + } +} + +/* ========================================================================== + Toolbox Menu + ========================================================================== */ + +#mqc-toolbox { + .mqc-toolbox-label { + margin-left: -5px; + transform: rotate(270deg) translateX(-20px); + } + .mqc-toolbox-buttons { + position: absolute; + left: -2.5rem; + top: 50px; + bottom: 0; + visibility: visible; + overflow-y: auto; + // Hide scrollbars if the toolbox overflows + scrollbar-width: none; + ::-webkit-scrollbar, + ::-webkit-scrollbar-track, + ::-webkit-scrollbar-thumb { + display: none !important; + } + .list-group { + width: 2.5rem; + padding-bottom: 1rem; + border-right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + .list-group-item { + background-color: var(--#{$prefix}secondary-bg); + padding: 0.5rem; + text-align: center; + margin-top: 0.5rem; + border-top-width: $border-width; + border-top-left-radius: $border-radius; + border-bottom-left-radius: $border-radius; + border-right: 0; + + &.active { + color: var(--#{$prefix}body-color); + background-color: var(--#{$prefix}body-bg); + border-color: var(--#{$prefix}secondary); + } + + &.in_use { + color: var(--#{$prefix}primary); + border-color: var(--#{$prefix}primary); + background-color: var(--#{$prefix}primary-bg-subtle); + } + + svg { + width: 20px; + height: 20px; + } + } + } + } + + // Citation blockquote + blockquote { + font-size: 0.8rem; + } +} + +/* ========================================================================== + Toolbox Filters & Sample Controls + ========================================================================== */ + +// Filter list +.mqc_filters { + margin: 10px 0; + padding: 0; + list-style-type: none; + font-size: $font-size-base * 0.9; + li { + // Drag handle for resizing filters + .hc_handle { + padding: 9px 4px; + cursor: pointer; + color: var(--#{$prefix}secondary); + } + &:hover, + &:focus, + &:has(:focus) { + background-color: var(--#{$prefix}tertiary-bg); + } + + // Filter text inputs + .f_text { + border: 0; + border-bottom: $border-width $border-style var(--#{$prefix}border-color); + padding: 5px 0 5px 10px; + margin: 0; + background-color: transparent; + outline: none; + color: inherit; + } + } +} + +// Rename samples bulk edit +#mqc_renamesamples_bulk_form textarea { + font-size: 8px; +} + +// Color input for sample highlighting +input.form-control[type="color"] { + padding: 5px; + width: 30px; + cursor: pointer; +} + +/* ========================================================================== + MultiQC Tables + ========================================================================== */ + +.table tr td { + font-size: $font-size-base * 0.9; + height: 30px; +} + +// Table container +.mqc-table-responsive { + overflow: auto; + + &.mqc-table-collapse { + max-height: 500px; + } +} + +// Table expand/collapse button +.mqc-table-expand { + text-align: center; + color: var(--#{$prefix}tertiary-color); + padding: 5px; + cursor: pointer; + background-color: var(--#{$prefix}secondary-bg); + transition: background-color 0.2s; + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: $border-radius; + + svg { + transition: transform 0.6s; + } + + &:hover, + &:focus, + &:active { + background-color: var(--#{$prefix}tertiary-bg); + } +} + +// Rotate chevron when table is expanded +.mqc-table-responsive[data-collapsed="false"] + .mqc-table-expand svg { + transform: rotate(180deg); +} + +.mqc_table_numrows_text { + padding: 5px 10px; + font-size: 12px; + vertical-align: middle; +} + +.mqc_table_control_buttons { + margin-bottom: $spacer * 0.5; +} + +// Table structure +.mqc_table { + tr, + td { + height: 100%; + } + + th { + white-space: nowrap; + } + + .rowheader { + border-left: none; + } + + .data-coloured { + padding: 0; + } + + .wrapper { + display: inline-block; + position: relative; + height: 100%; + width: 100%; + z-index: -10; + } + + // Table header styles + thead { + th, + td { + background-color: var(--#{$prefix}body-bg); + } + + th { + cursor: pointer; + // Border doesn't scroll with the CSS transform, so just a box-shadow instead. + border-bottom: 0; + box-shadow: inset 0px -2px 0px 0 var(--#{$prefix}border-color); + + .tablesorter-header-inner:after { + content: ""; + display: inline-block; + width: 0; + height: 0; + margin-left: 4px; + vertical-align: middle; + border-right: 4px solid transparent; + border-left: 4px solid transparent; + } + + &.tablesorter-headerAsc { + background-color: var(--#{$prefix}secondary-bg); + color: var(--#{$prefix}primary); + border-bottom: map-get($border-widths, 2) $border-style var(--#{$prefix}primary); + + .tablesorter-header-inner:after { + border-bottom: 4px dashed; + } + } + + &.tablesorter-headerDesc { + background-color: var(--#{$prefix}secondary-bg); + color: var(--#{$prefix}primary); + border-bottom: map-get($border-widths, 2) $border-style var(--#{$prefix}primary); + + .tablesorter-header-inner:after { + border-top: 4px dashed; + } + } + } + } + + // Table body cell values + tbody tr td .wrapper .val { + z-index: -1; + white-space: nowrap; // keep suffix stick to the value + + .label { + font-size: 100%; + display: inline-block; + min-width: 10px; + padding: 3px 7px; + font-size: 12px; + vertical-align: middle; + border-radius: 10px; + } + } + + // Background bars in cells + .bar { + display: block; + position: absolute; + top: 0; + left: 0; + bottom: 0; + background-color: var(--#{$prefix}tertiary-bg); + z-index: -1; + } + + .val { + display: block; + padding: 5px; + left: 0; + } +} + +// Sort handle column +.sorthandle { + border-right: none; + font-weight: bold; + text-align: center; +} + +tbody .sorthandle { + cursor: pointer; + color: var(--#{$prefix}tertiary-color); +} + +.mqc_config_modal_table tbody .sorthandle { + color: var(--#{$prefix}tertiary-color); +} + +.table.mqc_table > thead > tr > th { + cursor: pointer; + // Border doesn't scroll with the CSS transform, so just a box-shadow instead. + border-bottom: 0; + -webkit-box-shadow: inset 0px -2px 0px 0 var(--#{$prefix}border-color); + -moz-box-shadow: inset 0px -2px 0px 0 var(--#{$prefix}border-color); + box-shadow: inset 0px -2px 0px 0 var(--#{$prefix}border-color); +} + +// Expandable table rows +tr.expandable-row-primary { + cursor: pointer; + + .expandable-row-caret { + background: url('data:image/svg+xml;utf8,') + 50% / 24px 24px; + transform: rotate(-90deg); + } + + &.expanded .expandable-row-caret { + transform: rotate(0deg); + } +} + +tr.expandable-row-secondary { + background-color: rgba(144, 144, 144, 0.21); +} + +tr.expandable-row-secondary-hidden { + visibility: collapse; +} + +// Safari doesn't support visibility: collapse +@supports (-webkit-hyphens: none) { + tr.expandable-row-secondary-hidden { + display: none; + } +} + +// Hidden rows and columns +tr.sample-hidden, +tr.row-empty { + display: none; +} + +th.column-hidden, +td.column-hidden { + display: none; +} + +/* ========================================================================== + Plots & Visualizations + ========================================================================== */ + +// Static matplotlib plots +.mqc_mplplot { + border: $border-width $border-style var(--#{$prefix}border-color); + margin-top: 0; + width: 100%; + + img { + max-width: 100%; + height: auto; + } +} +// Fake dark-mode theme for static image plots in dark mode +[data-bs-theme="dark"] .mqc_mplplot img { + filter: invert(1) hue-rotate(180deg) brightness(1.5) contrast(0.81); +} + +// Interactive plots +.hc-plot { + height: 500px; + width: 100%; + + &.not_rendered { + background-color: var(--#{$prefix}tertiary-bg); + text-align: center; + + small { + display: inline-block; + font-style: italic; + padding-top: 40px; + color: var(--#{$prefix}tertiary-color); + } + } + + .render_plot { + margin-top: 40px; + } +} + +.hc-plot-wrapper { + width: 100%; + height: 512px; + position: relative; + border: $border-width $border-style var(--#{$prefix}border-color); + margin-top: 5px; +} + +// Draggable plot resize handle +.hc-plot-handle { + position: absolute; + width: 100%; + height: 10px; + background-color: var(--#{$prefix}tertiary-bg); + cursor: row-resize; + border-top: $border-width $border-style var(--#{$prefix}border-color); + transition: background-color 0.1s; + // two lines below are a hack to hide hanging plotly long vertical tick labels + border-bottom: map-get($border-widths, 2) $border-style var(--#{$prefix}body-bg); + bottom: -3px; + + span { + display: block; + height: 1px; + width: 20px; + margin: 1px auto; + background-color: var(--#{$prefix}tertiary-color); + } + + &:hover { + background-color: var(--#{$prefix}secondary-bg); + + span { + background-color: var(--#{$prefix}tertiary-color); + } + } +} + +// Plot controls +.mqc_hcplot_range_sliders { + display: inline-block; + + div { + display: inline-block; + white-space: nowrap; + } + + input { + display: inline-block; + width: 200px; + + &.form-control { + width: 100px; + } + } +} + +.mqc_hcplot_yaxis_limit_toggle { + float: right; + font-size: 11px; + margin-top: -26px; +} + +// Custom content images +.mqc-custom-content-image img { + max-width: 100%; +} + +// Plot annotations +g.annotation { + position: absolute !important; +} + +// Plotly watermark +.created-with-multiqc { + position: absolute; + bottom: 7px; + right: 4px; + margin-bottom: 0; + padding: 0; + color: var(--#{$prefix}tertiary-color); + font-size: 8px; + font-family: $font-family-base; +} + +/* ========================================================================== + AI Summary Components + ========================================================================== */ + +// Highlighted text (not sample IDs) +.text-green { + color: rgb(18, 106, 50); +} +.text-yellow { + color: rgb(178, 122, 3); +} +.text-red { + color: rgb(184, 31, 31); +} +[data-bs-theme="dark"] { + .text-green { + color: var(--#{$prefix}success-text-emphasis); + } + .text-yellow { + color: var(--#{$prefix}warning-text-emphasis); + } + .text-red { + color: var(--#{$prefix}danger-text-emphasis); + } +} + +.ai-summary { + li p:last-of-type { + margin-bottom: 0; + } + + .ai-summary-disclaimer { + color: var(--#{$prefix}tertiary-color); + font-size: $font-size-base * 0.9; + } + + // Animate the chevron rotation + .ai-summary-expand svg { + transition: $transition-base; + } + + // Highlighted clickable sample names in AI response + sample { + cursor: pointer; + border-bottom: 2px dashed var(--#{$prefix}secondary); + padding: 1px 3px; + transition: $transition-base; + &:hover { + opacity: 0.9; + } + // Status colors + &.text-green { + color: var(--#{$prefix}success-text-emphasis); + border-color: var(--#{$prefix}success); + background: var(--#{$prefix}success-bg-subtle); + &:hover { + background: var(--#{$prefix}success); + color: var(--#{$prefix}white); + } + } + + &.text-yellow { + color: var(--#{$prefix}warning-text-emphasis); + border-color: var(--#{$prefix}warning); + background: var(--#{$prefix}warning-bg-subtle); + &:hover { + background: var(--#{$prefix}warning); + color: var(--#{$prefix}text-dark); + } + } + + &.text-red { + color: var(--#{$prefix}danger-text-emphasis); + border-color: var(--#{$prefix}danger); + background: var(--#{$prefix}danger-bg-subtle); + &:hover { + background: var(--#{$prefix}danger); + color: var(--#{$prefix}white); + } + } + } +} + +// AI loading state +.ai-loading { + margin: 15px 0; + font-style: italic; + color: var(--#{$prefix}secondary); +} + +// AI generate button +.ai-generate-button svg { + vertical-align: baseline; +} + +/* ========================================================================== + Button Sizes + ========================================================================== */ + +.btn-xs { + padding: 2px 6px; + font-size: $font-size-base * 0.75; +} + +/* ========================================================================== + MultiQC Logo Styling + ========================================================================== */ + +// Logo in side navigation +.side-nav-logo { + text-align: center; + margin: 0; + padding: 0.25rem 0 0.5rem; + + svg { + height: 26px; + width: 100%; + } +} + +// Larger sizing for main header +#page_title .multiqc-logo-wrapper { + max-width: 320px; + height: auto; + width: auto; + + svg { + width: 100%; + height: 100%; + } +} + +// Dark mode color switching for logo text +.multiqc-logo-text { + color: #160f26; // Default dark color +} + +[data-bs-theme="dark"] .multiqc-logo-text { + color: #ffffff; // White in dark mode +} + +// Default width - can be overwritten with config.custom_logo_width +.custom_logo { + width: 320px; +} +// Custom logo theme switching (show/hide based on theme) +.custom_logo_dark { + display: none; +} + +[data-bs-theme="dark"] { + .custom_logo_light { + display: none; + } + .custom_logo_dark { + display: inline; + } +} + +/* ========================================================================== + Print Styles + ========================================================================== */ + +@media print { + .side-nav-wrapper, + .mqc-toolbox, + .mqc_table_control_buttons, + .mqc-table-expand, + .mqc-table-expand, + .mqc-section-plot .text-info, + button, + .btn { + display: none; + } + + .mainpage { + padding: 20px; + } + .footer { + background-color: transparent; + } + + // Don't limit the height of the sources + #analysis_dirs_wrapper { + max-height: none !important; + } + + // Expand long tables + .mqc-table-responsive.mqc-table-collapse { + max-height: none !important; + } + + .mqc_table thead { + transform: none !important; + } + + // keep section titles with the first bit of content, and try to keep sections together + .mqc-module-section-first, + .mqc-section { + page-break-inside: avoid; + } + + // make sure table cell contents show up + .mqc_table .wrapper { + z-index: 0; + } + + // make sure logos aren't dominantly huge + .multiqc_logo, + .custom_logo { + width: 200px; + } + + // print cell background colors in table + td, + span.bar { + -webkit-print-color-adjust: exact; + color-adjust: exact; + } + + // tidy up user-provided report header info + .dl-horizontal { + display: flex; + flex-wrap: wrap; + + dt { + flex: 0 0 34%; + float: none; + width: 300px; + font-weight: 900; // bold the key in the kv pair + } + + dd { + flex: 1 0 34%; // let the val grow to fill the space + margin-left: 0; + } + } + + // printed link text is fairly ugly + a[href]:after { + content: none !important; + } + + .table.mqc_table { + table-layout: fixed; + } + + .mqc_table th { + white-space: normal; // make sure that table header text can wrap + font-weight: 900; // and that table headers are bolded + } + + .table > thead > tr > th { + vertical-align: top; // looks better when all the table-headers are top-aligned, especially when some wrap + } +} + +/* ========================================================================== + Module Status Bars + ========================================================================== */ + +.mqc-status-progress-wrapper { + display: inline-block; + margin-left: $spacer; + width: 150px; + .progress { + cursor: pointer; + } +} + +// Popover styling +.popover-mqc-status { + .popover-body { + max-height: 300px; + overflow-y: auto; + } + + // Status-specific colors + &.popover-success { + --#{$prefix}popover-header-bg: var(--#{$prefix}success-bg-subtle); + --#{$prefix}popover-header-color: var(--#{$prefix}success-text-emphasis); + } + + &.popover-warning { + --#{$prefix}popover-header-bg: var(--#{$prefix}warning-bg-subtle); + --#{$prefix}popover-header-color: var(--#{$prefix}warning-text-emphasis); + } + + &.popover-danger { + --#{$prefix}popover-header-bg: var(--#{$prefix}danger-bg-subtle); + --#{$prefix}popover-header-color: var(--#{$prefix}danger-text-emphasis); + } +} diff --git a/multiqc/templates/default/src/scss/main.scss b/multiqc/templates/default/src/scss/main.scss new file mode 100644 index 0000000000..fbb962daaa --- /dev/null +++ b/multiqc/templates/default/src/scss/main.scss @@ -0,0 +1,66 @@ +// Main SCSS file for MultiQC default template +// This file imports Bootstrap and custom MultiQC styles + +// Custom colours +@import "_colors"; +@import "themes/dark/_colors"; + +// Bootstrap 5 - Functions and base variables +@import "../../node_modules/bootstrap/scss/functions"; +@import "../../node_modules/bootstrap/scss/variables"; +@import "../../node_modules/bootstrap/scss/variables-dark"; + +// Custom Bootstrap variable overrides for light theme +@import "_variables"; + +// Required Bootstrap parts +@import "../../node_modules/bootstrap/scss/maps"; +@import "../../node_modules/bootstrap/scss/mixins"; +@import "../../node_modules/bootstrap/scss/root"; +@import "../../node_modules/bootstrap/scss/utilities"; + +// Bootstrap - Layout & components +@import "../../node_modules/bootstrap/scss/reboot"; +@import "../../node_modules/bootstrap/scss/type"; +@import "../../node_modules/bootstrap/scss/images"; +@import "../../node_modules/bootstrap/scss/containers"; +@import "../../node_modules/bootstrap/scss/grid"; +@import "../../node_modules/bootstrap/scss/tables"; +@import "../../node_modules/bootstrap/scss/forms"; +@import "../../node_modules/bootstrap/scss/buttons"; +@import "../../node_modules/bootstrap/scss/transitions"; +@import "../../node_modules/bootstrap/scss/dropdown"; +@import "../../node_modules/bootstrap/scss/button-group"; +@import "../../node_modules/bootstrap/scss/nav"; +@import "../../node_modules/bootstrap/scss/navbar"; +@import "../../node_modules/bootstrap/scss/card"; +// @import "../../node_modules/bootstrap/scss/accordion"; +// @import "../../node_modules/bootstrap/scss/breadcrumb"; +// @import "../../node_modules/bootstrap/scss/pagination"; +@import "../../node_modules/bootstrap/scss/badge"; +@import "../../node_modules/bootstrap/scss/alert"; +@import "../../node_modules/bootstrap/scss/progress"; +@import "../../node_modules/bootstrap/scss/list-group"; +@import "../../node_modules/bootstrap/scss/close"; +@import "../../node_modules/bootstrap/scss/toasts"; +@import "../../node_modules/bootstrap/scss/modal"; +@import "../../node_modules/bootstrap/scss/tooltip"; +@import "../../node_modules/bootstrap/scss/popover"; +// @import "../../node_modules/bootstrap/scss/carousel"; +// @import "../../node_modules/bootstrap/scss/spinners"; +@import "../../node_modules/bootstrap/scss/offcanvas"; +// @import "../../node_modules/bootstrap/scss/placeholders"; + +// Bootstrap - Helpers +@import "../../node_modules/bootstrap/scss/helpers"; + +// Bootstrap - Utilities +@import "../../node_modules/bootstrap/scss/utilities/api"; + +// Custom MultiQC styles +@import "custom"; + +// Dark mode styles +[data-bs-theme="dark"] { + @import "themes/dark/custom"; +} diff --git a/multiqc/templates/default/src/scss/themes/dark/_colors.scss b/multiqc/templates/default/src/scss/themes/dark/_colors.scss new file mode 100644 index 0000000000..48f4db1c91 --- /dev/null +++ b/multiqc/templates/default/src/scss/themes/dark/_colors.scss @@ -0,0 +1,10 @@ +// Dark theme variables + +// Bootstrap dark theme background isn't very dark +$body-bg-dark: #16181a; +$body-secondary-bg-dark: #212529; +$body-tertiary-bg-dark: #343a40; + +$tooltip-color: #fff; +$tooltip-bg: #495057; +$tooltip-opacity: 1; diff --git a/multiqc/templates/default/src/scss/themes/dark/custom.scss b/multiqc/templates/default/src/scss/themes/dark/custom.scss new file mode 100644 index 0000000000..10ed3478fb --- /dev/null +++ b/multiqc/templates/default/src/scss/themes/dark/custom.scss @@ -0,0 +1,15 @@ +// Dark theme custom styles + +// Make btn-outline-secondary lighter in dark mode for better visibility +.btn-outline-secondary { + --bs-btn-color: #adb5bd; + --bs-btn-border-color: #adb5bd; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #adb5bd; + --bs-btn-hover-border-color: #adb5bd; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #adb5bd; + --bs-btn-active-border-color: #adb5bd; + --bs-btn-disabled-color: #6c757d; + --bs-btn-disabled-border-color: #6c757d; +} diff --git a/multiqc/templates/default/theme_toggle.html b/multiqc/templates/default/theme_toggle.html new file mode 100644 index 0000000000..82864fb8e9 --- /dev/null +++ b/multiqc/templates/default/theme_toggle.html @@ -0,0 +1,60 @@ +{# ####################### + theme_toggle.html +########################## + +Theme toggle dropdown component. + +#} + + + diff --git a/multiqc/templates/default/toolbox.html b/multiqc/templates/default/toolbox.html index de26bd2e2d..97695b26fb 100644 --- a/multiqc/templates/default/toolbox.html +++ b/multiqc/templates/default/toolbox.html @@ -7,367 +7,36 @@ #} -
    - - - - -
    - - - - -
    -

    - - Highlight Samples -

    - {% if report.num_flat_plots > 0 %} -

    - - This report has flat image plots that won't be highlighted.
    - See the documentation - for help. -

    - {% endif %} -
    - - - - -

    - Regex mode off - - -

    -
      -
      - - -
      -

      - - Rename Samples -

      - {% if report.num_flat_plots > 0 %} -

      - - This report has flat image plots that won't be renamed.
      - See the documentation - for help. -

      - {% endif %} -
      - - - - -

      Click here for bulk input.

      -
      -

      Paste two columns of a tab-delimited table here (eg. from Excel).

      -

      First column should be the old name, second column the new name.

      -
      - - - +
      + {% include('toolbox/buttons.html') %} +
      +
      +

      MultiQC Toolbox

      + +
      +
      + +
      + {% include('toolbox/highlight.html') %} + {% include('toolbox/rename.html') %} + {% include('toolbox/hide.html') %} + {% include('toolbox/ai.html') %} + {% include('toolbox/export.html') %} + {% include('toolbox/save.html') %} + {% include('toolbox/citations.html') %} + {% include('toolbox/about.html') %}
      -

      - Regex mode off - - -

      -
        - - -
        -

        - - Show / Hide Samples -

        - {% if report.num_flat_plots > 0 %} -

        - - This report has flat image plots that won't be hidden.
        - See the documentation - for help. -

        - {% endif %} -
        -
        - -
        -
        - -
        -
        - - -
        - - {% if report.general_stats_data | length > 10 %}

        Warning! This can take a few seconds.

        {% endif %} -

        - Regex mode off - - -

        -
          -
          - - -
          -

          Explain with AI

          - -

          Configure AI settings to get explanations of plots and data in this report.

          - -
          -
          - - - -

          -
          - - - -
          - - -

          -
          - -
          - - -

          -

          - Keys entered here will be stored in your browser's local storage. - See the docs. -

          -
          - - - - - - - -
          - -

          - Anonymize samples off -

          - -
          - - -
          -

          Export Plots

          -
          - -
          -
          -
          -
          -
          - - px -
          -
          -
          -
          - - px -
          -
          -
          -
          -
          - -
          -
          - -
          -
          -
          -
          - -
          -
          -
          - - X -
          -
          -
          -
          - -
          -

          Download the raw data used to create the plots in this report below:

          -
          -
          - -
          -
          - -
          -
          - {% if config.make_data_dir %} -

          Note that additional data was saved in {{ config.data_dir_name }} when this report was generated.

          - {% endif %} -
          -
          -
          - -
          -
          Choose Plots
          - - -
          - -
          - -

          If you use plots from MultiQC in a publication or presentation, please cite:

          -
          - MultiQC: Summarize analysis results for multiple tools and samples in a single report
          - Philip Ewels, Måns Magnusson, Sverker Lundin and Max Käller
          - Bioinformatics (2016)
          - doi: 10.1093/bioinformatics/btw354
          - PMID: 27312411 -
          - -
          - - -
          -
          - - Settings are automatically saved. You can also save named configurations below. -
          -

          Save Settings

          -

          You can save the toolbox settings for this report to the browser or as a file.

          -
          - - - - -
          - -

          Load Settings

          -

          Choose a saved report profile from the browser or load from a file:

          -
          -
          - -
          -
          - - - - -
          -
          -   Load from File - - -
          - -
          - - -
          -

          Tool Citations

          -

          Please remember to cite the tools that you use in your analysis.

          -

          To help with this, you can download publication details of the tools mentioned in this report:

          -

          -

          -
          - - -
          -

          About MultiQC

          -

          This report was generated using MultiQC, version {{ config.version }}

          -

          You can see a YouTube video describing how to use MultiQC reports here: - https://youtu.be/qPbIlO_KWN0

          -

          For more information about MultiQC, including other videos and - extensive documentation, please visit http://multiqc.info

          -

          You can report bugs, suggest improvements and find the source code for MultiQC on GitHub: - https://github.com/MultiQC/MultiQC

          -

          MultiQC is published in Bioinformatics:

          -
          - MultiQC: Summarize analysis results for multiple tools and samples in a single report
          - Philip Ewels, Måns Magnusson, Sverker Lundin and Max Käller
          - Bioinformatics (2016)
          - doi: 10.1093/bioinformatics/btw354
          - PMID: 27312411 -
          -
          - - -
          -
          -
          + + Scroll to top + {{ material_icon('mdi:chevron-up', 24) }} + diff --git a/multiqc/templates/default/toolbox/about.html b/multiqc/templates/default/toolbox/about.html new file mode 100644 index 0000000000..e835df5eff --- /dev/null +++ b/multiqc/templates/default/toolbox/about.html @@ -0,0 +1,46 @@ + +
          +
          About MultiQC
          +

          This report was generated using MultiQC, version {{ config.version }}

          + +

          MultiQC is published in Bioinformatics:

          +
          + MultiQC: Summarize analysis results for multiple tools and samples in a single report
          + Philip Ewels, Måns Magnusson, Sverker Lundin and Max Käller
          + Bioinformatics (2016)
          + doi: + 10.1093/bioinformatics/btw354
          + PMID: 27312411 +
          + +

          MultiQC is developed by Seqera.

          +

          + +

          +
          diff --git a/multiqc/templates/default/toolbox/ai.html b/multiqc/templates/default/toolbox/ai.html new file mode 100644 index 0000000000..58dc06d2b0 --- /dev/null +++ b/multiqc/templates/default/toolbox/ai.html @@ -0,0 +1,88 @@ + +
          +
          Explain with AI
          +

          Configure AI settings to get explanations of plots and data in this report.

          +
          +
          + + +
          + {% include('assets/img/Seqera_logo.svg') %} +
          +
          + {% include('assets/img/OpenAI_logo.svg') %} +
          +
          + {% include('assets/img/Anthropic_logo.svg') %} +
          +
          +
          + + + +
          + + +
          +
          + +
          + + +
          + + Keys entered here will be stored in your browser's local storage. See + the docs. +
          +
          + + + + + + +
          +
          + + +
          +
          Switch out sample names with random identifiers
          +
          +
          diff --git a/multiqc/templates/default/toolbox/buttons.html b/multiqc/templates/default/toolbox/buttons.html new file mode 100644 index 0000000000..0eb73db42d --- /dev/null +++ b/multiqc/templates/default/toolbox/buttons.html @@ -0,0 +1,55 @@ + + diff --git a/multiqc/templates/default/toolbox/citations.html b/multiqc/templates/default/toolbox/citations.html new file mode 100644 index 0000000000..4123bb2995 --- /dev/null +++ b/multiqc/templates/default/toolbox/citations.html @@ -0,0 +1,24 @@ + +
          +
          Tool Citations
          +

          Please remember to cite all of the tools that you use in your analysis.

          +
          + + +
          +
          `, 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''' +
          +
          {len(pass_samples)}
          +
          ''' + + if len(warn_samples) > 0: + html += f''' +
          +
          {len(warn_samples)}
          +
          ''' + + if len(fail_samples) > 0: + html += f''' +
          +
          {len(fail_samples)}
          +
          ''' + + 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"""\ - - +
          {"".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) {
          \ \
          \ - \ - \ + \ + \
          \
          \
          loading..
          '; 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 \
          \ - \ - \
          \ 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 r2 (SNPs)", + "ylab": "Best genotype r2 (SNPs)", }, { - "name": "Imputed dosage r-squared (SNPs)", - "ylab": "Imputed dosage r-squared (SNPs)", + "name": "Imputed dosage r2 (SNPs)", + "ylab": "Imputed dosage r2 (SNPs)", }, { - "name": "Best genotype r-squared (indels)", - "ylab": "Best genotype r-squared (indels)", + "name": "Best genotype r2 (indels)", + "ylab": "Best genotype r2 (indels)", }, { - "name": "Imputed dosage r-squared (indels)", - "ylab": "Imputed dosage r-squared (indels)", + "name": "Imputed dosage r2 (indels)", + "ylab": "Imputed dosage r2 (indels)", }, { - "name": "Best genotype r-squared (SNPs + indels)", - "ylab": "Best genotype r-squared (SNPs + indels)", + "name": "Best genotype r2 (SNPs + indels)", + "ylab": "Best genotype r2 (SNPs + indels)", }, { - "name": "Imputed dosage r-squared (SNPs + indels)", - "ylab": "Imputed dosage r-squared (SNPs + indels)", + "name": "Imputed dosage r2 (SNPs + indels)", + "ylab": "Imputed dosage r2 (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 r2", + "description": "Best genotype r2", "min": 0, "max": 1, "scale": "YlGn", + "format": "{:,.4f}", }, "imputed_ds_rsquared": { - "title": "Imputed dosage r-squared", - "description": "Imputed dosage r-squared", + "title": "Imputed dosage r2", + "description": "Imputed dosage r2", "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) ->
          \n' + return f'\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{buttons}\n
          \n
          \n\n" + html = f"
          \n
          \n{buttons}\n
          \n
          \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""" - """ ) @@ -1053,9 +1061,9 @@ def escape(s: str) -> str: buttons.append( f""" - """ ) @@ -1063,10 +1071,10 @@ def escape(s: str) -> str: # Sort By Highlight button buttons.append( f""" - + """ ) @@ -1074,27 +1082,27 @@ def escape(s: str) -> str: if len(col_to_th) > 1: buttons.append( f""" - - """ + + """ ) if violin_anchor is not None: buttons.append( f""" - - """ + + """ ) buttons.append( f""" - """ ) @@ -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""" -
          \n
          \n{panel}\n
          \n
          +
          \n
          \n{panel}\n
          \n
          """ # 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"""
          ${e[t]}\n ${t}
          ${doi_list[doi]} + ${doi}
          + diff --git a/multiqc/templates/default/toolbox/export.html b/multiqc/templates/default/toolbox/export.html new file mode 100644 index 0000000000..fe1625d0cd --- /dev/null +++ b/multiqc/templates/default/toolbox/export.html @@ -0,0 +1,96 @@ + +
          +
          Export Plots
          +
          + +
          +
          +
          +
          + +
          + + px +
          +
          +
          + +
          + + px +
          +
          +
          +
          +
          + + +
          +
          +
          +
          + + +
          +
          + +
          + + X +
          +
          +
          +
          + +
          +
          + + +
          + {% if config.make_data_dir %} +
          + {{ material_icon('mdi:information', 16) }} Note: Additional data was saved in + {{ config.data_dir_name }} when this report was generated. +
          + {% endif %} +
          +
          +
          + +
          + Choose Plots + + +
          +
          + + +

          If you use plots from MultiQC in a publication or presentation, please cite:

          +
          + MultiQC: Summarize analysis results for multiple tools and samples in a single report
          + Philip Ewels, Måns Magnusson, Sverker Lundin and Max Käller
          + Bioinformatics (2016)
          + doi: + 10.1093/bioinformatics/btw354
          + PMID: 27312411 +
          +
          +
          diff --git a/multiqc/templates/default/toolbox/hide.html b/multiqc/templates/default/toolbox/hide.html new file mode 100644 index 0000000000..9d75eea99b --- /dev/null +++ b/multiqc/templates/default/toolbox/hide.html @@ -0,0 +1,69 @@ + +
          +
          + + Show / Hide Samples +
          + {% if report.num_flat_plots > 0 %} +

          + {{ material_icon('mdi:alert', 16) }} This report has flat image plots that won't be hidden.
          + See the + + documentation + + for help. +

          + {% endif %} +
          +
          + + +
          +
          + + +
          +
          + + +
          +
          + {% if report.general_stats_data | length > 10 %}

          Warning! This can take a few seconds.

          {% endif %} +
          +
          + + +
          + + +
          +
            +
            diff --git a/multiqc/templates/default/toolbox/highlight.html b/multiqc/templates/default/toolbox/highlight.html new file mode 100644 index 0000000000..57493ecfaa --- /dev/null +++ b/multiqc/templates/default/toolbox/highlight.html @@ -0,0 +1,41 @@ + +
            +
            + + Highlight Samples +
            +
            + {% if report.num_flat_plots > 0 %} +

            + {{ material_icon('mdi:alert', 16) }} This report has flat image plots that won't be highlighted.
            + See the + + documentation + + for help. +

            + {% endif %} +
            + + + +
            +
            +
            + + +
            + + +
            +
              +
              diff --git a/multiqc/templates/default/toolbox/rename.html b/multiqc/templates/default/toolbox/rename.html new file mode 100644 index 0000000000..d9f6c3e248 --- /dev/null +++ b/multiqc/templates/default/toolbox/rename.html @@ -0,0 +1,66 @@ + +
              +
              + Rename Samples + + +
              + {% if report.num_flat_plots > 0 %} +

              + {{ material_icon('mdi:alert', 16) }} This report has flat image plots that won't be renamed.
              + See the + + documentation + + for help. +

              + {% endif %} +
              + + + +
              +
              +

              + Paste two columns of a tab-delimited table here (eg. from Excel). First column should be the old name, second + column the new name. +

              +
              + + +
              +
              +
              +
              + + +
              + + +
              +
                +
                diff --git a/multiqc/templates/default/toolbox/save.html b/multiqc/templates/default/toolbox/save.html new file mode 100644 index 0000000000..315729ba4d --- /dev/null +++ b/multiqc/templates/default/toolbox/save.html @@ -0,0 +1,50 @@ + +
                +
                Save Settings
                +
                + {{ material_icon('mdi:information', 20) }} Report settings are automatically saved in your browser as you use the + toolbox. You can also save named configurations below. +
                +
                + +
                + + +
                +
                + +
                Load Settings
                +

                Choose a saved report profile from the browser or load from a file:

                +
                + + +
                + + +
                + +
                + + +
                + +
                + +
                +
                +
                +
                diff --git a/multiqc/templates/default/vite.config.js b/multiqc/templates/default/vite.config.js new file mode 100644 index 0000000000..476eece615 --- /dev/null +++ b/multiqc/templates/default/vite.config.js @@ -0,0 +1,26 @@ +import { resolve } from "path"; + +export default { + root: resolve(__dirname, "src"), + build: { + outDir: "../compiled", + rollupOptions: { + input: { + main: resolve(__dirname, "src/js/main.js"), + }, + output: { + entryFileNames: "js/multiqc.min.js", + assetFileNames: "css/multiqc.min.css", + }, + }, + minify: "terser", + cssMinify: true, + }, + css: { + preprocessorOptions: { + scss: { + silenceDeprecations: ["import", "mixed-decls", "color-functions", "global-builtin"], + }, + }, + }, +}; diff --git a/multiqc/templates/disco/README.md b/multiqc/templates/disco/README.md new file mode 100644 index 0000000000..d0efda3cf6 --- /dev/null +++ b/multiqc/templates/disco/README.md @@ -0,0 +1,22 @@ +# MultiQC Crazy Template + +A totally bonkers child theme of the default MultiQC template to demonstrate how to make custom themes using Bootstrap variable overrides. + +## Usage + +```bash +multiqc . --template crazy +``` + +Or in your MultiQC configuration file: + +```yaml +template: brite +``` + +## Building + +```bash +npm install +npm run build +``` diff --git a/multiqc/templates/disco/__init__.py b/multiqc/templates/disco/__init__.py new file mode 100644 index 0000000000..146e80c30c --- /dev/null +++ b/multiqc/templates/disco/__init__.py @@ -0,0 +1,27 @@ +""" +======== + crazy +======== + +A child theme of 'default' - experimental / testing template. + +""" + +import os +import importlib + +template_parent = "default" +template_dir = os.path.dirname(__file__) +base_fn = "base.html" + +# Template configuration - overrides user config +template_dark_mode = False # Disable dark mode toggle for this template +plot_font_family = '"Rock Salt", cursive' + +# Import template functions from parent +try: + parent_mod = importlib.import_module(f"multiqc.templates.{template_parent}") + if hasattr(parent_mod, "template_functions"): + template_functions = parent_mod.template_functions +except ImportError: + pass diff --git a/multiqc/templates/disco/compiled/css/multiqc.min.css b/multiqc/templates/disco/compiled/css/multiqc.min.css new file mode 100644 index 0000000000..0ce8f448b9 --- /dev/null +++ b/multiqc/templates/disco/compiled/css/multiqc.min.css @@ -0,0 +1 @@ +@charset "UTF-8";@import"https://fonts.googleapis.com/css2?family=Rock+Salt&display=swap";:root,[data-bs-theme=light]{--bs-blue: #0d6efd;--bs-indigo: #6610f2;--bs-purple: #6f42c1;--bs-pink: #d63384;--bs-red: #dc3545;--bs-orange: #fd7e14;--bs-yellow: #ffc107;--bs-green: #198754;--bs-teal: #20c997;--bs-cyan: #0dcaf0;--bs-black: #000;--bs-white: #fff;--bs-gray: #6c757d;--bs-gray-dark: #343a40;--bs-gray-100: #f8f9fa;--bs-gray-200: #e9ecef;--bs-gray-300: #dee2e6;--bs-gray-400: #ced4da;--bs-gray-500: #adb5bd;--bs-gray-600: #6c757d;--bs-gray-700: #495057;--bs-gray-800: #343a40;--bs-gray-900: #212529;--bs-primary: #7300ff;--bs-secondary: #00ff0d;--bs-success: #198754;--bs-info: #3300aa;--bs-warning: #ffc107;--bs-danger: #dc3545;--bs-light: #f8f9fa;--bs-dark: #212529;--bs-primary-rgb: 115, 0, 255;--bs-secondary-rgb: 0, 255, 13;--bs-success-rgb: 25, 135, 84;--bs-info-rgb: 51, 0, 170;--bs-warning-rgb: 255, 193, 7;--bs-danger-rgb: 220, 53, 69;--bs-light-rgb: 248, 249, 250;--bs-dark-rgb: 33, 37, 41;--bs-primary-text-emphasis: #2e0066;--bs-secondary-text-emphasis: rgb(0, 102, 5.2);--bs-success-text-emphasis: rgb(10, 54, 33.6);--bs-info-text-emphasis: rgb(20.4, 0, 68);--bs-warning-text-emphasis: rgb(102, 77.2, 2.8);--bs-danger-text-emphasis: rgb(88, 21.2, 27.6);--bs-light-text-emphasis: #495057;--bs-dark-text-emphasis: #495057;--bs-primary-bg-subtle: #e3ccff;--bs-secondary-bg-subtle: rgb(204, 255, 206.6);--bs-success-bg-subtle: rgb(209, 231, 220.8);--bs-info-bg-subtle: rgb(214.2, 204, 238);--bs-warning-bg-subtle: rgb(255, 242.6, 205.4);--bs-danger-bg-subtle: rgb(248, 214.6, 217.8);--bs-light-bg-subtle: rgb(251.5, 252, 252.5);--bs-dark-bg-subtle: #ced4da;--bs-primary-border-subtle: #c799ff;--bs-secondary-border-subtle: rgb(153, 255, 158.2);--bs-success-border-subtle: rgb(163, 207, 186.6);--bs-info-border-subtle: rgb(173.4, 153, 221);--bs-warning-border-subtle: rgb(255, 230.2, 155.8);--bs-danger-border-subtle: rgb(241, 174.2, 180.6);--bs-light-border-subtle: #e9ecef;--bs-dark-border-subtle: #adb5bd;--bs-white-rgb: 255, 255, 255;--bs-black-rgb: 0, 0, 0;--bs-font-sans-serif: 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";--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, .15), rgba(255, 255, 255, 0));--bs-body-font-family: "Rock Salt", cursive;--bs-body-font-size: .85rem;--bs-body-font-weight: 400;--bs-body-line-height: 1.5;--bs-body-color: #212529;--bs-body-color-rgb: 33, 37, 41;--bs-body-bg: #833ab4;--bs-body-bg-rgb: 131, 58, 180;--bs-emphasis-color: #000;--bs-emphasis-color-rgb: 0, 0, 0;--bs-secondary-color: rgba(33, 37, 41, .75);--bs-secondary-color-rgb: 33, 37, 41;--bs-secondary-bg: #00f;--bs-secondary-bg-rgb: 0, 0, 255;--bs-tertiary-color: rgba(33, 37, 41, .5);--bs-tertiary-color-rgb: 33, 37, 41;--bs-tertiary-bg: #e1ff00;--bs-tertiary-bg-rgb: 225, 255, 0;--bs-heading-color: inherit;--bs-link-color: #7300ff;--bs-link-color-rgb: 115, 0, 255;--bs-link-decoration: underline;--bs-link-hover-color: #5c00cc;--bs-link-hover-color-rgb: 92, 0, 204;--bs-code-color: #d63384;--bs-highlight-color: #212529;--bs-highlight-bg: rgb(255, 242.6, 205.4);--bs-border-width: 1px;--bs-border-style: solid;--bs-border-color: #dee2e6;--bs-border-color-translucent: rgba(0, 0, 0, .175);--bs-border-radius: .375rem;--bs-border-radius-sm: .25rem;--bs-border-radius-lg: .5rem;--bs-border-radius-xl: 1rem;--bs-border-radius-xxl: 2rem;--bs-border-radius-2xl: var(--bs-border-radius-xxl);--bs-border-radius-pill: 50rem;--bs-box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15);--bs-box-shadow-sm: 0 .125rem .25rem rgba(0, 0, 0, .075);--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, .175);--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, .075);--bs-focus-ring-width: .25rem;--bs-focus-ring-opacity: .25;--bs-focus-ring-color: rgba(115, 0, 255, .25);--bs-form-valid-color: #198754;--bs-form-valid-border-color: #198754;--bs-form-invalid-color: #dc3545;--bs-form-invalid-border-color: #dc3545}*,*:before,*:after{box-sizing:border-box}@media (prefers-reduced-motion: no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0)}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}h6,.h6,h5,.h5,h4,.h4,h3,.h3,h2,.h2,h1,.h1{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}h1,.h1{font-size:calc(1.3375rem + 1.05vw)}@media (min-width: 1200px){h1,.h1{font-size:2.125rem}}h2,.h2{font-size:calc(1.295rem + .54vw)}@media (min-width: 1200px){h2,.h2{font-size:1.7rem}}h3,.h3{font-size:calc(1.27375rem + .285vw)}@media (min-width: 1200px){h3,.h3{font-size:1.4875rem}}h4,.h4{font-size:calc(1.2525rem + .03vw)}@media (min-width: 1200px){h4,.h4{font-size:1.275rem}}h5,.h5{font-size:1.0625rem}h6,.h6{font-size:.85rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{text-decoration:underline dotted;cursor:help;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small,.small{font-size:.875em}mark,.mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity, 1));text-decoration:underline}a:hover{--bs-link-color-rgb: var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}pre,code,kbd,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}thead,tbody,tfoot,tr,td,th{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}input,button,select,optgroup,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button:not(:disabled),[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;line-height:inherit;font-size:calc(1.275rem + .3vw)}@media (min-width: 1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-text,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button{cursor:pointer;filter:grayscale(1)}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.0625rem;font-weight:300}.display-1{font-weight:300;line-height:1.2;font-size:calc(1.625rem + 4.5vw)}@media (min-width: 1200px){.display-1{font-size:5rem}}.display-2{font-weight:300;line-height:1.2;font-size:calc(1.575rem + 3.9vw)}@media (min-width: 1200px){.display-2{font-size:4.5rem}}.display-3{font-weight:300;line-height:1.2;font-size:calc(1.525rem + 3.3vw)}@media (min-width: 1200px){.display-3{font-size:4rem}}.display-4{font-weight:300;line-height:1.2;font-size:calc(1.475rem + 2.7vw)}@media (min-width: 1200px){.display-4{font-size:3.5rem}}.display-5{font-weight:300;line-height:1.2;font-size:calc(1.425rem + 2.1vw)}@media (min-width: 1200px){.display-5{font-size:3rem}}.display-6{font-weight:300;line-height:1.2;font-size:calc(1.375rem + 1.5vw)}@media (min-width: 1200px){.display-6{font-size:2.5rem}}.list-unstyled,.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.0625rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer:before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-xxl,.container-xl,.container-lg,.container-md,.container-sm{--bs-gutter-x: 1.5rem;--bs-gutter-y: 0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width: 576px){.container-sm,.container{max-width:540px}}@media (min-width: 768px){.container-md,.container-sm,.container{max-width:720px}}@media (min-width: 992px){.container-lg,.container-md,.container-sm,.container{max-width:960px}}@media (min-width: 1200px){.container-xl,.container-lg,.container-md,.container-sm,.container{max-width:1140px}}@media (min-width: 1400px){.container-xxl,.container-xl,.container-lg,.container-md,.container-sm,.container{max-width:1320px}}:root{--bs-breakpoint-xs: 0;--bs-breakpoint-sm: 576px;--bs-breakpoint-md: 768px;--bs-breakpoint-lg: 992px;--bs-breakpoint-xl: 1200px;--bs-breakpoint-xxl: 1400px}.row{--bs-gutter-x: 1.5rem;--bs-gutter-y: 0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x: 0}.g-0,.gy-0{--bs-gutter-y: 0}.g-1,.gx-1{--bs-gutter-x: .25rem}.g-1,.gy-1{--bs-gutter-y: .25rem}.g-2,.gx-2{--bs-gutter-x: .5rem}.g-2,.gy-2{--bs-gutter-y: .5rem}.g-3,.gx-3{--bs-gutter-x: 1rem}.g-3,.gy-3{--bs-gutter-y: 1rem}.g-4,.gx-4{--bs-gutter-x: 1.5rem}.g-4,.gy-4{--bs-gutter-y: 1.5rem}.g-5,.gx-5{--bs-gutter-x: 3rem}.g-5,.gy-5{--bs-gutter-y: 3rem}@media (min-width: 576px){.col-sm{flex:1 0 0}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x: 0}.g-sm-0,.gy-sm-0{--bs-gutter-y: 0}.g-sm-1,.gx-sm-1{--bs-gutter-x: .25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y: .25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x: .5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y: .5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x: 1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y: 1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x: 1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y: 1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x: 3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y: 3rem}}@media (min-width: 768px){.col-md{flex:1 0 0}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x: 0}.g-md-0,.gy-md-0{--bs-gutter-y: 0}.g-md-1,.gx-md-1{--bs-gutter-x: .25rem}.g-md-1,.gy-md-1{--bs-gutter-y: .25rem}.g-md-2,.gx-md-2{--bs-gutter-x: .5rem}.g-md-2,.gy-md-2{--bs-gutter-y: .5rem}.g-md-3,.gx-md-3{--bs-gutter-x: 1rem}.g-md-3,.gy-md-3{--bs-gutter-y: 1rem}.g-md-4,.gx-md-4{--bs-gutter-x: 1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y: 1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x: 3rem}.g-md-5,.gy-md-5{--bs-gutter-y: 3rem}}@media (min-width: 992px){.col-lg{flex:1 0 0}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x: 0}.g-lg-0,.gy-lg-0{--bs-gutter-y: 0}.g-lg-1,.gx-lg-1{--bs-gutter-x: .25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y: .25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x: .5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y: .5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x: 1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y: 1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x: 1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y: 1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x: 3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y: 3rem}}@media (min-width: 1200px){.col-xl{flex:1 0 0}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x: 0}.g-xl-0,.gy-xl-0{--bs-gutter-y: 0}.g-xl-1,.gx-xl-1{--bs-gutter-x: .25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y: .25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x: .5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y: .5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x: 1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y: 1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x: 1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y: 1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x: 3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y: 3rem}}@media (min-width: 1400px){.col-xxl{flex:1 0 0}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x: 0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y: 0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x: .25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y: .25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x: .5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y: .5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x: 1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y: 1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x: 1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y: 1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x: 3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y: 3rem}}.table{--bs-table-color-type: initial;--bs-table-bg-type: initial;--bs-table-color-state: initial;--bs-table-bg-state: initial;--bs-table-color: var(--bs-emphasis-color);--bs-table-bg: transparent;--bs-table-border-color: var(--bs-border-color);--bs-table-accent-bg: transparent;--bs-table-striped-color: var(--bs-emphasis-color);--bs-table-striped-bg: rgba(var(--bs-emphasis-color-rgb), .05);--bs-table-active-color: var(--bs-emphasis-color);--bs-table-active-bg: rgba(var(--bs-emphasis-color-rgb), .1);--bs-table-hover-color: var(--bs-emphasis-color);--bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), .075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem;color:var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type: var(--bs-table-striped-color);--bs-table-bg-type: var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type: var(--bs-table-striped-color);--bs-table-bg-type: var(--bs-table-striped-bg)}.table-active{--bs-table-color-state: var(--bs-table-active-color);--bs-table-bg-state: var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state: var(--bs-table-hover-color);--bs-table-bg-state: var(--bs-table-hover-bg)}.table-primary{--bs-table-color: #000;--bs-table-bg: #e3ccff;--bs-table-border-color: rgb(181.6, 163.2, 204);--bs-table-striped-bg: rgb(215.65, 193.8, 242.25);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(204.3, 183.6, 229.5);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(209.975, 188.7, 235.875);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color: #000;--bs-table-bg: rgb(204, 255, 206.6);--bs-table-border-color: rgb(163.2, 204, 165.28);--bs-table-striped-bg: rgb(193.8, 242.25, 196.27);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(183.6, 229.5, 185.94);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(188.7, 235.875, 191.105);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color: #000;--bs-table-bg: rgb(209, 231, 220.8);--bs-table-border-color: rgb(167.2, 184.8, 176.64);--bs-table-striped-bg: rgb(198.55, 219.45, 209.76);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(188.1, 207.9, 198.72);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(193.325, 213.675, 204.24);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color: #000;--bs-table-bg: rgb(214.2, 204, 238);--bs-table-border-color: rgb(171.36, 163.2, 190.4);--bs-table-striped-bg: rgb(203.49, 193.8, 226.1);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(192.78, 183.6, 214.2);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(198.135, 188.7, 220.15);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color: #000;--bs-table-bg: rgb(255, 242.6, 205.4);--bs-table-border-color: rgb(204, 194.08, 164.32);--bs-table-striped-bg: rgb(242.25, 230.47, 195.13);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(229.5, 218.34, 184.86);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(235.875, 224.405, 189.995);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color: #000;--bs-table-bg: rgb(248, 214.6, 217.8);--bs-table-border-color: rgb(198.4, 171.68, 174.24);--bs-table-striped-bg: rgb(235.6, 203.87, 206.91);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(223.2, 193.14, 196.02);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(229.4, 198.505, 201.465);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color: #000;--bs-table-bg: #f8f9fa;--bs-table-border-color: rgb(198.4, 199.2, 200);--bs-table-striped-bg: rgb(235.6, 236.55, 237.5);--bs-table-striped-color: #000;--bs-table-active-bg: rgb(223.2, 224.1, 225);--bs-table-active-color: #000;--bs-table-hover-bg: rgb(229.4, 230.325, 231.25);--bs-table-hover-color: #000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color: #fff;--bs-table-bg: #212529;--bs-table-border-color: rgb(77.4, 80.6, 83.8);--bs-table-striped-bg: rgb(44.1, 47.9, 51.7);--bs-table-striped-color: #fff;--bs-table-active-bg: rgb(55.2, 58.8, 62.4);--bs-table-active-color: #fff;--bs-table-hover-bg: rgb(49.65, 53.35, 57.05);--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width: 575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width: 767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width: 991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width: 1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width: 1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.0625rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.74375rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:.85rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#b980ff;outline:0;box-shadow:0 0 0 .25rem #7300ff40}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-sm,.form-control-plaintext.form-control-lg{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.74375rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.0625rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:.85rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon, none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-select{transition:none}}.form-select:focus{border-color:#b980ff;outline:0;box-shadow:0 0 0 .25rem #7300ff40}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.74375rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.0625rem;border-radius:var(--bs-border-radius-lg)}.form-check{display:block;min-height:1.275rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg: var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#b980ff;outline:0;box-shadow:0 0 0 .25rem #7300ff40}.form-check-input:checked{background-color:#7300ff;border-color:#7300ff}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#7300ff;border-color:#7300ff;--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input[disabled]~.form-check-label,.form-check-input:disabled~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgb%28185, 127.5, 255%29'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check[disabled]+.btn,.btn-check:disabled+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #833ab4,0 0 0 .25rem #7300ff40}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #833ab4,0 0 0 .25rem #7300ff40}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;appearance:none;background-color:#7300ff;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-range::-webkit-slider-thumb{transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#d5b3ff}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;appearance:none;background-color:#7300ff;border:0;border-radius:1rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-range::-moz-range-thumb{transition:none}}.form-range::-moz-range-thumb:active{background-color:#d5b3ff}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;max-width:100%;height:100%;padding:1rem .75rem;overflow:hidden;color:rgba(var(--bs-body-color-rgb),.65);text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion: reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control::placeholder,.form-floating>.form-control-plaintext::placeholder{color:transparent}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown),.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill,.form-floating>.form-control-plaintext:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem;padding-left:.75rem}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-control-plaintext~label,.form-floating>.form-select~label{transform:scale(.85) translateY(-.5rem) translate(.15rem)}.form-floating>.form-control:-webkit-autofill~label{transform:scale(.85) translateY(-.5rem) translate(.15rem)}.form-floating>textarea:focus~label:after,.form-floating>textarea:not(:placeholder-shown)~label:after{position:absolute;inset:1rem .375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>textarea:disabled~label:after{background-color:var(--bs-secondary-bg)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>:disabled~label,.form-floating>.form-control:disabled~label{color:#6c757d}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select,.input-group>.form-floating{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus,.input-group>.form-floating:focus-within{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:.85rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text,.input-group-lg>.btn{padding:.5rem 1rem;font-size:1.0625rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text,.input-group-sm>.btn{padding:.25rem .5rem;font-size:.74375rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select{border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(-1 * var(--bs-border-width));border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.74375rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip,.is-valid~.valid-feedback,.is-valid~.valid-tooltip{display:block}.was-validated .form-control:valid,.form-control.is-valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .form-control:valid:focus,.form-control.is-valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.was-validated .form-select:valid,.form-select.is-valid{border-color:var(--bs-form-valid-border-color)}.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"],.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"]{--bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .form-select:valid:focus,.form-select.is-valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated .form-control-color:valid,.form-control-color.is-valid{width:calc(3.75rem + 1.5em)}.was-validated .form-check-input:valid,.form-check-input.is-valid{border-color:var(--bs-form-valid-border-color)}.was-validated .form-check-input:valid:checked,.form-check-input.is-valid:checked{background-color:var(--bs-form-valid-color)}.was-validated .form-check-input:valid:focus,.form-check-input.is-valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated .form-check-input:valid~.form-check-label,.form-check-input.is-valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.was-validated .input-group>.form-control:not(:focus):valid,.input-group>.form-control:not(:focus).is-valid,.was-validated .input-group>.form-select:not(:focus):valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.input-group>.form-floating:not(:focus-within).is-valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.74375rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip,.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip{display:block}.was-validated .form-control:invalid,.form-control.is-invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .form-control:invalid:focus,.form-control.is-invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.was-validated .form-select:invalid,.form-select.is-invalid{border-color:var(--bs-form-invalid-border-color)}.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"],.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.was-validated .form-select:invalid:focus,.form-select.is-invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated .form-control-color:invalid,.form-control-color.is-invalid{width:calc(3.75rem + 1.5em)}.was-validated .form-check-input:invalid,.form-check-input.is-invalid{border-color:var(--bs-form-invalid-border-color)}.was-validated .form-check-input:invalid:checked,.form-check-input.is-invalid:checked{background-color:var(--bs-form-invalid-color)}.was-validated .form-check-input:invalid:focus,.form-check-input.is-invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated .form-check-input:invalid~.form-check-label,.form-check-input.is-invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.was-validated .input-group>.form-control:not(:focus):invalid,.input-group>.form-control:not(:focus).is-invalid,.was-validated .input-group>.form-select:not(:focus):invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.input-group>.form-floating:not(:focus-within).is-invalid{z-index:4}.btn{--bs-btn-padding-x: .75rem;--bs-btn-padding-y: .375rem;--bs-btn-font-family: ;--bs-btn-font-size: .85rem;--bs-btn-font-weight: 400;--bs-btn-line-height: 1.5;--bs-btn-color: var(--bs-body-color);--bs-btn-bg: transparent;--bs-btn-border-width: var(--bs-border-width);--bs-btn-border-color: transparent;--bs-btn-border-radius: var(--bs-border-radius);--bs-btn-hover-border-color: transparent;--bs-btn-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);--bs-btn-disabled-opacity: .65;--bs-btn-focus-box-shadow: 0 0 0 .25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;white-space:nowrap;vertical-align:middle;cursor:pointer;-webkit-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion: reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,:not(.btn-check)+.btn:active,.btn:first-child:active,.btn.active,.btn.show{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,:not(.btn-check)+.btn:active:focus-visible,.btn:first-child:active:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn:disabled,.btn.disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color: #fff;--bs-btn-bg: #7300ff;--bs-btn-border-color: #7300ff;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(97.75, 0, 216.75);--bs-btn-hover-border-color: #5c00cc;--bs-btn-focus-shadow-rgb: 136, 38, 255;--bs-btn-active-color: #fff;--bs-btn-active-bg: #5c00cc;--bs-btn-active-border-color: rgb(86.25, 0, 191.25);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #7300ff;--bs-btn-disabled-border-color: #7300ff}.btn-secondary{--bs-btn-color: #000;--bs-btn-bg: #00ff0d;--bs-btn-border-color: #00ff0d;--bs-btn-hover-color: #000;--bs-btn-hover-bg: rgb(38.25, 255, 49.3);--bs-btn-hover-border-color: rgb(25.5, 255, 37.2);--bs-btn-focus-shadow-rgb: 0, 217, 11;--bs-btn-active-color: #000;--bs-btn-active-bg: rgb(51, 255, 61.4);--bs-btn-active-border-color: rgb(25.5, 255, 37.2);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #000;--bs-btn-disabled-bg: #00ff0d;--bs-btn-disabled-border-color: #00ff0d}.btn-success{--bs-btn-color: #fff;--bs-btn-bg: #198754;--bs-btn-border-color: #198754;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(21.25, 114.75, 71.4);--bs-btn-hover-border-color: rgb(20, 108, 67.2);--bs-btn-focus-shadow-rgb: 60, 153, 110;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(20, 108, 67.2);--bs-btn-active-border-color: rgb(18.75, 101.25, 63);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #198754;--bs-btn-disabled-border-color: #198754}.btn-info{--bs-btn-color: #fff;--bs-btn-bg: #3300aa;--bs-btn-border-color: #3300aa;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(43.35, 0, 144.5);--bs-btn-hover-border-color: rgb(40.8, 0, 136);--bs-btn-focus-shadow-rgb: 82, 38, 183;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(40.8, 0, 136);--bs-btn-active-border-color: rgb(38.25, 0, 127.5);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #3300aa;--bs-btn-disabled-border-color: #3300aa}.btn-warning{--bs-btn-color: #000;--bs-btn-bg: #ffc107;--bs-btn-border-color: #ffc107;--bs-btn-hover-color: #000;--bs-btn-hover-bg: rgb(255, 202.3, 44.2);--bs-btn-hover-border-color: rgb(255, 199.2, 31.8);--bs-btn-focus-shadow-rgb: 217, 164, 6;--bs-btn-active-color: #000;--bs-btn-active-bg: rgb(255, 205.4, 56.6);--bs-btn-active-border-color: rgb(255, 199.2, 31.8);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #000;--bs-btn-disabled-bg: #ffc107;--bs-btn-disabled-border-color: #ffc107}.btn-danger{--bs-btn-color: #fff;--bs-btn-bg: #dc3545;--bs-btn-border-color: #dc3545;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(187, 45.05, 58.65);--bs-btn-hover-border-color: rgb(176, 42.4, 55.2);--bs-btn-focus-shadow-rgb: 225, 83, 97;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(176, 42.4, 55.2);--bs-btn-active-border-color: rgb(165, 39.75, 51.75);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #dc3545;--bs-btn-disabled-border-color: #dc3545}.btn-light{--bs-btn-color: #000;--bs-btn-bg: #f8f9fa;--bs-btn-border-color: #f8f9fa;--bs-btn-hover-color: #000;--bs-btn-hover-bg: rgb(210.8, 211.65, 212.5);--bs-btn-hover-border-color: rgb(198.4, 199.2, 200);--bs-btn-focus-shadow-rgb: 211, 212, 213;--bs-btn-active-color: #000;--bs-btn-active-bg: rgb(198.4, 199.2, 200);--bs-btn-active-border-color: rgb(186, 186.75, 187.5);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #000;--bs-btn-disabled-bg: #f8f9fa;--bs-btn-disabled-border-color: #f8f9fa}.btn-dark{--bs-btn-color: #fff;--bs-btn-bg: #212529;--bs-btn-border-color: #212529;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: rgb(66.3, 69.7, 73.1);--bs-btn-hover-border-color: rgb(55.2, 58.8, 62.4);--bs-btn-focus-shadow-rgb: 66, 70, 73;--bs-btn-active-color: #fff;--bs-btn-active-bg: rgb(77.4, 80.6, 83.8);--bs-btn-active-border-color: rgb(55.2, 58.8, 62.4);--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #212529;--bs-btn-disabled-border-color: #212529}.btn-outline-primary{--bs-btn-color: #7300ff;--bs-btn-border-color: #7300ff;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #7300ff;--bs-btn-hover-border-color: #7300ff;--bs-btn-focus-shadow-rgb: 115, 0, 255;--bs-btn-active-color: #fff;--bs-btn-active-bg: #7300ff;--bs-btn-active-border-color: #7300ff;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #7300ff;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #7300ff;--bs-gradient: none}.btn-outline-secondary{--bs-btn-color: #00ff0d;--bs-btn-border-color: #00ff0d;--bs-btn-hover-color: #000;--bs-btn-hover-bg: #00ff0d;--bs-btn-hover-border-color: #00ff0d;--bs-btn-focus-shadow-rgb: 0, 255, 13;--bs-btn-active-color: #000;--bs-btn-active-bg: #00ff0d;--bs-btn-active-border-color: #00ff0d;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #00ff0d;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #00ff0d;--bs-gradient: none}.btn-outline-success{--bs-btn-color: #198754;--bs-btn-border-color: #198754;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #198754;--bs-btn-hover-border-color: #198754;--bs-btn-focus-shadow-rgb: 25, 135, 84;--bs-btn-active-color: #fff;--bs-btn-active-bg: #198754;--bs-btn-active-border-color: #198754;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #198754;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #198754;--bs-gradient: none}.btn-outline-info{--bs-btn-color: #3300aa;--bs-btn-border-color: #3300aa;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #3300aa;--bs-btn-hover-border-color: #3300aa;--bs-btn-focus-shadow-rgb: 51, 0, 170;--bs-btn-active-color: #fff;--bs-btn-active-bg: #3300aa;--bs-btn-active-border-color: #3300aa;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #3300aa;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #3300aa;--bs-gradient: none}.btn-outline-warning{--bs-btn-color: #ffc107;--bs-btn-border-color: #ffc107;--bs-btn-hover-color: #000;--bs-btn-hover-bg: #ffc107;--bs-btn-hover-border-color: #ffc107;--bs-btn-focus-shadow-rgb: 255, 193, 7;--bs-btn-active-color: #000;--bs-btn-active-bg: #ffc107;--bs-btn-active-border-color: #ffc107;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #ffc107;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #ffc107;--bs-gradient: none}.btn-outline-danger{--bs-btn-color: #dc3545;--bs-btn-border-color: #dc3545;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #dc3545;--bs-btn-hover-border-color: #dc3545;--bs-btn-focus-shadow-rgb: 220, 53, 69;--bs-btn-active-color: #fff;--bs-btn-active-bg: #dc3545;--bs-btn-active-border-color: #dc3545;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #dc3545;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #dc3545;--bs-gradient: none}.btn-outline-light{--bs-btn-color: #f8f9fa;--bs-btn-border-color: #f8f9fa;--bs-btn-hover-color: #000;--bs-btn-hover-bg: #f8f9fa;--bs-btn-hover-border-color: #f8f9fa;--bs-btn-focus-shadow-rgb: 248, 249, 250;--bs-btn-active-color: #000;--bs-btn-active-bg: #f8f9fa;--bs-btn-active-border-color: #f8f9fa;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #f8f9fa;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #f8f9fa;--bs-gradient: none}.btn-outline-dark{--bs-btn-color: #212529;--bs-btn-border-color: #212529;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #212529;--bs-btn-hover-border-color: #212529;--bs-btn-focus-shadow-rgb: 33, 37, 41;--bs-btn-active-color: #fff;--bs-btn-active-bg: #212529;--bs-btn-active-border-color: #212529;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);--bs-btn-disabled-color: #212529;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #212529;--bs-gradient: none}.btn-link{--bs-btn-font-weight: 400;--bs-btn-color: var(--bs-link-color);--bs-btn-bg: transparent;--bs-btn-border-color: transparent;--bs-btn-hover-color: var(--bs-link-hover-color);--bs-btn-hover-border-color: transparent;--bs-btn-active-color: var(--bs-link-hover-color);--bs-btn-active-border-color: transparent;--bs-btn-disabled-color: #6c757d;--bs-btn-disabled-border-color: transparent;--bs-btn-box-shadow: 0 0 0 #000;--bs-btn-focus-shadow-rgb: 136, 38, 255;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-lg,.btn-group-lg>.btn{--bs-btn-padding-y: .5rem;--bs-btn-padding-x: 1rem;--bs-btn-font-size: 1.0625rem;--bs-btn-border-radius: var(--bs-border-radius-lg)}.btn-sm,.btn-group-sm>.btn{--bs-btn-padding-y: .2rem;--bs-btn-padding-x: .45rem;--bs-btn-font-size: .8rem;--bs-btn-border-radius: var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion: reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion: reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion: reduce){.collapsing.collapse-horizontal{transition:none}}.dropup,.dropend,.dropdown,.dropstart,.dropup-center,.dropdown-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle:after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty:after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex: 1000;--bs-dropdown-min-width: 10rem;--bs-dropdown-padding-x: 0;--bs-dropdown-padding-y: .5rem;--bs-dropdown-spacer: .125rem;--bs-dropdown-font-size: .85rem;--bs-dropdown-color: var(--bs-body-color);--bs-dropdown-bg: var(--bs-body-bg);--bs-dropdown-border-color: var(--bs-border-color-translucent);--bs-dropdown-border-radius: var(--bs-border-radius);--bs-dropdown-border-width: var(--bs-border-width);--bs-dropdown-inner-border-radius: calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg: var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y: .5rem;--bs-dropdown-box-shadow: var(--bs-box-shadow);--bs-dropdown-link-color: var(--bs-body-color);--bs-dropdown-link-hover-color: var(--bs-body-color);--bs-dropdown-link-hover-bg: var(--bs-tertiary-bg);--bs-dropdown-link-active-color: #fff;--bs-dropdown-link-active-bg: #7300ff;--bs-dropdown-link-disabled-color: var(--bs-tertiary-color);--bs-dropdown-item-padding-x: 1rem;--bs-dropdown-item-padding-y: .25rem;--bs-dropdown-header-color: #6c757d;--bs-dropdown-header-padding-x: 1rem;--bs-dropdown-header-padding-y: .5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position: start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position: end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width: 576px){.dropdown-menu-sm-start{--bs-position: start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position: end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width: 768px){.dropdown-menu-md-start{--bs-position: start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position: end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width: 992px){.dropdown-menu-lg-start{--bs-position: start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position: end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width: 1200px){.dropdown-menu-xl-start{--bs-position: start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position: end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width: 1400px){.dropdown-menu-xxl-start{--bs-position: start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position: end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle:after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty:after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle:after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty:after{margin-left:0}.dropend .dropdown-toggle:after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle:after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle:after{display:none}.dropstart .dropdown-toggle:before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty:after{margin-left:0}.dropstart .dropdown-toggle:before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius, 0)}.dropdown-item:hover,.dropdown-item:focus{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.74375rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color: #dee2e6;--bs-dropdown-bg: #343a40;--bs-dropdown-border-color: var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color: #dee2e6;--bs-dropdown-link-hover-color: #fff;--bs-dropdown-divider-bg: var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg: rgba(255, 255, 255, .15);--bs-dropdown-link-active-color: #fff;--bs-dropdown-link-active-bg: #7300ff;--bs-dropdown-link-disabled-color: #adb5bd;--bs-dropdown-header-color: #adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;flex:1 1 auto}.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn:hover,.btn-group>.btn:focus,.btn-group>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn:hover,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn.active{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>:not(.btn-check:first-child)+.btn,.btn-group>.btn-group:not(:first-child){margin-left:calc(-1 * var(--bs-border-width))}.btn-group>.btn:not(:last-child):not(.dropdown-toggle),.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn-group:not(:last-child)>.btn{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn,.btn-group>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split:after,.dropup .dropdown-toggle-split:after,.dropend .dropdown-toggle-split:after{margin-left:0}.dropstart .dropdown-toggle-split:before{margin-right:0}.btn-sm+.dropdown-toggle-split,.btn-group-sm>.btn+.dropdown-toggle-split{padding-right:.3375rem;padding-left:.3375rem}.btn-lg+.dropdown-toggle-split,.btn-group-lg>.btn+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child){margin-top:calc(-1 * var(--bs-border-width))}.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle),.btn-group-vertical>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:nth-child(n+3),.btn-group-vertical>:not(.btn-check)+.btn,.btn-group-vertical>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x: 1rem;--bs-nav-link-padding-y: .5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: var(--bs-link-color);--bs-nav-link-hover-color: var(--bs-link-hover-color);--bs-nav-link-disabled-color: var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:none;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion: reduce){.nav-link{transition:none}}.nav-link:hover,.nav-link:focus{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem #7300ff40}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width: var(--bs-border-width);--bs-nav-tabs-border-color: var(--bs-border-color);--bs-nav-tabs-border-radius: var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color: var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color: var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg: var(--bs-body-bg);--bs-nav-tabs-link-active-border-color: var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius: var(--bs-border-radius);--bs-nav-pills-link-active-color: #fff;--bs-nav-pills-link-active-bg: #7300ff}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap: 1rem;--bs-nav-underline-border-width: .125rem;--bs-nav-underline-link-active-color: var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:hover,.nav-underline .nav-link:focus{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill>.nav-link,.nav-fill .nav-item{flex:1 1 auto;text-align:center}.nav-justified>.nav-link,.nav-justified .nav-item{flex-grow:1;flex-basis:0;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x: 0;--bs-navbar-padding-y: .5rem;--bs-navbar-color: rgba(var(--bs-emphasis-color-rgb), .65);--bs-navbar-hover-color: rgba(var(--bs-emphasis-color-rgb), .8);--bs-navbar-disabled-color: rgba(var(--bs-emphasis-color-rgb), .3);--bs-navbar-active-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y: .340625rem;--bs-navbar-brand-margin-end: 1rem;--bs-navbar-brand-font-size: 1.0625rem;--bs-navbar-brand-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x: .5rem;--bs-navbar-toggler-padding-y: .25rem;--bs-navbar-toggler-padding-x: .75rem;--bs-navbar-toggler-font-size: 1.0625rem;--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color: rgba(var(--bs-emphasis-color-rgb), .15);--bs-navbar-toggler-border-radius: var(--bs-border-radius);--bs-navbar-toggler-focus-width: .25rem;--bs-navbar-toggler-transition: box-shadow .15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-sm,.navbar>.container-md,.navbar>.container-lg,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x: 0;--bs-nav-link-padding-y: .5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: var(--bs-navbar-color);--bs-nav-link-hover-color: var(--bs-navbar-hover-color);--bs-nav-link-disabled-color: var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:hover,.navbar-text a:focus{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-grow:1;flex-basis:100%;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion: reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height, 75vh);overflow-y:auto}@media (min-width: 576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width: 768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width: 992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width: 1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width: 1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color: rgba(255, 255, 255, .55);--bs-navbar-hover-color: rgba(255, 255, 255, .75);--bs-navbar-disabled-color: rgba(255, 255, 255, .25);--bs-navbar-active-color: #fff;--bs-navbar-brand-color: #fff;--bs-navbar-brand-hover-color: #fff;--bs-navbar-toggler-border-color: rgba(255, 255, 255, .1);--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y: 1rem;--bs-card-spacer-x: 1rem;--bs-card-title-spacer-y: .5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width: var(--bs-border-width);--bs-card-border-color: var(--bs-border-color-translucent);--bs-card-border-radius: var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y: .5rem;--bs-card-cap-padding-x: 1rem;--bs-card-cap-bg: rgba(var(--bs-body-color-rgb), .03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg: var(--bs-body-bg);--bs-card-img-overlay-padding: 1rem;--bs-card-group-margin: .75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;inset:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-top,.card-img-bottom{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width: 576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child)>.card-img-top,.card-group>.card:not(:last-child)>.card-header{border-top-right-radius:0}.card-group>.card:not(:last-child)>.card-img-bottom,.card-group>.card:not(:last-child)>.card-footer{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child)>.card-img-top,.card-group>.card:not(:first-child)>.card-header{border-top-left-radius:0}.card-group>.card:not(:first-child)>.card-img-bottom,.card-group>.card:not(:first-child)>.card-footer{border-bottom-left-radius:0}}.accordion{--bs-accordion-color: var(--bs-body-color);--bs-accordion-bg: var(--bs-body-bg);--bs-accordion-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out, border-radius .15s ease;--bs-accordion-border-color: var(--bs-border-color);--bs-accordion-border-width: var(--bs-border-width);--bs-accordion-border-radius: var(--bs-border-radius);--bs-accordion-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x: 1.25rem;--bs-accordion-btn-padding-y: 1rem;--bs-accordion-btn-color: var(--bs-body-color);--bs-accordion-btn-bg: var(--bs-accordion-bg);--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width: 1.25rem;--bs-accordion-btn-icon-transform: rotate(-180deg);--bs-accordion-btn-icon-transition: transform .2s ease-in-out;--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%232e0066' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow: 0 0 0 .25rem rgba(115, 0, 255, .25);--bs-accordion-body-padding-x: 1.25rem;--bs-accordion-body-padding-y: 1rem;--bs-accordion-active-color: var(--bs-primary-text-emphasis);--bs-accordion-active-bg: var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:.85rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion: reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed):after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button:after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion: reduce){.accordion-button:after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-collapse,.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.breadcrumb{--bs-breadcrumb-padding-x: 0;--bs-breadcrumb-padding-y: 0;--bs-breadcrumb-margin-bottom: 1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color: var(--bs-secondary-color);--bs-breadcrumb-item-padding-x: .5rem;--bs-breadcrumb-item-active-color: var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item:before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x: .75rem;--bs-pagination-padding-y: .375rem;--bs-pagination-font-size: .85rem;--bs-pagination-color: var(--bs-link-color);--bs-pagination-bg: var(--bs-body-bg);--bs-pagination-border-width: var(--bs-border-width);--bs-pagination-border-color: var(--bs-border-color);--bs-pagination-border-radius: var(--bs-border-radius);--bs-pagination-hover-color: var(--bs-link-hover-color);--bs-pagination-hover-bg: var(--bs-tertiary-bg);--bs-pagination-hover-border-color: var(--bs-border-color);--bs-pagination-focus-color: var(--bs-link-hover-color);--bs-pagination-focus-bg: var(--bs-secondary-bg);--bs-pagination-focus-box-shadow: 0 0 0 .25rem rgba(115, 0, 255, .25);--bs-pagination-active-color: #fff;--bs-pagination-active-bg: #7300ff;--bs-pagination-active-border-color: #7300ff;--bs-pagination-disabled-color: var(--bs-secondary-color);--bs-pagination-disabled-bg: var(--bs-secondary-bg);--bs-pagination-disabled-border-color: var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion: reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.page-link.active,.active>.page-link{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.page-link.disabled,.disabled>.page-link{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(-1 * var(--bs-border-width))}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x: 1.5rem;--bs-pagination-padding-y: .75rem;--bs-pagination-font-size: 1.0625rem;--bs-pagination-border-radius: var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x: .5rem;--bs-pagination-padding-y: .25rem;--bs-pagination-font-size: .74375rem;--bs-pagination-border-radius: var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x: .65em;--bs-badge-padding-y: .35em;--bs-badge-font-size: .75em;--bs-badge-font-weight: 700;--bs-badge-color: #fff;--bs-badge-border-radius: var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg: transparent;--bs-alert-padding-x: 1rem;--bs-alert-padding-y: 1rem;--bs-alert-margin-bottom: 1rem;--bs-alert-color: inherit;--bs-alert-border-color: transparent;--bs-alert-border: var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius: var(--bs-border-radius);--bs-alert-link-color: inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color: var(--bs-primary-text-emphasis);--bs-alert-bg: var(--bs-primary-bg-subtle);--bs-alert-border-color: var(--bs-primary-border-subtle);--bs-alert-link-color: var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color: var(--bs-secondary-text-emphasis);--bs-alert-bg: var(--bs-secondary-bg-subtle);--bs-alert-border-color: var(--bs-secondary-border-subtle);--bs-alert-link-color: var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color: var(--bs-success-text-emphasis);--bs-alert-bg: var(--bs-success-bg-subtle);--bs-alert-border-color: var(--bs-success-border-subtle);--bs-alert-link-color: var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color: var(--bs-info-text-emphasis);--bs-alert-bg: var(--bs-info-bg-subtle);--bs-alert-border-color: var(--bs-info-border-subtle);--bs-alert-link-color: var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color: var(--bs-warning-text-emphasis);--bs-alert-bg: var(--bs-warning-bg-subtle);--bs-alert-border-color: var(--bs-warning-border-subtle);--bs-alert-link-color: var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color: var(--bs-danger-text-emphasis);--bs-alert-bg: var(--bs-danger-bg-subtle);--bs-alert-border-color: var(--bs-danger-border-subtle);--bs-alert-link-color: var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color: var(--bs-light-text-emphasis);--bs-alert-bg: var(--bs-light-bg-subtle);--bs-alert-border-color: var(--bs-light-border-subtle);--bs-alert-link-color: var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color: var(--bs-dark-text-emphasis);--bs-alert-bg: var(--bs-dark-bg-subtle);--bs-alert-border-color: var(--bs-dark-border-subtle);--bs-alert-link-color: var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:var(--bs-progress-height)}}.progress,.progress-stacked{--bs-progress-height: 1rem;--bs-progress-font-size: .6375rem;--bs-progress-bg: var(--bs-secondary-bg);--bs-progress-border-radius: var(--bs-border-radius);--bs-progress-box-shadow: var(--bs-box-shadow-inset);--bs-progress-bar-color: #fff;--bs-progress-bar-bg: #7300ff;--bs-progress-bar-transition: width .6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion: reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion: reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color: var(--bs-body-color);--bs-list-group-bg: var(--bs-body-bg);--bs-list-group-border-color: var(--bs-border-color);--bs-list-group-border-width: var(--bs-border-width);--bs-list-group-border-radius: var(--bs-border-radius);--bs-list-group-item-padding-x: 1rem;--bs-list-group-item-padding-y: .5rem;--bs-list-group-action-color: var(--bs-secondary-color);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-tertiary-bg);--bs-list-group-action-active-color: var(--bs-body-color);--bs-list-group-action-active-bg: var(--bs-secondary-bg);--bs-list-group-disabled-color: var(--bs-secondary-color);--bs-list-group-disabled-bg: var(--bs-body-bg);--bs-list-group-active-color: #fff;--bs-list-group-active-bg: #7300ff;--bs-list-group-active-border-color: #7300ff;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item:before{content:counters(section,".") ". ";counter-increment:section}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:not(.active):hover,.list-group-item-action:not(.active):focus{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:not(.active):active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width: 576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width: 768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width: 992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width: 1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width: 1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color: var(--bs-primary-text-emphasis);--bs-list-group-bg: var(--bs-primary-bg-subtle);--bs-list-group-border-color: var(--bs-primary-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-primary-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-primary-border-subtle);--bs-list-group-active-color: var(--bs-primary-bg-subtle);--bs-list-group-active-bg: var(--bs-primary-text-emphasis);--bs-list-group-active-border-color: var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color: var(--bs-secondary-text-emphasis);--bs-list-group-bg: var(--bs-secondary-bg-subtle);--bs-list-group-border-color: var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-secondary-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-secondary-border-subtle);--bs-list-group-active-color: var(--bs-secondary-bg-subtle);--bs-list-group-active-bg: var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color: var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color: var(--bs-success-text-emphasis);--bs-list-group-bg: var(--bs-success-bg-subtle);--bs-list-group-border-color: var(--bs-success-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-success-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-success-border-subtle);--bs-list-group-active-color: var(--bs-success-bg-subtle);--bs-list-group-active-bg: var(--bs-success-text-emphasis);--bs-list-group-active-border-color: var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color: var(--bs-info-text-emphasis);--bs-list-group-bg: var(--bs-info-bg-subtle);--bs-list-group-border-color: var(--bs-info-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-info-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-info-border-subtle);--bs-list-group-active-color: var(--bs-info-bg-subtle);--bs-list-group-active-bg: var(--bs-info-text-emphasis);--bs-list-group-active-border-color: var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color: var(--bs-warning-text-emphasis);--bs-list-group-bg: var(--bs-warning-bg-subtle);--bs-list-group-border-color: var(--bs-warning-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-warning-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-warning-border-subtle);--bs-list-group-active-color: var(--bs-warning-bg-subtle);--bs-list-group-active-bg: var(--bs-warning-text-emphasis);--bs-list-group-active-border-color: var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color: var(--bs-danger-text-emphasis);--bs-list-group-bg: var(--bs-danger-bg-subtle);--bs-list-group-border-color: var(--bs-danger-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-danger-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-danger-border-subtle);--bs-list-group-active-color: var(--bs-danger-bg-subtle);--bs-list-group-active-bg: var(--bs-danger-text-emphasis);--bs-list-group-active-border-color: var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color: var(--bs-light-text-emphasis);--bs-list-group-bg: var(--bs-light-bg-subtle);--bs-list-group-border-color: var(--bs-light-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-light-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-light-border-subtle);--bs-list-group-active-color: var(--bs-light-bg-subtle);--bs-list-group-active-bg: var(--bs-light-text-emphasis);--bs-list-group-active-border-color: var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color: var(--bs-dark-text-emphasis);--bs-list-group-bg: var(--bs-dark-bg-subtle);--bs-list-group-border-color: var(--bs-dark-border-subtle);--bs-list-group-action-hover-color: var(--bs-emphasis-color);--bs-list-group-action-hover-bg: var(--bs-dark-border-subtle);--bs-list-group-action-active-color: var(--bs-emphasis-color);--bs-list-group-action-active-bg: var(--bs-dark-border-subtle);--bs-list-group-active-color: var(--bs-dark-bg-subtle);--bs-list-group-active-bg: var(--bs-dark-text-emphasis);--bs-list-group-active-border-color: var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color: #000;--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");--bs-btn-close-opacity: .5;--bs-btn-close-hover-opacity: .75;--bs-btn-close-focus-shadow: 0 0 0 .25rem rgba(115, 0, 255, .25);--bs-btn-close-focus-opacity: 1;--bs-btn-close-disabled-opacity: .25;box-sizing:content-box;width:1em;height:1em;padding:.25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;filter:var(--bs-btn-close-filter);border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close:disabled,.btn-close.disabled{pointer-events:none;-webkit-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{--bs-btn-close-filter: invert(1) grayscale(100%) brightness(200%)}:root,[data-bs-theme=light]{--bs-btn-close-filter: }.toast{--bs-toast-zindex: 1090;--bs-toast-padding-x: .75rem;--bs-toast-padding-y: .5rem;--bs-toast-spacing: 1.5rem;--bs-toast-max-width: 350px;--bs-toast-font-size: .875rem;--bs-toast-color: ;--bs-toast-bg: rgba(var(--bs-body-bg-rgb), .85);--bs-toast-border-width: var(--bs-border-width);--bs-toast-border-color: var(--bs-border-color-translucent);--bs-toast-border-radius: var(--bs-border-radius);--bs-toast-box-shadow: var(--bs-box-shadow);--bs-toast-header-color: var(--bs-secondary-color);--bs-toast-header-bg: rgba(var(--bs-body-bg-rgb), .85);--bs-toast-header-border-color: var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex: 1090;position:absolute;z-index:var(--bs-toast-zindex);width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex: 1055;--bs-modal-width: 500px;--bs-modal-padding: 1rem;--bs-modal-margin: .5rem;--bs-modal-color: var(--bs-body-color);--bs-modal-bg: var(--bs-body-bg);--bs-modal-border-color: var(--bs-border-color-translucent);--bs-modal-border-width: var(--bs-border-width);--bs-modal-border-radius: var(--bs-border-radius-lg);--bs-modal-box-shadow: var(--bs-box-shadow-sm);--bs-modal-inner-border-radius: calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x: 1rem;--bs-modal-header-padding-y: 1rem;--bs-modal-header-padding: 1rem 1rem;--bs-modal-header-border-color: var(--bs-border-color);--bs-modal-header-border-width: var(--bs-border-width);--bs-modal-title-line-height: 1.5;--bs-modal-footer-gap: .5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color: var(--bs-border-color);--bs-modal-footer-border-width: var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transform:translateY(-50px);transition:transform .3s ease-out}@media (prefers-reduced-motion: reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex: 1050;--bs-backdrop-bg: #000;--bs-backdrop-opacity: .5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin-top:calc(-.5 * var(--bs-modal-header-padding-y));margin-right:calc(-.5 * var(--bs-modal-header-padding-x));margin-bottom:calc(-.5 * var(--bs-modal-header-padding-y));margin-left:auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width: 576px){.modal{--bs-modal-margin: 1.75rem;--bs-modal-box-shadow: var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width: 300px}}@media (min-width: 992px){.modal-lg,.modal-xl{--bs-modal-width: 800px}}@media (min-width: 1200px){.modal-xl{--bs-modal-width: 1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header,.modal-fullscreen .modal-footer{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width: 575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header,.modal-fullscreen-sm-down .modal-footer{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width: 767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header,.modal-fullscreen-md-down .modal-footer{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width: 991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header,.modal-fullscreen-lg-down .modal-footer{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width: 1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header,.modal-fullscreen-xl-down .modal-footer{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width: 1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header,.modal-fullscreen-xxl-down .modal-footer{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex: 1080;--bs-tooltip-max-width: 200px;--bs-tooltip-padding-x: .5rem;--bs-tooltip-padding-y: .25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size: .74375rem;--bs-tooltip-color: var(--bs-body-bg);--bs-tooltip-bg: var(--bs-emphasis-color);--bs-tooltip-border-radius: var(--bs-border-radius);--bs-tooltip-opacity: .9;--bs-tooltip-arrow-width: .8rem;--bs-tooltip-arrow-height: .4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:Rock Salt,cursive;font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow:before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-top .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-top .tooltip-arrow:before,.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow:before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-end .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-end .tooltip-arrow:before,.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow:before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-bottom .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-bottom .tooltip-arrow:before,.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow:before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-start .tooltip-arrow,.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-start .tooltip-arrow:before,.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow:before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex: 1070;--bs-popover-max-width: 276px;--bs-popover-font-size: .74375rem;--bs-popover-bg: var(--bs-body-bg);--bs-popover-border-width: var(--bs-border-width);--bs-popover-border-color: var(--bs-border-color-translucent);--bs-popover-border-radius: var(--bs-border-radius-lg);--bs-popover-inner-border-radius: calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow: var(--bs-box-shadow);--bs-popover-header-padding-x: 1rem;--bs-popover-header-padding-y: .5rem;--bs-popover-header-font-size: .85rem;--bs-popover-header-color: inherit;--bs-popover-header-bg: var(--bs-secondary-bg);--bs-popover-body-padding-x: 1rem;--bs-popover-body-padding-y: 1rem;--bs-popover-body-color: var(--bs-body-color);--bs-popover-arrow-width: 1rem;--bs-popover-arrow-height: .5rem;--bs-popover-arrow-border: var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:Rock Salt,cursive;font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow:before,.popover .popover-arrow:after{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-top>.popover-arrow,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-top>.popover-arrow:before,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow:before,.bs-popover-top>.popover-arrow:after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow:after{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-top>.popover-arrow:before,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow:before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-top>.popover-arrow:after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow:after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-end>.popover-arrow,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-end>.popover-arrow:before,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow:before,.bs-popover-end>.popover-arrow:after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow:after{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-end>.popover-arrow:before,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow:before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-end>.popover-arrow:after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow:after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-bottom>.popover-arrow,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-bottom>.popover-arrow:before,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow:before,.bs-popover-bottom>.popover-arrow:after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow:after{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-bottom>.popover-arrow:before,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow:before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-bottom>.popover-arrow:after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow:after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-bottom .popover-header:before,.bs-popover-auto[data-popper-placement^=bottom] .popover-header:before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-start>.popover-arrow,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-start>.popover-arrow:before,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow:before,.bs-popover-start>.popover-arrow:after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow:after{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-start>.popover-arrow:before,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow:before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-start>.popover-arrow:after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow:after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner:after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion: reduce){.carousel-item{transition:none}}.carousel-item.active,.carousel-item-next,.carousel-item-prev{display:block}.carousel-item-next:not(.carousel-item-start),.active.carousel-item-end{transform:translate(100%)}.carousel-item-prev:not(.carousel-item-end),.active.carousel-item-start{transform:translate(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item.active,.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end{z-index:1;opacity:1}.carousel-fade .active.carousel-item-start,.carousel-fade .active.carousel-item-end{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion: reduce){.carousel-fade .active.carousel-item-start,.carousel-fade .active.carousel-item-end{transition:none}}.carousel-control-prev,.carousel-control-next{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:none;filter:var(--bs-carousel-control-icon-filter);border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion: reduce){.carousel-control-prev,.carousel-control-next{transition:none}}.carousel-control-prev:hover,.carousel-control-prev:focus,.carousel-control-next:hover,.carousel-control-next:focus{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-prev-icon,.carousel-control-next-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:var(--bs-carousel-indicator-active-bg);background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion: reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:var(--bs-carousel-caption-color);text-align:center}.carousel-dark{--bs-carousel-indicator-active-bg: #000;--bs-carousel-caption-color: #000;--bs-carousel-control-icon-filter: invert(1) grayscale(100)}:root,[data-bs-theme=light]{--bs-carousel-indicator-active-bg: #fff;--bs-carousel-caption-color: #fff;--bs-carousel-control-icon-filter: }.spinner-grow,.spinner-border{display:inline-block;flex-shrink:0;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width: 2rem;--bs-spinner-height: 2rem;--bs-spinner-vertical-align: -.125em;--bs-spinner-border-width: .25em;--bs-spinner-animation-speed: .75s;--bs-spinner-animation-name: spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width: 1rem;--bs-spinner-height: 1rem;--bs-spinner-border-width: .2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width: 2rem;--bs-spinner-height: 2rem;--bs-spinner-vertical-align: -.125em;--bs-spinner-animation-speed: .75s;--bs-spinner-animation-name: spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width: 1rem;--bs-spinner-height: 1rem}@media (prefers-reduced-motion: reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed: 1.5s}}.offcanvas,.offcanvas-xxl,.offcanvas-xl,.offcanvas-lg,.offcanvas-md,.offcanvas-sm{--bs-offcanvas-zindex: 1045;--bs-offcanvas-width: 400px;--bs-offcanvas-height: 30vh;--bs-offcanvas-padding-x: 1rem;--bs-offcanvas-padding-y: 1rem;--bs-offcanvas-color: var(--bs-body-color);--bs-offcanvas-bg: var(--bs-body-bg);--bs-offcanvas-border-width: var(--bs-border-width);--bs-offcanvas-border-color: var(--bs-border-color-translucent);--bs-offcanvas-box-shadow: var(--bs-box-shadow-sm);--bs-offcanvas-transition: transform .3s ease-in-out;--bs-offcanvas-title-line-height: 1.5}@media (max-width: 575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width: 575.98px) and (prefers-reduced-motion: reduce){.offcanvas-sm{transition:none}}@media (max-width: 575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.showing,.offcanvas-sm.show:not(.hiding){transform:none}.offcanvas-sm.showing,.offcanvas-sm.hiding,.offcanvas-sm.show{visibility:visible}}@media (min-width: 576px){.offcanvas-sm{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width: 767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width: 767.98px) and (prefers-reduced-motion: reduce){.offcanvas-md{transition:none}}@media (max-width: 767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.showing,.offcanvas-md.show:not(.hiding){transform:none}.offcanvas-md.showing,.offcanvas-md.hiding,.offcanvas-md.show{visibility:visible}}@media (min-width: 768px){.offcanvas-md{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width: 991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width: 991.98px) and (prefers-reduced-motion: reduce){.offcanvas-lg{transition:none}}@media (max-width: 991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.showing,.offcanvas-lg.show:not(.hiding){transform:none}.offcanvas-lg.showing,.offcanvas-lg.hiding,.offcanvas-lg.show{visibility:visible}}@media (min-width: 992px){.offcanvas-lg{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width: 1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width: 1199.98px) and (prefers-reduced-motion: reduce){.offcanvas-xl{transition:none}}@media (max-width: 1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.showing,.offcanvas-xl.show:not(.hiding){transform:none}.offcanvas-xl.showing,.offcanvas-xl.hiding,.offcanvas-xl.show{visibility:visible}}@media (min-width: 1200px){.offcanvas-xl{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width: 1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width: 1399.98px) and (prefers-reduced-motion: reduce){.offcanvas-xxl{transition:none}}@media (max-width: 1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.showing,.offcanvas-xxl.show:not(.hiding){transform:none}.offcanvas-xxl.showing,.offcanvas-xxl.hiding,.offcanvas-xxl.show{visibility:visible}}@media (min-width: 1400px){.offcanvas-xxl{--bs-offcanvas-height: auto;--bs-offcanvas-border-width: 0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion: reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translate(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.showing,.offcanvas.show:not(.hiding){transform:none}.offcanvas.showing,.offcanvas.hiding,.offcanvas.show{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--bs-offcanvas-padding-y));margin-right:calc(-.5 * var(--bs-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--bs-offcanvas-padding-y));margin-left:auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn:before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,#000c,#000 95%);mask-image:linear-gradient(130deg,#000 55%,#000c,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{to{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix:after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity, 1))!important}.text-bg-secondary{color:#000!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity, 1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity, 1))!important}.text-bg-info{color:#fff!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity, 1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity, 1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity, 1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity, 1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity, 1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity, 1))!important}.link-primary:hover,.link-primary:focus{color:RGBA(92,0,204,var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(92,0,204,var(--bs-link-underline-opacity, 1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity, 1))!important}.link-secondary:hover,.link-secondary:focus{color:RGBA(51,255,61,var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(51,255,61,var(--bs-link-underline-opacity, 1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity, 1))!important}.link-success:hover,.link-success:focus{color:RGBA(20,108,67,var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity, 1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity, 1))!important}.link-info:hover,.link-info:focus{color:RGBA(41,0,136,var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(41,0,136,var(--bs-link-underline-opacity, 1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity, 1))!important}.link-warning:hover,.link-warning:focus{color:RGBA(255,205,57,var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity, 1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity, 1))!important}.link-danger:hover,.link-danger:focus{color:RGBA(176,42,55,var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity, 1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity, 1))!important}.link-light:hover,.link-light:focus{color:RGBA(249,250,251,var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity, 1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity, 1))!important}.link-dark:hover,.link-dark:focus{color:RGBA(26,30,33,var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity, 1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity, 1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity, 1))!important}.link-body-emphasis:hover,.link-body-emphasis:focus{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity, .75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity, .75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x, 0) var(--bs-focus-ring-y, 0) var(--bs-focus-ring-blur, 0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity, .5));text-underline-offset:.25em;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion: reduce){.icon-link>.bi{transition:none}}.icon-link-hover:hover>.bi,.icon-link-hover:focus-visible>.bi{transform:var(--bs-icon-link-transform, translate3d(.25em, 0, 0))}.ratio{position:relative;width:100%}.ratio:before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio: 100%}.ratio-4x3{--bs-aspect-ratio: 75%}.ratio-16x9{--bs-aspect-ratio: 56.25%}.ratio-21x9{--bs-aspect-ratio: 42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:sticky;top:0;z-index:1020}.sticky-bottom{position:sticky;bottom:0;z-index:1020}@media (min-width: 576px){.sticky-sm-top{position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width: 768px){.sticky-md-top{position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width: 992px){.sticky-lg-top{position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width: 1200px){.sticky-xl-top{position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:sticky;bottom:0;z-index:1020}}@media (min-width: 1400px){.sticky-xxl-top{position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden:not(caption),.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption){position:absolute!important}.visually-hidden *,.visually-hidden-focusable:not(:focus):not(:focus-within) *{overflow:hidden!important}.stretched-link:after{position:absolute;inset:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{object-fit:contain!important}.object-fit-cover{object-fit:cover!important}.object-fit-fill{object-fit:fill!important}.object-fit-scale{object-fit:scale-down!important}.object-fit-none{object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color: rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color: rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color: rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color: rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color: rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color: rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color: rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color: rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translate(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity: 1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity: 1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity: 1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity: 1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity: 1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity: 1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity: 1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity: 1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity: 1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity: 1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity: .1}.border-opacity-25{--bs-border-opacity: .25}.border-opacity-50{--bs-border-opacity: .5}.border-opacity-75{--bs-border-opacity: .75}.border-opacity-100{--bs-border-opacity: 1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{column-gap:0!important}.column-gap-1{column-gap:.25rem!important}.column-gap-2{column-gap:.5rem!important}.column-gap-3{column-gap:1rem!important}.column-gap-4{column-gap:1.5rem!important}.column-gap-5{column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.3375rem + 1.05vw)!important}.fs-2{font-size:calc(1.295rem + .54vw)!important}.fs-3{font-size:calc(1.27375rem + .285vw)!important}.fs-4{font-size:calc(1.2525rem + .03vw)!important}.fs-5{font-size:1.0625rem!important}.fs-6{font-size:.85rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity: 1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity: 1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity: 1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity: 1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity: 1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity: 1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity: 1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity: 1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity: 1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity: 1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity: 1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity: 1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity: 1;color:#00000080!important}.text-white-50{--bs-text-opacity: 1;color:#ffffff80!important}.text-body-secondary{--bs-text-opacity: 1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity: 1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity: 1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity: 1;color:inherit!important}.text-opacity-25{--bs-text-opacity: .25}.text-opacity-50{--bs-text-opacity: .5}.text-opacity-75{--bs-text-opacity: .75}.text-opacity-100{--bs-text-opacity: 1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10,.link-opacity-10-hover:hover{--bs-link-opacity: .1}.link-opacity-25,.link-opacity-25-hover:hover{--bs-link-opacity: .25}.link-opacity-50,.link-opacity-50-hover:hover{--bs-link-opacity: .5}.link-opacity-75,.link-opacity-75-hover:hover{--bs-link-opacity: .75}.link-opacity-100,.link-opacity-100-hover:hover{--bs-link-opacity: 1}.link-offset-1,.link-offset-1-hover:hover{text-underline-offset:.125em!important}.link-offset-2,.link-offset-2-hover:hover{text-underline-offset:.25em!important}.link-offset-3,.link-offset-3-hover:hover{text-underline-offset:.375em!important}.link-underline-primary{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity: 1;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity, 1))!important}.link-underline-opacity-0,.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity: 0}.link-underline-opacity-10,.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity: .1}.link-underline-opacity-25,.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity: .25}.link-underline-opacity-50,.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity: .5}.link-underline-opacity-75,.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity: .75}.link-underline-opacity-100,.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity: 1}.bg-primary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity: 1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity: 1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity: 1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity: 1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity: 1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity: 1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity: 1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity: 1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity: 1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity: 1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity: 1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity: .1}.bg-opacity-25{--bs-bg-opacity: .25}.bg-opacity-50{--bs-bg-opacity: .5}.bg-opacity-75{--bs-bg-opacity: .75}.bg-opacity-100{--bs-bg-opacity: 1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width: 576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{object-fit:contain!important}.object-fit-sm-cover{object-fit:cover!important}.object-fit-sm-fill{object-fit:fill!important}.object-fit-sm-scale{object-fit:scale-down!important}.object-fit-sm-none{object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{column-gap:0!important}.column-gap-sm-1{column-gap:.25rem!important}.column-gap-sm-2{column-gap:.5rem!important}.column-gap-sm-3{column-gap:1rem!important}.column-gap-sm-4{column-gap:1.5rem!important}.column-gap-sm-5{column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width: 768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{object-fit:contain!important}.object-fit-md-cover{object-fit:cover!important}.object-fit-md-fill{object-fit:fill!important}.object-fit-md-scale{object-fit:scale-down!important}.object-fit-md-none{object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{column-gap:0!important}.column-gap-md-1{column-gap:.25rem!important}.column-gap-md-2{column-gap:.5rem!important}.column-gap-md-3{column-gap:1rem!important}.column-gap-md-4{column-gap:1.5rem!important}.column-gap-md-5{column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width: 992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{object-fit:contain!important}.object-fit-lg-cover{object-fit:cover!important}.object-fit-lg-fill{object-fit:fill!important}.object-fit-lg-scale{object-fit:scale-down!important}.object-fit-lg-none{object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{column-gap:0!important}.column-gap-lg-1{column-gap:.25rem!important}.column-gap-lg-2{column-gap:.5rem!important}.column-gap-lg-3{column-gap:1rem!important}.column-gap-lg-4{column-gap:1.5rem!important}.column-gap-lg-5{column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width: 1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{object-fit:contain!important}.object-fit-xl-cover{object-fit:cover!important}.object-fit-xl-fill{object-fit:fill!important}.object-fit-xl-scale{object-fit:scale-down!important}.object-fit-xl-none{object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{column-gap:0!important}.column-gap-xl-1{column-gap:.25rem!important}.column-gap-xl-2{column-gap:.5rem!important}.column-gap-xl-3{column-gap:1rem!important}.column-gap-xl-4{column-gap:1.5rem!important}.column-gap-xl-5{column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width: 1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{object-fit:contain!important}.object-fit-xxl-cover{object-fit:cover!important}.object-fit-xxl-fill{object-fit:fill!important}.object-fit-xxl-scale{object-fit:scale-down!important}.object-fit-xxl-none{object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{column-gap:0!important}.column-gap-xxl-1{column-gap:.25rem!important}.column-gap-xxl-2{column-gap:.5rem!important}.column-gap-xxl-3{column-gap:1rem!important}.column-gap-xxl-4{column-gap:1.5rem!important}.column-gap-xxl-5{column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width: 1200px){.fs-1{font-size:2.125rem!important}.fs-2{font-size:1.7rem!important}.fs-3{font-size:1.4875rem!important}.fs-4{font-size:1.275rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}}.mqc_small_space{padding:0 1px}@media (min-width: 768px){.mainpage{margin-left:250px;transition:margin-left .5s}.mainpage.hidden-nav{margin-left:0}}.side-nav-wrapper{height:100%;background-color:var(--bs-secondary-bg)}@media (min-width: 768px){.side-nav-wrapper{position:fixed}}.side-nav{border-bottom:1px solid var(--bs-border-color)}@media (min-width: 768px){.side-nav{height:100%;width:250px;border-bottom:none;border-right:1px solid var(--bs-border-color);overflow:auto;padding-bottom:1rem;transition:margin-left .5s;margin-left:0}.side-nav.hidden-nav{margin-left:-250px}}.side-nav .navbar-toggler{position:absolute;right:10px;top:15px}.side-nav .side-nav-title{text-align:center;font-size:1.02rem;font-weight:400;border-top:1px solid var(--bs-border-color);margin:0}.side-nav .side-nav-title a{color:var(--bs-body-color);text-decoration:none;padding:.75rem 0;display:block}@media (min-width: 576px){.side-nav .mqc-nav{border-bottom:1px solid var(--bs-border-color)}}.side-nav .mqc-nav,.side-nav .mqc-nav ul{margin:0;padding:0;list-style-type:none}.side-nav .mqc-nav li{border-top:1px solid var(--bs-border-color)}.side-nav .mqc-nav li a{display:block;text-decoration:none}.side-nav .mqc-nav li a:hover,.side-nav .mqc-nav li a:active,.side-nav .mqc-nav li a:focus{background-color:var(--bs-secondary-bg)}.side-nav .mqc-nav li a.nav-l1{padding:.6rem}.side-nav .mqc-nav li a.nav-l2{padding:.3rem .6rem .3rem .9rem;font-size:.765rem;color:var(--bs-secondary-emphasis)}.side-nav .mqc-nav .mobile-toolbox-nav{background-color:var(--bs-tertiary-bg)}.side-nav .mqc-nav .mobile-toolbox-nav:hover,.side-nav .mqc-nav .mobile-toolbox-nav:active,.side-nav .mqc-nav .mobile-toolbox-nav:focus{background-color:var(--bs-secondary-bg)}#side-nav-handle{position:absolute;top:50%;right:-14px;height:50px;width:15px;padding-top:14px;background-color:var(--bs-tertiary-bg);cursor:pointer}#side-nav-handle svg{color:var(--bs-tertiary-color);transition:transform .6s ease}#mqc_hide_welcome_btn{color:inherit;opacity:.4;transition:opacity .15s linear}#mqc_hide_welcome_btn:hover,#mqc_hide_welcome_btn:active,#mqc_hide_welcome_btn:focus{opacity:1}#analysis_dirs_wrapper{max-height:80px;overflow:auto;margin-bottom:10px}.mqc-section h3,.mqc-section .h3{margin-top:1.5rem}.mqc-module-section-first .mqc-section h3,.mqc-module-section-first .mqc-section .h3{margin-top:0}.report_comment,.mqc-section-comment{border-left:5px solid var(--bs-info);background-color:var(--bs-info-bg-subtle);padding:.5rem 1rem;margin:0 0 1rem;font-size:1.02rem}.mqc-section-description>p{margin-bottom:.5rem}.mqc-toplink,.mqc-toplink:visited{position:fixed;bottom:1.1rem;right:.8rem;padding:2px 1px;z-index:900;background-color:var(--bs-tertiary-bg);color:var(--bs-tertiary-color);text-decoration:none}.mqc-toplink span,.mqc-toplink:visited span{display:none;padding-left:.5rem}.mqc-toplink:hover,.mqc-toplink:visited:hover{background-color:var(--bs-secondary-bg);color:var(--bs-body-color)}.mqc-toplink:hover span,.mqc-toplink:visited:hover span{display:inline;color:var(--bs-tertiary-color)}.footer{color:var(--bs-tertiary-color);background-color:var(--bs-tertiary-bg);font-size:.765rem}.footer .container-fluid,.footer .container-sm,.footer .container-md,.footer .container-lg,.footer .container-xl,.footer .container-xxl{padding-right:3.5rem}.footer p{margin:0}.footer p a{color:var(--bs-tertiary-color);text-decoration:underline}.footer .seqera-logo{width:120px}.seqera-logo svg{color:var(--bs-emphasis-color);transition:margin .3s,opacity .3s;opacity:80%;margin-bottom:0}.seqera-logo:hover svg{margin-bottom:.2rem;opacity:100%}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:var(--bs-body-bg);border-left:1px solid var(--bs-border-color);border-right:1px solid var(--bs-border-color)}::-webkit-scrollbar-thumb{background:var(--bs-secondary-bg)}::-webkit-scrollbar-thumb:active{background:var(--bs-tertiary-bg)}#mqc-toolbox .mqc-toolbox-label{margin-left:-5px;transform:rotate(270deg) translate(-20px)}#mqc-toolbox .mqc-toolbox-buttons{position:absolute;left:-2.5rem;top:50px;bottom:0;visibility:visible;overflow-y:auto;scrollbar-width:none}#mqc-toolbox .mqc-toolbox-buttons ::-webkit-scrollbar,#mqc-toolbox .mqc-toolbox-buttons ::-webkit-scrollbar-track,#mqc-toolbox .mqc-toolbox-buttons ::-webkit-scrollbar-thumb{display:none!important}#mqc-toolbox .mqc-toolbox-buttons .list-group{width:2.5rem;padding-bottom:1rem;border-right:0;border-top-right-radius:0;border-bottom-right-radius:0}#mqc-toolbox .mqc-toolbox-buttons .list-group .list-group-item{background-color:var(--bs-secondary-bg);padding:.5rem;text-align:center;margin-top:.5rem;border-top-width:1px;border-top-left-radius:.375rem;border-bottom-left-radius:.375rem;border-right:0}#mqc-toolbox .mqc-toolbox-buttons .list-group .list-group-item.active{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:var(--bs-secondary)}#mqc-toolbox .mqc-toolbox-buttons .list-group .list-group-item.in_use{color:var(--bs-primary);border-color:var(--bs-primary);background-color:var(--bs-primary-bg-subtle)}#mqc-toolbox .mqc-toolbox-buttons .list-group .list-group-item svg{width:20px;height:20px}#mqc-toolbox blockquote{font-size:.8rem}.mqc_filters{margin:10px 0;padding:0;list-style-type:none;font-size:.765rem}.mqc_filters li .hc_handle{padding:9px 4px;cursor:pointer;color:var(--bs-secondary)}.mqc_filters li:hover,.mqc_filters li:focus,.mqc_filters li:has(:focus){background-color:var(--bs-tertiary-bg)}.mqc_filters li .f_text{border:0;border-bottom:1px solid var(--bs-border-color);padding:5px 0 5px 10px;margin:0;background-color:transparent;outline:none;color:inherit}#mqc_renamesamples_bulk_form textarea{font-size:8px}input.form-control[type=color]{padding:5px;width:30px;cursor:pointer}.table tr td{font-size:.765rem;height:30px}.mqc-table-responsive{overflow:auto}.mqc-table-responsive.mqc-table-collapse{max-height:500px}.mqc-table-expand{text-align:center;color:var(--bs-tertiary-color);padding:5px;cursor:pointer;background-color:var(--bs-secondary-bg);transition:background-color .2s}.mqc-table-expand:hover,.mqc-table-expand:focus,.mqc-table-expand:active{background-color:var(--bs-tertiary-bg)}.mqc_table_numrows_text{padding:5px 10px;font-size:12px;vertical-align:middle}.mqc_table{margin-top:.5rem}.mqc_table tr,.mqc_table td{height:100%}.mqc_table th{white-space:nowrap}.mqc_table .rowheader{border-left:none}.mqc_table .data-coloured{padding:0}.mqc_table .wrapper{display:inline-block;position:relative;height:100%;width:100%;z-index:-10}.mqc_table thead th,.mqc_table thead td{background-color:var(--bs-body-bg)}.mqc_table thead th{cursor:pointer;border-bottom:0;box-shadow:inset 0 -2px 0 0 var(--bs-border-color)}.mqc_table thead th .tablesorter-header-inner:after{content:"";display:inline-block;width:0;height:0;margin-left:4px;vertical-align:middle;border-right:4px solid transparent;border-left:4px solid transparent}.mqc_table thead th.tablesorter-headerAsc{background-color:var(--bs-secondary-bg);color:var(--bs-primary);border-bottom:2px solid var(--bs-primary)}.mqc_table thead th.tablesorter-headerAsc .tablesorter-header-inner:after{border-bottom:4px dashed}.mqc_table thead th.tablesorter-headerDesc{background-color:var(--bs-secondary-bg);color:var(--bs-primary);border-bottom:2px solid var(--bs-primary)}.mqc_table thead th.tablesorter-headerDesc .tablesorter-header-inner:after{border-top:4px dashed}.mqc_table tbody tr td .wrapper .val{z-index:-1;white-space:nowrap}.mqc_table tbody tr td .wrapper .val .label{font-size:100%;display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;vertical-align:middle;border-radius:10px}.mqc_table .bar{display:block;position:absolute;top:0;left:0;bottom:0;background-color:var(--bs-tertiary-bg);z-index:-1}.mqc_table .val{display:block;padding:5px;left:0}.sorthandle{border-right:none;font-weight:700;text-align:center}tbody .sorthandle{cursor:pointer;color:var(--bs-tertiary-color)}.mqc_config_modal_table tbody .sorthandle{color:var(--bs-tertiary-color)}.table.mqc_table>thead>tr>th{cursor:pointer;border-bottom:0;-webkit-box-shadow:inset 0px -2px 0px 0 var(--bs-border-color);-moz-box-shadow:inset 0px -2px 0px 0 var(--bs-border-color);box-shadow:inset 0 -2px 0 0 var(--bs-border-color)}tr.expandable-row-primary{cursor:pointer}tr.expandable-row-primary .expandable-row-caret{background:url('data:image/svg+xml;utf8,') 50%/24px 24px;transform:rotate(-90deg)}tr.expandable-row-primary.expanded .expandable-row-caret{transform:rotate(0)}tr.expandable-row-secondary{background-color:#00000014}tr.expandable-row-secondary-hidden{visibility:collapse}@supports (-webkit-hyphens: none){tr.expandable-row-secondary-hidden{display:none}}tr.sample-hidden,tr.row-empty,th.column-hidden,td.column-hidden{display:none}.mqc_mplplot{border:1px solid var(--bs-border-color);margin-top:0;width:100%}.mqc_mplplot img{max-width:100%;height:auto}.hc-plot{height:500px;width:100%}.hc-plot.not_rendered{background-color:var(--bs-tertiary-bg);text-align:center}.hc-plot.not_rendered small,.hc-plot.not_rendered .small{display:inline-block;font-style:italic;padding-top:40px;color:var(--bs-tertiary-color)}.hc-plot .render_plot{margin-top:40px}.hc-plot-wrapper{width:100%;height:512px;position:relative;border:1px solid var(--bs-border-color);margin-top:5px}.hc-plot-handle{position:absolute;width:100%;height:10px;background-color:var(--bs-tertiary-bg);cursor:row-resize;border-top:1px solid var(--bs-border-color);transition:background-color .1s;border-bottom:2px solid var(--bs-body-bg);bottom:-3px}.hc-plot-handle span{display:block;height:1px;width:20px;margin:1px auto;background-color:var(--bs-tertiary-color)}.hc-plot-handle:hover{background-color:var(--bs-secondary-bg)}.hc-plot-handle:hover span{background-color:var(--bs-tertiary-color)}.mqc_hcplot_range_sliders{display:inline-block}.mqc_hcplot_range_sliders div{display:inline-block;white-space:nowrap}.mqc_hcplot_range_sliders input{display:inline-block;width:200px}.mqc_hcplot_range_sliders input.form-control{width:100px}.mqc_hcplot_yaxis_limit_toggle{float:right;font-size:11px;margin-top:-26px}.mqc-custom-content-image img{max-width:100%}g.annotation{position:absolute!important}.created-with-multiqc{position:absolute;bottom:7px;right:4px;margin-bottom:0;padding:0;color:var(--bs-tertiary-color);font-size:8px;font-family:Rock Salt,cursive}.ai-summary li p:last-of-type{margin-bottom:0}.ai-summary .ai-summary-disclaimer{color:var(--bs-tertiary-color);font-size:.765rem}.ai-summary .ai-summary-expand svg{transition:all .2s ease-in-out}.ai-summary sample{cursor:pointer;border-bottom:2px dashed var(--bs-secondary);padding:1px 3px;transition:all .2s ease-in-out}.ai-summary sample:hover{opacity:.9}.ai-summary sample.text-green{color:var(--bs-success-text-emphasis);border-color:var(--bs-success);background:var(--bs-success-bg-subtle)}.ai-summary sample.text-green:hover{background:var(--bs-success);color:var(--bs-white)}.ai-summary sample.text-yellow{color:var(--bs-warning-text-emphasis);border-color:var(--bs-warning);background:var(--bs-warning-bg-subtle)}.ai-summary sample.text-yellow:hover{background:var(--bs-warning);color:var(--bs-text-dark)}.ai-summary sample.text-red{color:var(--bs-danger-text-emphasis);border-color:var(--bs-danger);background:var(--bs-danger-bg-subtle)}.ai-summary sample.text-red:hover{background:var(--bs-danger);color:var(--bs-white)}.ai-loading{margin:15px 0;font-style:italic;color:var(--bs-secondary)}.ai-generate-button svg{vertical-align:baseline}.btn-xs{padding:2px 6px;font-size:.6375rem}.side-nav-logo{text-align:center;margin:0;padding:.25rem 0 .5rem}.side-nav-logo svg{height:26px;width:100%}#page_title .multiqc-logo-wrapper{max-width:320px;height:auto;width:auto}#page_title .multiqc-logo-wrapper svg{width:100%;height:100%}.multiqc-logo-text{color:#160f26}[data-bs-theme=dark] .multiqc-logo-text{color:#fff}@media print{.side-nav-wrapper,.mqc-toolbox,.mqc_table_control_buttons,.mqc-table-expand,.mqc-section-plot .text-info,button,.btn{display:none}.mainpage{padding:20px}.footer{background-color:transparent}#analysis_dirs_wrapper,.mqc-table-responsive.mqc-table-collapse{max-height:none!important}.mqc_table thead{transform:none!important}.mqc-module-section-first,.mqc-section{page-break-inside:avoid}.mqc_table .wrapper{z-index:0}.multiqc_logo,.custom_logo{width:200px}td,span.bar{-webkit-print-color-adjust:exact;color-adjust:exact}.dl-horizontal{display:flex;flex-wrap:wrap}.dl-horizontal dt{flex:0 0 34%;float:none;width:300px;font-weight:900}.dl-horizontal dd{flex:1 0 34%;margin-left:0}a[href]:after{content:none!important}.table.mqc_table{table-layout:fixed}.mqc_table th{white-space:normal;font-weight:900}.table>thead>tr>th{vertical-align:top}}.mqc-status-progress-wrapper{display:inline-block;margin-left:1rem;width:150px}.mqc-status-progress-wrapper .progress{cursor:pointer}.popover-mqc-status .popover-body{max-height:300px;overflow-y:auto}.popover-mqc-status.popover-success{--bs-popover-header-bg: var(--bs-success-bg-subtle);--bs-popover-header-color: var(--bs-success-text-emphasis)}.popover-mqc-status.popover-warning{--bs-popover-header-bg: var(--bs-warning-bg-subtle);--bs-popover-header-color: var(--bs-warning-text-emphasis)}.popover-mqc-status.popover-danger{--bs-popover-header-bg: var(--bs-danger-bg-subtle);--bs-popover-header-color: var(--bs-danger-text-emphasis)}body{background:linear-gradient(90deg,#833ab4,#fd1d1d,#fcb045)}.side-nav-wrapper{background:linear-gradient(0deg,#22c1c3,#fdbb2d)} diff --git a/multiqc/templates/disco/compiled/js/multiqc.min.js b/multiqc/templates/disco/compiled/js/multiqc.min.js new file mode 100644 index 0000000000..595fc5beb1 --- /dev/null +++ b/multiqc/templates/disco/compiled/js/multiqc.min.js @@ -0,0 +1,11 @@ +var e="top",t="bottom",o="right",n="left",s="auto",a=[e,t,o,n],r="start",l="end",c="clippingParents",d="viewport",h="popper",m="reference",p=a.reduce(function(e,t){return e.concat([t+"-"+r,t+"-"+l])},[]),u=[].concat(a,[s]).reduce(function(e,t){return e.concat([t,t+"-"+r,t+"-"+l])},[]),f="beforeRead",_="read",g="afterRead",b="beforeMain",v="main",w="afterMain",y="beforeWrite",x="write",q="afterWrite",k=[f,_,g,b,v,w,y,x,q];function C(e){return e?(e.nodeName||"").toLowerCase():null}function A(e){if(null==e)return window;if("[object Window]"!==e.toString()){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function S(e){return e instanceof A(e).Element||e instanceof Element}function E(e){return e instanceof A(e).HTMLElement||e instanceof HTMLElement}function T(e){return"undefined"!=typeof ShadowRoot&&(e instanceof A(e).ShadowRoot||e instanceof ShadowRoot)}const O={name:"applyStyles",enabled:!0,phase:"write",fn:function(e){var t=e.state;Object.keys(t.elements).forEach(function(e){var o=t.styles[e]||{},i=t.attributes[e]||{},n=t.elements[e];E(n)&&C(n)&&(Object.assign(n.style,o),Object.keys(i).forEach(function(e){var t=i[e];!1===t?n.removeAttribute(e):n.setAttribute(e,!0===t?"":t)}))})},effect:function(e){var t=e.state,o={popper:{position:t.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(t.elements.popper.style,o.popper),t.styles=o,t.elements.arrow&&Object.assign(t.elements.arrow.style,o.arrow),function(){Object.keys(t.elements).forEach(function(e){var i=t.elements[e],n=t.attributes[e]||{},s=Object.keys(t.styles.hasOwnProperty(e)?t.styles[e]:o[e]).reduce(function(e,t){return e[t]="",e},{});E(i)&&C(i)&&(Object.assign(i.style,s),Object.keys(n).forEach(function(e){i.removeAttribute(e)}))})}},requires:["computeStyles"]};function D(e){return e.split("-")[0]}var I=Math.max,P=Math.min,N=Math.round;function L(){var e=navigator.userAgentData;return null!=e&&e.brands&&Array.isArray(e.brands)?e.brands.map(function(e){return e.brand+"/"+e.version}).join(" "):navigator.userAgent}function j(){return!/^((?!chrome|android).)*safari/i.test(L())}function M(e,t,o){void 0===t&&(t=!1),void 0===o&&(o=!1);var i=e.getBoundingClientRect(),n=1,s=1;t&&E(e)&&(n=e.offsetWidth>0&&N(i.width)/e.offsetWidth||1,s=e.offsetHeight>0&&N(i.height)/e.offsetHeight||1);var a=(S(e)?A(e):window).visualViewport,r=!j()&&o,l=(i.left+(r&&a?a.offsetLeft:0))/n,c=(i.top+(r&&a?a.offsetTop:0))/s,d=i.width/n,h=i.height/s;return{width:d,height:h,top:c,right:l+d,bottom:c+h,left:l,x:l,y:c}}function F(e){var t=M(e),o=e.offsetWidth,i=e.offsetHeight;return Math.abs(t.width-o)<=1&&(o=t.width),Math.abs(t.height-i)<=1&&(i=t.height),{x:e.offsetLeft,y:e.offsetTop,width:o,height:i}}function z(e,t){var o=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(o&&T(o)){var i=t;do{if(i&&e.isSameNode(i))return!0;i=i.parentNode||i.host}while(i)}return!1}function R(e){return A(e).getComputedStyle(e)}function H(e){return["table","td","th"].indexOf(C(e))>=0}function B(e){return((S(e)?e.ownerDocument:e.document)||window.document).documentElement}function W(e){return"html"===C(e)?e:e.assignedSlot||e.parentNode||(T(e)?e.host:null)||B(e)}function U(e){return E(e)&&"fixed"!==R(e).position?e.offsetParent:null}function V(e){for(var t=A(e),o=U(e);o&&H(o)&&"static"===R(o).position;)o=U(o);return o&&("html"===C(o)||"body"===C(o)&&"static"===R(o).position)?t:o||function(e){var t=/firefox/i.test(L());if(/Trident/i.test(L())&&E(e)&&"fixed"===R(e).position)return null;var o=W(e);for(T(o)&&(o=o.host);E(o)&&["html","body"].indexOf(C(o))<0;){var i=R(o);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||t&&"filter"===i.willChange||t&&i.filter&&"none"!==i.filter)return o;o=o.parentNode}return null}(e)||t}function Y(e){return["top","bottom"].indexOf(e)>=0?"x":"y"}function J(e,t,o){return I(e,P(t,o))}function Q(e){return Object.assign({},{top:0,right:0,bottom:0,left:0},e)}function X(e,t){return t.reduce(function(t,o){return t[o]=e,t},{})}const K={name:"arrow",enabled:!0,phase:"main",fn:function(i){var s,r=i.state,l=i.name,c=i.options,d=r.elements.arrow,h=r.modifiersData.popperOffsets,m=D(r.placement),p=Y(m),u=[n,o].indexOf(m)>=0?"height":"width";if(d&&h){var f=function(e,t){return Q("number"!=typeof(e="function"==typeof e?e(Object.assign({},t.rects,{placement:t.placement})):e)?e:X(e,a))}(c.padding,r),_=F(d),g="y"===p?e:n,b="y"===p?t:o,v=r.rects.reference[u]+r.rects.reference[p]-h[p]-r.rects.popper[u],w=h[p]-r.rects.reference[p],y=V(d),x=y?"y"===p?y.clientHeight||0:y.clientWidth||0:0,$=v/2-w/2,q=f[g],k=x-_[u]-f[b],C=x/2-_[u]/2+$,A=J(q,C,k),S=p;r.modifiersData[l]=((s={})[S]=A,s.centerOffset=A-C,s)}},effect:function(e){var t=e.state,o=e.options.element,i=void 0===o?"[data-popper-arrow]":o;null!=i&&("string"!=typeof i||(i=t.elements.popper.querySelector(i)))&&z(t.elements.popper,i)&&(t.elements.arrow=i)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function G(e){return e.split("-")[1]}var Z={top:"auto",right:"auto",bottom:"auto",left:"auto"};function ee(i){var s,a=i.popper,r=i.popperRect,c=i.placement,d=i.variation,h=i.offsets,m=i.position,p=i.gpuAcceleration,u=i.adaptive,f=i.roundOffsets,_=i.isFixed,g=h.x,b=void 0===g?0:g,v=h.y,w=void 0===v?0:v,y="function"==typeof f?f({x:b,y:w}):{x:b,y:w};b=y.x,w=y.y;var x=h.hasOwnProperty("x"),$=h.hasOwnProperty("y"),q=n,k=e,C=window;if(u){var S=V(a),E="clientHeight",T="clientWidth";if(S===A(a)&&"static"!==R(S=B(a)).position&&"absolute"===m&&(E="scrollHeight",T="scrollWidth"),c===e||(c===n||c===o)&&d===l)k=t,w-=(_&&S===C&&C.visualViewport?C.visualViewport.height:S[E])-r.height,w*=p?1:-1;if(c===n||(c===e||c===t)&&d===l)q=o,b-=(_&&S===C&&C.visualViewport?C.visualViewport.width:S[T])-r.width,b*=p?1:-1}var O,D=Object.assign({position:m},u&&Z),I=!0===f?function(e,t){var o=e.x,i=e.y,n=t.devicePixelRatio||1;return{x:N(o*n)/n||0,y:N(i*n)/n||0}}({x:b,y:w},A(a)):{x:b,y:w};return b=I.x,w=I.y,p?Object.assign({},D,((O={})[k]=$?"0":"",O[q]=x?"0":"",O.transform=(C.devicePixelRatio||1)<=1?"translate("+b+"px, "+w+"px)":"translate3d("+b+"px, "+w+"px, 0)",O)):Object.assign({},D,((s={})[k]=$?w+"px":"",s[q]=x?b+"px":"",s.transform="",s))}const te={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(e){var t=e.state,o=e.options,i=o.gpuAcceleration,n=void 0===i||i,s=o.adaptive,a=void 0===s||s,r=o.roundOffsets,l=void 0===r||r,c={placement:D(t.placement),variation:G(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:n,isFixed:"fixed"===t.options.strategy};null!=t.modifiersData.popperOffsets&&(t.styles.popper=Object.assign({},t.styles.popper,ee(Object.assign({},c,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:a,roundOffsets:l})))),null!=t.modifiersData.arrow&&(t.styles.arrow=Object.assign({},t.styles.arrow,ee(Object.assign({},c,{offsets:t.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-placement":t.placement})},data:{}};var oe={passive:!0};const ie={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(e){var t=e.state,o=e.instance,i=e.options,n=i.scroll,s=void 0===n||n,a=i.resize,r=void 0===a||a,l=A(t.elements.popper),c=[].concat(t.scrollParents.reference,t.scrollParents.popper);return s&&c.forEach(function(e){e.addEventListener("scroll",o.update,oe)}),r&&l.addEventListener("resize",o.update,oe),function(){s&&c.forEach(function(e){e.removeEventListener("scroll",o.update,oe)}),r&&l.removeEventListener("resize",o.update,oe)}},data:{}};var ne={left:"right",right:"left",bottom:"top",top:"bottom"};function se(e){return e.replace(/left|right|bottom|top/g,function(e){return ne[e]})}var ae={start:"end",end:"start"};function re(e){return e.replace(/start|end/g,function(e){return ae[e]})}function le(e){var t=A(e);return{scrollLeft:t.pageXOffset,scrollTop:t.pageYOffset}}function ce(e){return M(B(e)).left+le(e).scrollLeft}function de(e){var t=R(e),o=t.overflow,i=t.overflowX,n=t.overflowY;return/auto|scroll|overlay|hidden/.test(o+n+i)}function he(e){return["html","body","#document"].indexOf(C(e))>=0?e.ownerDocument.body:E(e)&&de(e)?e:he(W(e))}function me(e,t){var o;void 0===t&&(t=[]);var i=he(e),n=i===(null==(o=e.ownerDocument)?void 0:o.body),s=A(i),a=n?[s].concat(s.visualViewport||[],de(i)?i:[]):i,r=t.concat(a);return n?r:r.concat(me(W(a)))}function pe(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function ue(e,t,o){return t===d?pe(function(e,t){var o=A(e),i=B(e),n=o.visualViewport,s=i.clientWidth,a=i.clientHeight,r=0,l=0;if(n){s=n.width,a=n.height;var c=j();(c||!c&&"fixed"===t)&&(r=n.offsetLeft,l=n.offsetTop)}return{width:s,height:a,x:r+ce(e),y:l}}(e,o)):S(t)?function(e,t){var o=M(e,!1,"fixed"===t);return o.top=o.top+e.clientTop,o.left=o.left+e.clientLeft,o.bottom=o.top+e.clientHeight,o.right=o.left+e.clientWidth,o.width=e.clientWidth,o.height=e.clientHeight,o.x=o.left,o.y=o.top,o}(t,o):pe(function(e){var t,o=B(e),i=le(e),n=null==(t=e.ownerDocument)?void 0:t.body,s=I(o.scrollWidth,o.clientWidth,n?n.scrollWidth:0,n?n.clientWidth:0),a=I(o.scrollHeight,o.clientHeight,n?n.scrollHeight:0,n?n.clientHeight:0),r=-i.scrollLeft+ce(e),l=-i.scrollTop;return"rtl"===R(n||o).direction&&(r+=I(o.clientWidth,n?n.clientWidth:0)-s),{width:s,height:a,x:r,y:l}}(B(e)))}function fe(e,t,o,i){var n="clippingParents"===t?function(e){var t=me(W(e)),o=["absolute","fixed"].indexOf(R(e).position)>=0&&E(e)?V(e):e;return S(o)?t.filter(function(e){return S(e)&&z(e,o)&&"body"!==C(e)}):[]}(e):[].concat(t),s=[].concat(n,[o]),a=s[0],r=s.reduce(function(t,o){var n=ue(e,o,i);return t.top=I(n.top,t.top),t.right=P(n.right,t.right),t.bottom=P(n.bottom,t.bottom),t.left=I(n.left,t.left),t},ue(e,a,i));return r.width=r.right-r.left,r.height=r.bottom-r.top,r.x=r.left,r.y=r.top,r}function _e(i){var s,a=i.reference,c=i.element,d=i.placement,h=d?D(d):null,m=d?G(d):null,p=a.x+a.width/2-c.width/2,u=a.y+a.height/2-c.height/2;switch(h){case e:s={x:p,y:a.y-c.height};break;case t:s={x:p,y:a.y+a.height};break;case o:s={x:a.x+a.width,y:u};break;case n:s={x:a.x-c.width,y:u};break;default:s={x:a.x,y:a.y}}var f=h?Y(h):null;if(null!=f){var _="y"===f?"height":"width";switch(m){case r:s[f]=s[f]-(a[_]/2-c[_]/2);break;case l:s[f]=s[f]+(a[_]/2-c[_]/2)}}return s}function ge(i,n){void 0===n&&(n={});var s=n,r=s.placement,l=void 0===r?i.placement:r,p=s.strategy,u=void 0===p?i.strategy:p,f=s.boundary,_=void 0===f?c:f,g=s.rootBoundary,b=void 0===g?d:g,v=s.elementContext,w=void 0===v?h:v,y=s.altBoundary,x=void 0!==y&&y,$=s.padding,q=void 0===$?0:$,k=Q("number"!=typeof q?q:X(q,a)),C=w===h?m:h,A=i.rects.popper,E=i.elements[x?C:w],T=fe(S(E)?E:E.contextElement||B(i.elements.popper),_,b,u),O=M(i.elements.reference),D=_e({reference:O,element:A,placement:l}),I=pe(Object.assign({},A,D)),P=w===h?I:O,N={top:T.top-P.top+k.top,bottom:P.bottom-T.bottom+k.bottom,left:T.left-P.left+k.left,right:P.right-T.right+k.right},L=i.modifiersData.offset;if(w===h&&L){var j=L[l];Object.keys(N).forEach(function(i){var n=[o,t].indexOf(i)>=0?1:-1,s=[e,t].indexOf(i)>=0?"y":"x";N[i]+=j[s]*n})}return N}const be={name:"flip",enabled:!0,phase:"main",fn:function(i){var l=i.state,c=i.options,d=i.name;if(!l.modifiersData[d]._skip){for(var h=c.mainAxis,m=void 0===h||h,f=c.altAxis,_=void 0===f||f,g=c.fallbackPlacements,b=c.padding,v=c.boundary,w=c.rootBoundary,y=c.altBoundary,x=c.flipVariations,$=void 0===x||x,q=c.allowedAutoPlacements,k=l.options.placement,C=D(k),A=g||(C===k||!$?[se(k)]:function(e){if(D(e)===s)return[];var t=se(e);return[re(e),t,re(t)]}(k)),S=[k].concat(A).reduce(function(e,t){return e.concat(D(t)===s?function(e,t){void 0===t&&(t={});var o=t,i=o.placement,n=o.boundary,s=o.rootBoundary,r=o.padding,l=o.flipVariations,c=o.allowedAutoPlacements,d=void 0===c?u:c,h=G(i),m=h?l?p:p.filter(function(e){return G(e)===h}):a,f=m.filter(function(e){return d.indexOf(e)>=0});0===f.length&&(f=m);var _=f.reduce(function(t,o){return t[o]=ge(e,{placement:o,boundary:n,rootBoundary:s,padding:r})[D(o)],t},{});return Object.keys(_).sort(function(e,t){return _[e]-_[t]})}(l,{placement:t,boundary:v,rootBoundary:w,padding:b,flipVariations:$,allowedAutoPlacements:q}):t)},[]),E=l.rects.reference,T=l.rects.popper,O=new Map,I=!0,P=S[0],N=0;N=0,z=F?"width":"height",R=ge(l,{placement:L,boundary:v,rootBoundary:w,altBoundary:y,padding:b}),H=F?M?o:n:M?t:e;E[z]>T[z]&&(H=se(H));var B=se(H),W=[];if(m&&W.push(R[j]<=0),_&&W.push(R[H]<=0,R[B]<=0),W.every(function(e){return e})){P=L,I=!1;break}O.set(L,W)}if(I)for(var U=function(e){var t=S.find(function(t){var o=O.get(t);if(o)return o.slice(0,e).every(function(e){return e})});if(t)return P=t,"break"},V=$?3:1;V>0;V--){if("break"===U(V))break}l.placement!==P&&(l.modifiersData[d]._skip=!0,l.placement=P,l.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function ve(e,t,o){return void 0===o&&(o={x:0,y:0}),{top:e.top-t.height-o.y,right:e.right-t.width+o.x,bottom:e.bottom-t.height+o.y,left:e.left-t.width-o.x}}function we(i){return[e,o,t,n].some(function(e){return i[e]>=0})}const ye={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(e){var t=e.state,o=e.name,i=t.rects.reference,n=t.rects.popper,s=t.modifiersData.preventOverflow,a=ge(t,{elementContext:"reference"}),r=ge(t,{altBoundary:!0}),l=ve(a,i),c=ve(r,n,s),d=we(l),h=we(c);t.modifiersData[o]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:d,hasPopperEscaped:h},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":d,"data-popper-escaped":h})}};const xe={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var i=t.state,s=t.options,a=t.name,r=s.offset,l=void 0===r?[0,0]:r,c=u.reduce(function(t,s){return t[s]=function(t,i,s){var a=D(t),r=[n,e].indexOf(a)>=0?-1:1,l="function"==typeof s?s(Object.assign({},i,{placement:t})):s,c=l[0],d=l[1];return c=c||0,d=(d||0)*r,[n,o].indexOf(a)>=0?{x:d,y:c}:{x:c,y:d}}(s,i.rects,l),t},{}),d=c[i.placement],h=d.x,m=d.y;null!=i.modifiersData.popperOffsets&&(i.modifiersData.popperOffsets.x+=h,i.modifiersData.popperOffsets.y+=m),i.modifiersData[a]=c}};const $e={name:"popperOffsets",enabled:!0,phase:"read",fn:function(e){var t=e.state,o=e.name;t.modifiersData[o]=_e({reference:t.rects.reference,element:t.rects.popper,placement:t.placement})},data:{}};const qe={name:"preventOverflow",enabled:!0,phase:"main",fn:function(i){var s=i.state,a=i.options,l=i.name,c=a.mainAxis,d=void 0===c||c,h=a.altAxis,m=void 0!==h&&h,p=a.boundary,u=a.rootBoundary,f=a.altBoundary,_=a.padding,g=a.tether,b=void 0===g||g,v=a.tetherOffset,w=void 0===v?0:v,y=ge(s,{boundary:p,rootBoundary:u,padding:_,altBoundary:f}),x=D(s.placement),$=G(s.placement),q=!$,k=Y(x),C="x"===k?"y":"x",A=s.modifiersData.popperOffsets,S=s.rects.reference,E=s.rects.popper,T="function"==typeof w?w(Object.assign({},s.rects,{placement:s.placement})):w,O="number"==typeof T?{mainAxis:T,altAxis:T}:Object.assign({mainAxis:0,altAxis:0},T),N=s.modifiersData.offset?s.modifiersData.offset[s.placement]:null,L={x:0,y:0};if(A){if(d){var j,M="y"===k?e:n,z="y"===k?t:o,R="y"===k?"height":"width",H=A[k],B=H+y[M],W=H-y[z],U=b?-E[R]/2:0,Q=$===r?S[R]:E[R],X=$===r?-E[R]:-S[R],K=s.elements.arrow,Z=b&&K?F(K):{width:0,height:0},ee=s.modifiersData["arrow#persistent"]?s.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},te=ee[M],oe=ee[z],ie=J(0,S[R],Z[R]),ne=q?S[R]/2-U-ie-te-O.mainAxis:Q-ie-te-O.mainAxis,se=q?-S[R]/2+U+ie+oe+O.mainAxis:X+ie+oe+O.mainAxis,ae=s.elements.arrow&&V(s.elements.arrow),re=ae?"y"===k?ae.clientTop||0:ae.clientLeft||0:0,le=null!=(j=null==N?void 0:N[k])?j:0,ce=H+se-le,de=J(b?P(B,H+ne-le-re):B,H,b?I(W,ce):W);A[k]=de,L[k]=de-H}if(m){var he,me="x"===k?e:n,pe="x"===k?t:o,ue=A[C],fe="y"===C?"height":"width",_e=ue+y[me],be=ue-y[pe],ve=-1!==[e,n].indexOf(x),we=null!=(he=null==N?void 0:N[C])?he:0,ye=ve?_e:ue-S[fe]-E[fe]-we+O.altAxis,xe=ve?ue+S[fe]+E[fe]-we-O.altAxis:be,$e=b&&ve?(ke=J(ye,ue,qe=xe))>qe?qe:ke:J(b?ye:_e,ue,b?xe:be);A[C]=$e,L[C]=$e-ue}var qe,ke;s.modifiersData[l]=L}},requiresIfExists:["offset"]};function ke(e,t,o){void 0===o&&(o=!1);var i,n,s=E(t),a=E(t)&&function(e){var t=e.getBoundingClientRect(),o=N(t.width)/e.offsetWidth||1,i=N(t.height)/e.offsetHeight||1;return 1!==o||1!==i}(t),r=B(t),l=M(e,a,o),c={scrollLeft:0,scrollTop:0},d={x:0,y:0};return(s||!s&&!o)&&(("body"!==C(t)||de(r))&&(c=(i=t)!==A(i)&&E(i)?{scrollLeft:(n=i).scrollLeft,scrollTop:n.scrollTop}:le(i)),E(t)?((d=M(t,!0)).x+=t.clientLeft,d.y+=t.clientTop):r&&(d.x=ce(r))),{x:l.left+c.scrollLeft-d.x,y:l.top+c.scrollTop-d.y,width:l.width,height:l.height}}function Ce(e){var t=new Map,o=new Set,i=[];function n(e){o.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach(function(e){if(!o.has(e)){var i=t.get(e);i&&n(i)}}),i.push(e)}return e.forEach(function(e){t.set(e.name,e)}),e.forEach(function(e){o.has(e.name)||n(e)}),i}var Ae={placement:"bottom",modifiers:[],strategy:"absolute"};function Se(){for(var e=arguments.length,t=new Array(e),o=0;oPe.has(e)&&Pe.get(e).get(t)||null,remove(e,t){if(!Pe.has(e))return;const o=Pe.get(e);o.delete(t),0===o.size&&Pe.delete(e)}},Le="transitionend",je=e=>(e&&window.CSS&&window.CSS.escape&&(e=e.replace(/#([^\s"#']+)/g,(e,t)=>`#${CSS.escape(t)}`)),e),Me=e=>null==e?`${e}`:Object.prototype.toString.call(e).match(/\s([a-z]+)/i)[1].toLowerCase(),Fe=e=>{e.dispatchEvent(new Event(Le))},ze=e=>!(!e||"object"!=typeof e)&&(void 0!==e.jquery&&(e=e[0]),void 0!==e.nodeType),Re=e=>ze(e)?e.jquery?e[0]:e:"string"==typeof e&&e.length>0?document.querySelector(je(e)):null,He=e=>{if(!ze(e)||0===e.getClientRects().length)return!1;const t="visible"===getComputedStyle(e).getPropertyValue("visibility"),o=e.closest("details:not([open])");if(!o)return t;if(o!==e){const t=e.closest("summary");if(t&&t.parentNode!==o)return!1;if(null===t)return!1}return t},Be=e=>!e||e.nodeType!==Node.ELEMENT_NODE||(!!e.classList.contains("disabled")||(void 0!==e.disabled?e.disabled:e.hasAttribute("disabled")&&"false"!==e.getAttribute("disabled"))),We=e=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof e.getRootNode){const t=e.getRootNode();return t instanceof ShadowRoot?t:null}return e instanceof ShadowRoot?e:e.parentNode?We(e.parentNode):null},Ue=()=>{},Ve=e=>{e.offsetHeight},Ye=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,Je=[],Qe=()=>"rtl"===document.documentElement.dir,Xe=e=>{var t;t=()=>{const t=Ye();if(t){const o=e.NAME,i=t.fn[o];t.fn[o]=e.jQueryInterface,t.fn[o].Constructor=e,t.fn[o].noConflict=()=>(t.fn[o]=i,e.jQueryInterface)}},"loading"===document.readyState?(Je.length||document.addEventListener("DOMContentLoaded",()=>{for(const e of Je)e()}),Je.push(t)):t()},Ke=(e,t=[],o=e)=>"function"==typeof e?e.call(...t):o,Ge=(e,t,o=!0)=>{if(!o)return void Ke(e);const i=(e=>{if(!e)return 0;let{transitionDuration:t,transitionDelay:o}=window.getComputedStyle(e);const i=Number.parseFloat(t),n=Number.parseFloat(o);return i||n?(t=t.split(",")[0],o=o.split(",")[0],1e3*(Number.parseFloat(t)+Number.parseFloat(o))):0})(t)+5;let n=!1;const s=({target:o})=>{o===t&&(n=!0,t.removeEventListener(Le,s),Ke(e))};t.addEventListener(Le,s),setTimeout(()=>{n||Fe(t)},i)},Ze=(e,t,o,i)=>{const n=e.length;let s=e.indexOf(t);return-1===s?!o&&i?e[n-1]:e[0]:(s+=o?1:-1,i&&(s=(s+n)%n),e[Math.max(0,Math.min(s,n-1))])},et=/[^.]*(?=\..*)\.|.*/,tt=/\..*/,ot=/::\d+$/,it={}; +/*! + * Bootstrap v5.3.7 (https://getbootstrap.com/) + * Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */let nt=1;const st={mouseenter:"mouseover",mouseleave:"mouseout"},at=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function rt(e,t){return t&&`${t}::${nt++}`||e.uidEvent||nt++}function lt(e){const t=rt(e);return e.uidEvent=t,it[t]=it[t]||{},it[t]}function ct(e,t,o=null){return Object.values(e).find(e=>e.callable===t&&e.delegationSelector===o)}function dt(e,t,o){const i="string"==typeof t,n=i?o:t||o;let s=ut(e);return at.has(s)||(s=e),[i,n,s]}function ht(e,t,o,i,n){if("string"!=typeof t||!e)return;let[s,a,r]=dt(t,o,i);if(t in st){a=(e=>function(t){if(!t.relatedTarget||t.relatedTarget!==t.delegateTarget&&!t.delegateTarget.contains(t.relatedTarget))return e.call(this,t)})(a)}const l=lt(e),c=l[r]||(l[r]={}),d=ct(c,a,s?o:null);if(d)return void(d.oneOff=d.oneOff&&n);const h=rt(a,t.replace(et,"")),m=s?function(e,t,o){return function i(n){const s=e.querySelectorAll(t);for(let{target:a}=n;a&&a!==this;a=a.parentNode)for(const r of s)if(r===a)return _t(n,{delegateTarget:a}),i.oneOff&&ft.off(e,n.type,t,o),o.apply(a,[n])}}(e,o,a):function(e,t){return function o(i){return _t(i,{delegateTarget:e}),o.oneOff&&ft.off(e,i.type,t),t.apply(e,[i])}}(e,a);m.delegationSelector=s?o:null,m.callable=a,m.oneOff=n,m.uidEvent=h,c[h]=m,e.addEventListener(r,m,s)}function mt(e,t,o,i,n){const s=ct(t[o],i,n);s&&(e.removeEventListener(o,s,Boolean(n)),delete t[o][s.uidEvent])}function pt(e,t,o,i){const n=t[o]||{};for(const[s,a]of Object.entries(n))s.includes(i)&&mt(e,t,o,a.callable,a.delegationSelector)}function ut(e){return e=e.replace(tt,""),st[e]||e}const ft={on(e,t,o,i){ht(e,t,o,i,!1)},one(e,t,o,i){ht(e,t,o,i,!0)},off(e,t,o,i){if("string"!=typeof t||!e)return;const[n,s,a]=dt(t,o,i),r=a!==t,l=lt(e),c=l[a]||{},d=t.startsWith(".");if(void 0===s){if(d)for(const o of Object.keys(l))pt(e,l,o,t.slice(1));for(const[o,i]of Object.entries(c)){const n=o.replace(ot,"");r&&!t.includes(n)||mt(e,l,a,i.callable,i.delegationSelector)}}else{if(!Object.keys(c).length)return;mt(e,l,a,s,n?o:null)}},trigger(e,t,o){if("string"!=typeof t||!e)return null;const i=Ye();let n=null,s=!0,a=!0,r=!1;t!==ut(t)&&i&&(n=i.Event(t,o),i(e).trigger(n),s=!n.isPropagationStopped(),a=!n.isImmediatePropagationStopped(),r=n.isDefaultPrevented());const l=_t(new Event(t,{bubbles:s,cancelable:!0}),o);return r&&l.preventDefault(),a&&e.dispatchEvent(l),l.defaultPrevented&&n&&n.preventDefault(),l}};function _t(e,t={}){for(const[i,n]of Object.entries(t))try{e[i]=n}catch(o){Object.defineProperty(e,i,{configurable:!0,get:()=>n})}return e}function gt(e){if("true"===e)return!0;if("false"===e)return!1;if(e===Number(e).toString())return Number(e);if(""===e||"null"===e)return null;if("string"!=typeof e)return e;try{return JSON.parse(decodeURIComponent(e))}catch(t){return e}}function bt(e){return e.replace(/[A-Z]/g,e=>`-${e.toLowerCase()}`)}const vt={setDataAttribute(e,t,o){e.setAttribute(`data-bs-${bt(t)}`,o)},removeDataAttribute(e,t){e.removeAttribute(`data-bs-${bt(t)}`)},getDataAttributes(e){if(!e)return{};const t={},o=Object.keys(e.dataset).filter(e=>e.startsWith("bs")&&!e.startsWith("bsConfig"));for(const i of o){let o=i.replace(/^bs/,"");o=o.charAt(0).toLowerCase()+o.slice(1),t[o]=gt(e.dataset[i])}return t},getDataAttribute:(e,t)=>gt(e.getAttribute(`data-bs-${bt(t)}`))};class wt{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(e){return e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e}_mergeConfigObj(e,t){const o=ze(t)?vt.getDataAttribute(t,"config"):{};return{...this.constructor.Default,..."object"==typeof o?o:{},...ze(t)?vt.getDataAttributes(t):{},..."object"==typeof e?e:{}}}_typeCheckConfig(e,t=this.constructor.DefaultType){for(const[o,i]of Object.entries(t)){const t=e[o],n=ze(t)?"element":Me(t);if(!new RegExp(i).test(n))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${o}" provided type "${n}" but expected type "${i}".`)}}}class yt extends wt{constructor(e,t){super(),(e=Re(e))&&(this._element=e,this._config=this._getConfig(t),Ne.set(this._element,this.constructor.DATA_KEY,this))}dispose(){Ne.remove(this._element,this.constructor.DATA_KEY),ft.off(this._element,this.constructor.EVENT_KEY);for(const e of Object.getOwnPropertyNames(this))this[e]=null}_queueCallback(e,t,o=!0){Ge(e,t,o)}_getConfig(e){return e=this._mergeConfigObj(e,this._element),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}static getInstance(e){return Ne.get(Re(e),this.DATA_KEY)}static getOrCreateInstance(e,t={}){return this.getInstance(e)||new this(e,"object"==typeof t?t:null)}static get VERSION(){return"5.3.7"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(e){return`${e}${this.EVENT_KEY}`}}const xt=e=>{let t=e.getAttribute("data-bs-target");if(!t||"#"===t){let o=e.getAttribute("href");if(!o||!o.includes("#")&&!o.startsWith("."))return null;o.includes("#")&&!o.startsWith("#")&&(o=`#${o.split("#")[1]}`),t=o&&"#"!==o?o.trim():null}return t?t.split(",").map(e=>je(e)).join(","):null},$t={find:(e,t=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(t,e)),findOne:(e,t=document.documentElement)=>Element.prototype.querySelector.call(t,e),children:(e,t)=>[].concat(...e.children).filter(e=>e.matches(t)),parents(e,t){const o=[];let i=e.parentNode.closest(t);for(;i;)o.push(i),i=i.parentNode.closest(t);return o},prev(e,t){let o=e.previousElementSibling;for(;o;){if(o.matches(t))return[o];o=o.previousElementSibling}return[]},next(e,t){let o=e.nextElementSibling;for(;o;){if(o.matches(t))return[o];o=o.nextElementSibling}return[]},focusableChildren(e){const t=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map(e=>`${e}:not([tabindex^="-"])`).join(",");return this.find(t,e).filter(e=>!Be(e)&&He(e))},getSelectorFromElement(e){const t=xt(e);return t&&$t.findOne(t)?t:null},getElementFromSelector(e){const t=xt(e);return t?$t.findOne(t):null},getMultipleElementsFromSelector(e){const t=xt(e);return t?$t.find(t):[]}},qt=(e,t="hide")=>{const o=`click.dismiss${e.EVENT_KEY}`,i=e.NAME;ft.on(document,o,`[data-bs-dismiss="${i}"]`,function(o){if(["A","AREA"].includes(this.tagName)&&o.preventDefault(),Be(this))return;const n=$t.getElementFromSelector(this)||this.closest(`.${i}`);e.getOrCreateInstance(n)[t]()})},kt=".bs.alert",Ct=`close${kt}`,At=`closed${kt}`;class St extends yt{static get NAME(){return"alert"}close(){if(ft.trigger(this._element,Ct).defaultPrevented)return;this._element.classList.remove("show");const e=this._element.classList.contains("fade");this._queueCallback(()=>this._destroyElement(),this._element,e)}_destroyElement(){this._element.remove(),ft.trigger(this._element,At),this.dispose()}static jQueryInterface(e){return this.each(function(){const t=St.getOrCreateInstance(this);if("string"==typeof e){if(void 0===t[e]||e.startsWith("_")||"constructor"===e)throw new TypeError(`No method named "${e}"`);t[e](this)}})}}qt(St,"close"),Xe(St);const Et='[data-bs-toggle="button"]';class Tt extends yt{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(e){return this.each(function(){const t=Tt.getOrCreateInstance(this);"toggle"===e&&t[e]()})}}ft.on(document,"click.bs.button.data-api",Et,e=>{e.preventDefault();const t=e.target.closest(Et);Tt.getOrCreateInstance(t).toggle()}),Xe(Tt);const Ot=".bs.swipe",Dt=`touchstart${Ot}`,It=`touchmove${Ot}`,Pt=`touchend${Ot}`,Nt=`pointerdown${Ot}`,Lt=`pointerup${Ot}`,jt={endCallback:null,leftCallback:null,rightCallback:null},Mt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class Ft extends wt{constructor(e,t){super(),this._element=e,e&&Ft.isSupported()&&(this._config=this._getConfig(t),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return jt}static get DefaultType(){return Mt}static get NAME(){return"swipe"}dispose(){ft.off(this._element,Ot)}_start(e){this._supportPointerEvents?this._eventIsPointerPenTouch(e)&&(this._deltaX=e.clientX):this._deltaX=e.touches[0].clientX}_end(e){this._eventIsPointerPenTouch(e)&&(this._deltaX=e.clientX-this._deltaX),this._handleSwipe(),Ke(this._config.endCallback)}_move(e){this._deltaX=e.touches&&e.touches.length>1?0:e.touches[0].clientX-this._deltaX}_handleSwipe(){const e=Math.abs(this._deltaX);if(e<=40)return;const t=e/this._deltaX;this._deltaX=0,t&&Ke(t>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(ft.on(this._element,Nt,e=>this._start(e)),ft.on(this._element,Lt,e=>this._end(e)),this._element.classList.add("pointer-event")):(ft.on(this._element,Dt,e=>this._start(e)),ft.on(this._element,It,e=>this._move(e)),ft.on(this._element,Pt,e=>this._end(e)))}_eventIsPointerPenTouch(e){return this._supportPointerEvents&&("pen"===e.pointerType||"touch"===e.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const zt=".bs.carousel",Rt=".data-api",Ht="ArrowLeft",Bt="ArrowRight",Wt="next",Ut="prev",Vt="left",Yt="right",Jt=`slide${zt}`,Qt=`slid${zt}`,Xt=`keydown${zt}`,Kt=`mouseenter${zt}`,Gt=`mouseleave${zt}`,Zt=`dragstart${zt}`,eo=`load${zt}${Rt}`,to=`click${zt}${Rt}`,oo="carousel",io="active",no=".active",so=".carousel-item",ao=no+so,ro={[Ht]:Yt,[Bt]:Vt},lo={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},co={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class ho extends yt{constructor(e,t){super(e,t),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=$t.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===oo&&this.cycle()}static get Default(){return lo}static get DefaultType(){return co}static get NAME(){return"carousel"}next(){this._slide(Wt)}nextWhenVisible(){!document.hidden&&He(this._element)&&this.next()}prev(){this._slide(Ut)}pause(){this._isSliding&&Fe(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval(()=>this.nextWhenVisible(),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?ft.one(this._element,Qt,()=>this.cycle()):this.cycle())}to(e){const t=this._getItems();if(e>t.length-1||e<0)return;if(this._isSliding)return void ft.one(this._element,Qt,()=>this.to(e));const o=this._getItemIndex(this._getActive());if(o===e)return;const i=e>o?Wt:Ut;this._slide(i,t[e])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(e){return e.defaultInterval=e.interval,e}_addEventListeners(){this._config.keyboard&&ft.on(this._element,Xt,e=>this._keydown(e)),"hover"===this._config.pause&&(ft.on(this._element,Kt,()=>this.pause()),ft.on(this._element,Gt,()=>this._maybeEnableCycle())),this._config.touch&&Ft.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of $t.find(".carousel-item img",this._element))ft.on(t,Zt,e=>e.preventDefault());const e={leftCallback:()=>this._slide(this._directionToOrder(Vt)),rightCallback:()=>this._slide(this._directionToOrder(Yt)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(()=>this._maybeEnableCycle(),500+this._config.interval))}};this._swipeHelper=new Ft(this._element,e)}_keydown(e){if(/input|textarea/i.test(e.target.tagName))return;const t=ro[e.key];t&&(e.preventDefault(),this._slide(this._directionToOrder(t)))}_getItemIndex(e){return this._getItems().indexOf(e)}_setActiveIndicatorElement(e){if(!this._indicatorsElement)return;const t=$t.findOne(no,this._indicatorsElement);t.classList.remove(io),t.removeAttribute("aria-current");const o=$t.findOne(`[data-bs-slide-to="${e}"]`,this._indicatorsElement);o&&(o.classList.add(io),o.setAttribute("aria-current","true"))}_updateInterval(){const e=this._activeElement||this._getActive();if(!e)return;const t=Number.parseInt(e.getAttribute("data-bs-interval"),10);this._config.interval=t||this._config.defaultInterval}_slide(e,t=null){if(this._isSliding)return;const o=this._getActive(),i=e===Wt,n=t||Ze(this._getItems(),o,i,this._config.wrap);if(n===o)return;const s=this._getItemIndex(n),a=t=>ft.trigger(this._element,t,{relatedTarget:n,direction:this._orderToDirection(e),from:this._getItemIndex(o),to:s});if(a(Jt).defaultPrevented)return;if(!o||!n)return;const r=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(s),this._activeElement=n;const l=i?"carousel-item-start":"carousel-item-end",c=i?"carousel-item-next":"carousel-item-prev";n.classList.add(c),Ve(n),o.classList.add(l),n.classList.add(l);this._queueCallback(()=>{n.classList.remove(l,c),n.classList.add(io),o.classList.remove(io,c,l),this._isSliding=!1,a(Qt)},o,this._isAnimated()),r&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return $t.findOne(ao,this._element)}_getItems(){return $t.find(so,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(e){return Qe()?e===Vt?Ut:Wt:e===Vt?Wt:Ut}_orderToDirection(e){return Qe()?e===Ut?Vt:Yt:e===Ut?Yt:Vt}static jQueryInterface(e){return this.each(function(){const t=ho.getOrCreateInstance(this,e);if("number"!=typeof e){if("string"==typeof e){if(void 0===t[e]||e.startsWith("_")||"constructor"===e)throw new TypeError(`No method named "${e}"`);t[e]()}}else t.to(e)})}}ft.on(document,to,"[data-bs-slide], [data-bs-slide-to]",function(e){const t=$t.getElementFromSelector(this);if(!t||!t.classList.contains(oo))return;e.preventDefault();const o=ho.getOrCreateInstance(t),i=this.getAttribute("data-bs-slide-to");return i?(o.to(i),void o._maybeEnableCycle()):"next"===vt.getDataAttribute(this,"slide")?(o.next(),void o._maybeEnableCycle()):(o.prev(),void o._maybeEnableCycle())}),ft.on(window,eo,()=>{const e=$t.find('[data-bs-ride="carousel"]');for(const t of e)ho.getOrCreateInstance(t)}),Xe(ho);const mo=".bs.collapse",po=`show${mo}`,uo=`shown${mo}`,fo=`hide${mo}`,_o=`hidden${mo}`,go=`click${mo}.data-api`,bo="show",vo="collapse",wo="collapsing",yo=`:scope .${vo} .${vo}`,xo='[data-bs-toggle="collapse"]',$o={parent:null,toggle:!0},qo={parent:"(null|element)",toggle:"boolean"};class ko extends yt{constructor(e,t){super(e,t),this._isTransitioning=!1,this._triggerArray=[];const o=$t.find(xo);for(const i of o){const e=$t.getSelectorFromElement(i),t=$t.find(e).filter(e=>e===this._element);null!==e&&t.length&&this._triggerArray.push(i)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return $o}static get DefaultType(){return qo}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let e=[];if(this._config.parent&&(e=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter(e=>e!==this._element).map(e=>ko.getOrCreateInstance(e,{toggle:!1}))),e.length&&e[0]._isTransitioning)return;if(ft.trigger(this._element,po).defaultPrevented)return;for(const i of e)i.hide();const t=this._getDimension();this._element.classList.remove(vo),this._element.classList.add(wo),this._element.style[t]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const o=`scroll${t[0].toUpperCase()+t.slice(1)}`;this._queueCallback(()=>{this._isTransitioning=!1,this._element.classList.remove(wo),this._element.classList.add(vo,bo),this._element.style[t]="",ft.trigger(this._element,uo)},this._element,!0),this._element.style[t]=`${this._element[o]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(ft.trigger(this._element,fo).defaultPrevented)return;const e=this._getDimension();this._element.style[e]=`${this._element.getBoundingClientRect()[e]}px`,Ve(this._element),this._element.classList.add(wo),this._element.classList.remove(vo,bo);for(const t of this._triggerArray){const e=$t.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0;this._element.style[e]="",this._queueCallback(()=>{this._isTransitioning=!1,this._element.classList.remove(wo),this._element.classList.add(vo),ft.trigger(this._element,_o)},this._element,!0)}_isShown(e=this._element){return e.classList.contains(bo)}_configAfterMerge(e){return e.toggle=Boolean(e.toggle),e.parent=Re(e.parent),e}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const e=this._getFirstLevelChildren(xo);for(const t of e){const e=$t.getElementFromSelector(t);e&&this._addAriaAndCollapsedClass([t],this._isShown(e))}}_getFirstLevelChildren(e){const t=$t.find(yo,this._config.parent);return $t.find(e,this._config.parent).filter(e=>!t.includes(e))}_addAriaAndCollapsedClass(e,t){if(e.length)for(const o of e)o.classList.toggle("collapsed",!t),o.setAttribute("aria-expanded",t)}static jQueryInterface(e){const t={};return"string"==typeof e&&/show|hide/.test(e)&&(t.toggle=!1),this.each(function(){const o=ko.getOrCreateInstance(this,t);if("string"==typeof e){if(void 0===o[e])throw new TypeError(`No method named "${e}"`);o[e]()}})}}ft.on(document,go,xo,function(e){("A"===e.target.tagName||e.delegateTarget&&"A"===e.delegateTarget.tagName)&&e.preventDefault();for(const t of $t.getMultipleElementsFromSelector(this))ko.getOrCreateInstance(t,{toggle:!1}).toggle()}),Xe(ko);const Co="dropdown",Ao=".bs.dropdown",So=".data-api",Eo="ArrowUp",To="ArrowDown",Oo=`hide${Ao}`,Do=`hidden${Ao}`,Io=`show${Ao}`,Po=`shown${Ao}`,No=`click${Ao}${So}`,Lo=`keydown${Ao}${So}`,jo=`keyup${Ao}${So}`,Mo="show",Fo='[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)',zo=`${Fo}.${Mo}`,Ro=".dropdown-menu",Ho=Qe()?"top-end":"top-start",Bo=Qe()?"top-start":"top-end",Wo=Qe()?"bottom-end":"bottom-start",Uo=Qe()?"bottom-start":"bottom-end",Vo=Qe()?"left-start":"right-start",Yo=Qe()?"right-start":"left-start",Jo={autoClose:!0,boundary:"clippingParents",display:"dynamic",offset:[0,2],popperConfig:null,reference:"toggle"},Qo={autoClose:"(boolean|string)",boundary:"(string|element)",display:"string",offset:"(array|string|function)",popperConfig:"(null|object|function)",reference:"(string|element|object)"};class Xo extends yt{constructor(e,t){super(e,t),this._popper=null,this._parent=this._element.parentNode,this._menu=$t.next(this._element,Ro)[0]||$t.prev(this._element,Ro)[0]||$t.findOne(Ro,this._parent),this._inNavbar=this._detectNavbar()}static get Default(){return Jo}static get DefaultType(){return Qo}static get NAME(){return Co}toggle(){return this._isShown()?this.hide():this.show()}show(){if(Be(this._element)||this._isShown())return;const e={relatedTarget:this._element};if(!ft.trigger(this._element,Io,e).defaultPrevented){if(this._createPopper(),"ontouchstart"in document.documentElement&&!this._parent.closest(".navbar-nav"))for(const e of[].concat(...document.body.children))ft.on(e,"mouseover",Ue);this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(Mo),this._element.classList.add(Mo),ft.trigger(this._element,Po,e)}}hide(){if(Be(this._element)||!this._isShown())return;const e={relatedTarget:this._element};this._completeHide(e)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(e){if(!ft.trigger(this._element,Oo,e).defaultPrevented){if("ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))ft.off(e,"mouseover",Ue);this._popper&&this._popper.destroy(),this._menu.classList.remove(Mo),this._element.classList.remove(Mo),this._element.setAttribute("aria-expanded","false"),vt.removeDataAttribute(this._menu,"popper"),ft.trigger(this._element,Do,e),this._element.focus()}}_getConfig(e){if("object"==typeof(e=super._getConfig(e)).reference&&!ze(e.reference)&&"function"!=typeof e.reference.getBoundingClientRect)throw new TypeError(`${Co.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return e}_createPopper(){if(void 0===Ie)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org/docs/v2/)");let e=this._element;"parent"===this._config.reference?e=this._parent:ze(this._config.reference)?e=Re(this._config.reference):"object"==typeof this._config.reference&&(e=this._config.reference);const t=this._getPopperConfig();this._popper=De(e,this._menu,t)}_isShown(){return this._menu.classList.contains(Mo)}_getPlacement(){const e=this._parent;if(e.classList.contains("dropend"))return Vo;if(e.classList.contains("dropstart"))return Yo;if(e.classList.contains("dropup-center"))return"top";if(e.classList.contains("dropdown-center"))return"bottom";const t="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return e.classList.contains("dropup")?t?Bo:Ho:t?Uo:Wo}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:e}=this._config;return"string"==typeof e?e.split(",").map(e=>Number.parseInt(e,10)):"function"==typeof e?t=>e(t,this._element):e}_getPopperConfig(){const e={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(vt.setDataAttribute(this._menu,"popper","static"),e.modifiers=[{name:"applyStyles",enabled:!1}]),{...e,...Ke(this._config.popperConfig,[void 0,e])}}_selectMenuItem({key:e,target:t}){const o=$t.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter(e=>He(e));o.length&&Ze(o,t,e===To,!o.includes(t)).focus()}static jQueryInterface(e){return this.each(function(){const t=Xo.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError(`No method named "${e}"`);t[e]()}})}static clearMenus(e){if(2===e.button||"keyup"===e.type&&"Tab"!==e.key)return;const t=$t.find(zo);for(const o of t){const t=Xo.getInstance(o);if(!t||!1===t._config.autoClose)continue;const i=e.composedPath(),n=i.includes(t._menu);if(i.includes(t._element)||"inside"===t._config.autoClose&&!n||"outside"===t._config.autoClose&&n)continue;if(t._menu.contains(e.target)&&("keyup"===e.type&&"Tab"===e.key||/input|select|option|textarea|form/i.test(e.target.tagName)))continue;const s={relatedTarget:t._element};"click"===e.type&&(s.clickEvent=e),t._completeHide(s)}}static dataApiKeydownHandler(e){const t=/input|textarea/i.test(e.target.tagName),o="Escape"===e.key,i=[Eo,To].includes(e.key);if(!i&&!o)return;if(t&&!o)return;e.preventDefault();const n=this.matches(Fo)?this:$t.prev(this,Fo)[0]||$t.next(this,Fo)[0]||$t.findOne(Fo,e.delegateTarget.parentNode),s=Xo.getOrCreateInstance(n);if(i)return e.stopPropagation(),s.show(),void s._selectMenuItem(e);s._isShown()&&(e.stopPropagation(),s.hide(),n.focus())}}ft.on(document,Lo,Fo,Xo.dataApiKeydownHandler),ft.on(document,Lo,Ro,Xo.dataApiKeydownHandler),ft.on(document,No,Xo.clearMenus),ft.on(document,jo,Xo.clearMenus),ft.on(document,No,Fo,function(e){e.preventDefault(),Xo.getOrCreateInstance(this).toggle()}),Xe(Xo);const Ko="backdrop",Go="show",Zo=`mousedown.bs.${Ko}`,ei={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},ti={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class oi extends wt{constructor(e){super(),this._config=this._getConfig(e),this._isAppended=!1,this._element=null}static get Default(){return ei}static get DefaultType(){return ti}static get NAME(){return Ko}show(e){if(!this._config.isVisible)return void Ke(e);this._append();const t=this._getElement();this._config.isAnimated&&Ve(t),t.classList.add(Go),this._emulateAnimation(()=>{Ke(e)})}hide(e){this._config.isVisible?(this._getElement().classList.remove(Go),this._emulateAnimation(()=>{this.dispose(),Ke(e)})):Ke(e)}dispose(){this._isAppended&&(ft.off(this._element,Zo),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const e=document.createElement("div");e.className=this._config.className,this._config.isAnimated&&e.classList.add("fade"),this._element=e}return this._element}_configAfterMerge(e){return e.rootElement=Re(e.rootElement),e}_append(){if(this._isAppended)return;const e=this._getElement();this._config.rootElement.append(e),ft.on(e,Zo,()=>{Ke(this._config.clickCallback)}),this._isAppended=!0}_emulateAnimation(e){Ge(e,this._getElement(),this._config.isAnimated)}}const ii=".bs.focustrap",ni=`focusin${ii}`,si=`keydown.tab${ii}`,ai="backward",ri={autofocus:!0,trapElement:null},li={autofocus:"boolean",trapElement:"element"};class ci extends wt{constructor(e){super(),this._config=this._getConfig(e),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return ri}static get DefaultType(){return li}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),ft.off(document,ii),ft.on(document,ni,e=>this._handleFocusin(e)),ft.on(document,si,e=>this._handleKeydown(e)),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,ft.off(document,ii))}_handleFocusin(e){const{trapElement:t}=this._config;if(e.target===document||e.target===t||t.contains(e.target))return;const o=$t.focusableChildren(t);0===o.length?t.focus():this._lastTabNavDirection===ai?o[o.length-1].focus():o[0].focus()}_handleKeydown(e){"Tab"===e.key&&(this._lastTabNavDirection=e.shiftKey?ai:"forward")}}const di=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",hi=".sticky-top",mi="padding-right",pi="margin-right";class ui{constructor(){this._element=document.body}getWidth(){const e=document.documentElement.clientWidth;return Math.abs(window.innerWidth-e)}hide(){const e=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,mi,t=>t+e),this._setElementAttributes(di,mi,t=>t+e),this._setElementAttributes(hi,pi,t=>t-e)}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,mi),this._resetElementAttributes(di,mi),this._resetElementAttributes(hi,pi)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(e,t,o){const i=this.getWidth();this._applyManipulationCallback(e,e=>{if(e!==this._element&&window.innerWidth>e.clientWidth+i)return;this._saveInitialAttribute(e,t);const n=window.getComputedStyle(e).getPropertyValue(t);e.style.setProperty(t,`${o(Number.parseFloat(n))}px`)})}_saveInitialAttribute(e,t){const o=e.style.getPropertyValue(t);o&&vt.setDataAttribute(e,t,o)}_resetElementAttributes(e,t){this._applyManipulationCallback(e,e=>{const o=vt.getDataAttribute(e,t);null!==o?(vt.removeDataAttribute(e,t),e.style.setProperty(t,o)):e.style.removeProperty(t)})}_applyManipulationCallback(e,t){if(ze(e))t(e);else for(const o of $t.find(e,this._element))t(o)}}const fi=".bs.modal",_i=`hide${fi}`,gi=`hidePrevented${fi}`,bi=`hidden${fi}`,vi=`show${fi}`,wi=`shown${fi}`,yi=`resize${fi}`,xi=`click.dismiss${fi}`,$i=`mousedown.dismiss${fi}`,qi=`keydown.dismiss${fi}`,ki=`click${fi}.data-api`,Ci="modal-open",Ai="show",Si="modal-static",Ei={backdrop:!0,focus:!0,keyboard:!0},Ti={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Oi extends yt{constructor(e,t){super(e,t),this._dialog=$t.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new ui,this._addEventListeners()}static get Default(){return Ei}static get DefaultType(){return Ti}static get NAME(){return"modal"}toggle(e){return this._isShown?this.hide():this.show(e)}show(e){if(this._isShown||this._isTransitioning)return;ft.trigger(this._element,vi,{relatedTarget:e}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(Ci),this._adjustDialog(),this._backdrop.show(()=>this._showElement(e)))}hide(){if(!this._isShown||this._isTransitioning)return;ft.trigger(this._element,_i).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Ai),this._queueCallback(()=>this._hideModal(),this._element,this._isAnimated()))}dispose(){ft.off(window,fi),ft.off(this._dialog,fi),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new oi({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new ci({trapElement:this._element})}_showElement(e){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const t=$t.findOne(".modal-body",this._dialog);t&&(t.scrollTop=0),Ve(this._element),this._element.classList.add(Ai);this._queueCallback(()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,ft.trigger(this._element,wi,{relatedTarget:e})},this._dialog,this._isAnimated())}_addEventListeners(){ft.on(this._element,qi,e=>{"Escape"===e.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())}),ft.on(window,yi,()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()}),ft.on(this._element,$i,e=>{ft.one(this._element,xi,t=>{this._element===e.target&&this._element===t.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())})})}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide(()=>{document.body.classList.remove(Ci),this._resetAdjustments(),this._scrollBar.reset(),ft.trigger(this._element,bi)})}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(ft.trigger(this._element,gi).defaultPrevented)return;const e=this._element.scrollHeight>document.documentElement.clientHeight,t=this._element.style.overflowY;"hidden"===t||this._element.classList.contains(Si)||(e||(this._element.style.overflowY="hidden"),this._element.classList.add(Si),this._queueCallback(()=>{this._element.classList.remove(Si),this._queueCallback(()=>{this._element.style.overflowY=t},this._dialog)},this._dialog),this._element.focus())}_adjustDialog(){const e=this._element.scrollHeight>document.documentElement.clientHeight,t=this._scrollBar.getWidth(),o=t>0;if(o&&!e){const e=Qe()?"paddingLeft":"paddingRight";this._element.style[e]=`${t}px`}if(!o&&e){const e=Qe()?"paddingRight":"paddingLeft";this._element.style[e]=`${t}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(e,t){return this.each(function(){const o=Oi.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===o[e])throw new TypeError(`No method named "${e}"`);o[e](t)}})}}ft.on(document,ki,'[data-bs-toggle="modal"]',function(e){const t=$t.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&e.preventDefault(),ft.one(t,vi,e=>{e.defaultPrevented||ft.one(t,bi,()=>{He(this)&&this.focus()})});const o=$t.findOne(".modal.show");o&&Oi.getInstance(o).hide();Oi.getOrCreateInstance(t).toggle(this)}),qt(Oi),Xe(Oi);const Di=".bs.offcanvas",Ii=".data-api",Pi=`load${Di}${Ii}`,Ni="show",Li="showing",ji="hiding",Mi=".offcanvas.show",Fi=`show${Di}`,zi=`shown${Di}`,Ri=`hide${Di}`,Hi=`hidePrevented${Di}`,Bi=`hidden${Di}`,Wi=`resize${Di}`,Ui=`click${Di}${Ii}`,Vi=`keydown.dismiss${Di}`,Yi={backdrop:!0,keyboard:!0,scroll:!1},Ji={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class Qi extends yt{constructor(e,t){super(e,t),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return Yi}static get DefaultType(){return Ji}static get NAME(){return"offcanvas"}toggle(e){return this._isShown?this.hide():this.show(e)}show(e){if(this._isShown)return;if(ft.trigger(this._element,Fi,{relatedTarget:e}).defaultPrevented)return;this._isShown=!0,this._backdrop.show(),this._config.scroll||(new ui).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Li);this._queueCallback(()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Ni),this._element.classList.remove(Li),ft.trigger(this._element,zi,{relatedTarget:e})},this._element,!0)}hide(){if(!this._isShown)return;if(ft.trigger(this._element,Ri).defaultPrevented)return;this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(ji),this._backdrop.hide();this._queueCallback(()=>{this._element.classList.remove(Ni,ji),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new ui).reset(),ft.trigger(this._element,Bi)},this._element,!0)}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const e=Boolean(this._config.backdrop);return new oi({className:"offcanvas-backdrop",isVisible:e,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:e?()=>{"static"!==this._config.backdrop?this.hide():ft.trigger(this._element,Hi)}:null})}_initializeFocusTrap(){return new ci({trapElement:this._element})}_addEventListeners(){ft.on(this._element,Vi,e=>{"Escape"===e.key&&(this._config.keyboard?this.hide():ft.trigger(this._element,Hi))})}static jQueryInterface(e){return this.each(function(){const t=Qi.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e]||e.startsWith("_")||"constructor"===e)throw new TypeError(`No method named "${e}"`);t[e](this)}})}}ft.on(document,Ui,'[data-bs-toggle="offcanvas"]',function(e){const t=$t.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&e.preventDefault(),Be(this))return;ft.one(t,Bi,()=>{He(this)&&this.focus()});const o=$t.findOne(Mi);o&&o!==t&&Qi.getInstance(o).hide();Qi.getOrCreateInstance(t).toggle(this)}),ft.on(window,Pi,()=>{for(const e of $t.find(Mi))Qi.getOrCreateInstance(e).show()}),ft.on(window,Wi,()=>{for(const e of $t.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(e).position&&Qi.getOrCreateInstance(e).hide()}),qt(Qi),Xe(Qi);const Xi={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Ki=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Gi=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Zi=(e,t)=>{const o=e.nodeName.toLowerCase();return t.includes(o)?!Ki.has(o)||Boolean(Gi.test(e.nodeValue)):t.filter(e=>e instanceof RegExp).some(e=>e.test(o))};const en={allowList:Xi,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
                "},tn={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},on={entry:"(string|element|function|null)",selector:"(string|element)"};class nn extends wt{constructor(e){super(),this._config=this._getConfig(e)}static get Default(){return en}static get DefaultType(){return tn}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map(e=>this._resolvePossibleFunction(e)).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(e){return this._checkContent(e),this._config.content={...this._config.content,...e},this}toHtml(){const e=document.createElement("div");e.innerHTML=this._maybeSanitize(this._config.template);for(const[i,n]of Object.entries(this._config.content))this._setContent(e,n,i);const t=e.children[0],o=this._resolvePossibleFunction(this._config.extraClass);return o&&t.classList.add(...o.split(" ")),t}_typeCheckConfig(e){super._typeCheckConfig(e),this._checkContent(e.content)}_checkContent(e){for(const[t,o]of Object.entries(e))super._typeCheckConfig({selector:t,entry:o},on)}_setContent(e,t,o){const i=$t.findOne(o,e);i&&((t=this._resolvePossibleFunction(t))?ze(t)?this._putElementInTemplate(Re(t),i):this._config.html?i.innerHTML=this._maybeSanitize(t):i.textContent=t:i.remove())}_maybeSanitize(e){return this._config.sanitize?function(e,t,o){if(!e.length)return e;if(o&&"function"==typeof o)return o(e);const i=(new window.DOMParser).parseFromString(e,"text/html"),n=[].concat(...i.body.querySelectorAll("*"));for(const s of n){const e=s.nodeName.toLowerCase();if(!Object.keys(t).includes(e)){s.remove();continue}const o=[].concat(...s.attributes),i=[].concat(t["*"]||[],t[e]||[]);for(const t of o)Zi(t,i)||s.removeAttribute(t.nodeName)}return i.body.innerHTML}(e,this._config.allowList,this._config.sanitizeFn):e}_resolvePossibleFunction(e){return Ke(e,[void 0,this])}_putElementInTemplate(e,t){if(this._config.html)return t.innerHTML="",void t.append(e);t.textContent=e.textContent}}const sn=new Set(["sanitize","allowList","sanitizeFn"]),an="fade",rn="show",ln=".tooltip-inner",cn=".modal",dn="hide.bs.modal",hn="hover",mn="focus",pn="click",un={AUTO:"auto",TOP:"top",RIGHT:Qe()?"left":"right",BOTTOM:"bottom",LEFT:Qe()?"right":"left"},fn={allowList:Xi,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},_n={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class gn extends yt{constructor(e,t){if(void 0===Ie)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org/docs/v2/)");super(e,t),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return fn}static get DefaultType(){return _n}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),ft.off(this._element.closest(cn),dn,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const e=ft.trigger(this._element,this.constructor.eventName("show")),t=(We(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(e.defaultPrevented||!t)return;this._disposePopper();const o=this._getTipElement();this._element.setAttribute("aria-describedby",o.getAttribute("id"));const{container:i}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(i.append(o),ft.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(o),o.classList.add(rn),"ontouchstart"in document.documentElement)for(const n of[].concat(...document.body.children))ft.on(n,"mouseover",Ue);this._queueCallback(()=>{ft.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1},this.tip,this._isAnimated())}hide(){if(!this._isShown())return;if(ft.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented)return;if(this._getTipElement().classList.remove(rn),"ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))ft.off(e,"mouseover",Ue);this._activeTrigger[pn]=!1,this._activeTrigger[mn]=!1,this._activeTrigger[hn]=!1,this._isHovered=null;this._queueCallback(()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),ft.trigger(this._element,this.constructor.eventName("hidden")))},this.tip,this._isAnimated())}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(e){const t=this._getTemplateFactory(e).toHtml();if(!t)return null;t.classList.remove(an,rn),t.classList.add(`bs-${this.constructor.NAME}-auto`);const o=(e=>{do{e+=Math.floor(1e6*Math.random())}while(document.getElementById(e));return e})(this.constructor.NAME).toString();return t.setAttribute("id",o),this._isAnimated()&&t.classList.add(an),t}setContent(e){this._newContent=e,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(e){return this._templateFactory?this._templateFactory.changeContent(e):this._templateFactory=new nn({...this._config,content:e,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{[ln]:this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(e){return this.constructor.getOrCreateInstance(e.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(an)}_isShown(){return this.tip&&this.tip.classList.contains(rn)}_createPopper(e){const t=Ke(this._config.placement,[this,e,this._element]),o=un[t.toUpperCase()];return De(this._element,e,this._getPopperConfig(o))}_getOffset(){const{offset:e}=this._config;return"string"==typeof e?e.split(",").map(e=>Number.parseInt(e,10)):"function"==typeof e?t=>e(t,this._element):e}_resolvePossibleFunction(e){return Ke(e,[this._element,this._element])}_getPopperConfig(e){const t={placement:e,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:e=>{this._getTipElement().setAttribute("data-popper-placement",e.state.placement)}}]};return{...t,...Ke(this._config.popperConfig,[void 0,t])}}_setListeners(){const e=this._config.trigger.split(" ");for(const t of e)if("click"===t)ft.on(this._element,this.constructor.eventName("click"),this._config.selector,e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger[pn]=!(t._isShown()&&t._activeTrigger[pn]),t.toggle()});else if("manual"!==t){const e=t===hn?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),o=t===hn?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");ft.on(this._element,e,this._config.selector,e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger["focusin"===e.type?mn:hn]=!0,t._enter()}),ft.on(this._element,o,this._config.selector,e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger["focusout"===e.type?mn:hn]=t._element.contains(e.relatedTarget),t._leave()})}this._hideModalHandler=()=>{this._element&&this.hide()},ft.on(this._element.closest(cn),dn,this._hideModalHandler)}_fixTitle(){const e=this._element.getAttribute("title");e&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",e),this._element.setAttribute("data-bs-original-title",e),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout(()=>{this._isHovered&&this.show()},this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout(()=>{this._isHovered||this.hide()},this._config.delay.hide))}_setTimeout(e,t){clearTimeout(this._timeout),this._timeout=setTimeout(e,t)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(e){const t=vt.getDataAttributes(this._element);for(const o of Object.keys(t))sn.has(o)&&delete t[o];return e={...t,..."object"==typeof e&&e?e:{}},e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e.container=!1===e.container?document.body:Re(e.container),"number"==typeof e.delay&&(e.delay={show:e.delay,hide:e.delay}),"number"==typeof e.title&&(e.title=e.title.toString()),"number"==typeof e.content&&(e.content=e.content.toString()),e}_getDelegateConfig(){const e={};for(const[t,o]of Object.entries(this._config))this.constructor.Default[t]!==o&&(e[t]=o);return e.selector=!1,e.trigger="manual",e}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(e){return this.each(function(){const t=gn.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError(`No method named "${e}"`);t[e]()}})}}Xe(gn);const bn=".popover-header",vn=".popover-body",wn={...gn.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},yn={...gn.DefaultType,content:"(null|string|element|function)"};class xn extends gn{static get Default(){return wn}static get DefaultType(){return yn}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{[bn]:this._getTitle(),[vn]:this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(e){return this.each(function(){const t=xn.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError(`No method named "${e}"`);t[e]()}})}}Xe(xn);const $n=".bs.scrollspy",qn=`activate${$n}`,kn=`click${$n}`,Cn=`load${$n}.data-api`,An="active",Sn="[href]",En=".nav-link",Tn=`${En}, .nav-item > ${En}, .list-group-item`,On={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},Dn={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class In extends yt{constructor(e,t){super(e,t),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return On}static get DefaultType(){return Dn}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const e of this._observableSections.values())this._observer.observe(e)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(e){return e.target=Re(e.target)||document.body,e.rootMargin=e.offset?`${e.offset}px 0px -30%`:e.rootMargin,"string"==typeof e.threshold&&(e.threshold=e.threshold.split(",").map(e=>Number.parseFloat(e))),e}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(ft.off(this._config.target,kn),ft.on(this._config.target,kn,Sn,e=>{const t=this._observableSections.get(e.target.hash);if(t){e.preventDefault();const o=this._rootElement||window,i=t.offsetTop-this._element.offsetTop;if(o.scrollTo)return void o.scrollTo({top:i,behavior:"smooth"});o.scrollTop=i}}))}_getNewObserver(){const e={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver(e=>this._observerCallback(e),e)}_observerCallback(e){const t=e=>this._targetLinks.get(`#${e.target.id}`),o=e=>{this._previousScrollData.visibleEntryTop=e.target.offsetTop,this._process(t(e))},i=(this._rootElement||document.documentElement).scrollTop,n=i>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=i;for(const s of e){if(!s.isIntersecting){this._activeTarget=null,this._clearActiveClass(t(s));continue}const e=s.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(n&&e){if(o(s),!i)return}else n||e||o(s)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const e=$t.find(Sn,this._config.target);for(const t of e){if(!t.hash||Be(t))continue;const e=$t.findOne(decodeURI(t.hash),this._element);He(e)&&(this._targetLinks.set(decodeURI(t.hash),t),this._observableSections.set(t.hash,e))}}_process(e){this._activeTarget!==e&&(this._clearActiveClass(this._config.target),this._activeTarget=e,e.classList.add(An),this._activateParents(e),ft.trigger(this._element,qn,{relatedTarget:e}))}_activateParents(e){if(e.classList.contains("dropdown-item"))$t.findOne(".dropdown-toggle",e.closest(".dropdown")).classList.add(An);else for(const t of $t.parents(e,".nav, .list-group"))for(const e of $t.prev(t,Tn))e.classList.add(An)}_clearActiveClass(e){e.classList.remove(An);const t=$t.find(`${Sn}.${An}`,e);for(const o of t)o.classList.remove(An)}static jQueryInterface(e){return this.each(function(){const t=In.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e]||e.startsWith("_")||"constructor"===e)throw new TypeError(`No method named "${e}"`);t[e]()}})}}ft.on(window,Cn,()=>{for(const e of $t.find('[data-bs-spy="scroll"]'))In.getOrCreateInstance(e)}),Xe(In);const Pn=".bs.tab",Nn=`hide${Pn}`,Ln=`hidden${Pn}`,jn=`show${Pn}`,Mn=`shown${Pn}`,Fn=`click${Pn}`,zn=`keydown${Pn}`,Rn=`load${Pn}`,Hn="ArrowLeft",Bn="ArrowRight",Wn="ArrowUp",Un="ArrowDown",Vn="Home",Yn="End",Jn="active",Qn="fade",Xn="show",Kn=".dropdown-toggle",Gn=`:not(${Kn})`,Zn='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',es=`${`.nav-link${Gn}, .list-group-item${Gn}, [role="tab"]${Gn}`}, ${Zn}`,ts=`.${Jn}[data-bs-toggle="tab"], .${Jn}[data-bs-toggle="pill"], .${Jn}[data-bs-toggle="list"]`;class os extends yt{constructor(e){super(e),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),ft.on(this._element,zn,e=>this._keydown(e)))}static get NAME(){return"tab"}show(){const e=this._element;if(this._elemIsActive(e))return;const t=this._getActiveElem(),o=t?ft.trigger(t,Nn,{relatedTarget:e}):null;ft.trigger(e,jn,{relatedTarget:t}).defaultPrevented||o&&o.defaultPrevented||(this._deactivate(t,e),this._activate(e,t))}_activate(e,t){if(!e)return;e.classList.add(Jn),this._activate($t.getElementFromSelector(e));this._queueCallback(()=>{"tab"===e.getAttribute("role")?(e.removeAttribute("tabindex"),e.setAttribute("aria-selected",!0),this._toggleDropDown(e,!0),ft.trigger(e,Mn,{relatedTarget:t})):e.classList.add(Xn)},e,e.classList.contains(Qn))}_deactivate(e,t){if(!e)return;e.classList.remove(Jn),e.blur(),this._deactivate($t.getElementFromSelector(e));this._queueCallback(()=>{"tab"===e.getAttribute("role")?(e.setAttribute("aria-selected",!1),e.setAttribute("tabindex","-1"),this._toggleDropDown(e,!1),ft.trigger(e,Ln,{relatedTarget:t})):e.classList.remove(Xn)},e,e.classList.contains(Qn))}_keydown(e){if(![Hn,Bn,Wn,Un,Vn,Yn].includes(e.key))return;e.stopPropagation(),e.preventDefault();const t=this._getChildren().filter(e=>!Be(e));let o;if([Vn,Yn].includes(e.key))o=t[e.key===Vn?0:t.length-1];else{const i=[Bn,Un].includes(e.key);o=Ze(t,e.target,i,!0)}o&&(o.focus({preventScroll:!0}),os.getOrCreateInstance(o).show())}_getChildren(){return $t.find(es,this._parent)}_getActiveElem(){return this._getChildren().find(e=>this._elemIsActive(e))||null}_setInitialAttributes(e,t){this._setAttributeIfNotExists(e,"role","tablist");for(const o of t)this._setInitialAttributesOnChild(o)}_setInitialAttributesOnChild(e){e=this._getInnerElement(e);const t=this._elemIsActive(e),o=this._getOuterElement(e);e.setAttribute("aria-selected",t),o!==e&&this._setAttributeIfNotExists(o,"role","presentation"),t||e.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(e,"role","tab"),this._setInitialAttributesOnTargetPanel(e)}_setInitialAttributesOnTargetPanel(e){const t=$t.getElementFromSelector(e);t&&(this._setAttributeIfNotExists(t,"role","tabpanel"),e.id&&this._setAttributeIfNotExists(t,"aria-labelledby",`${e.id}`))}_toggleDropDown(e,t){const o=this._getOuterElement(e);if(!o.classList.contains("dropdown"))return;const i=(e,i)=>{const n=$t.findOne(e,o);n&&n.classList.toggle(i,t)};i(Kn,Jn),i(".dropdown-menu",Xn),o.setAttribute("aria-expanded",t)}_setAttributeIfNotExists(e,t,o){e.hasAttribute(t)||e.setAttribute(t,o)}_elemIsActive(e){return e.classList.contains(Jn)}_getInnerElement(e){return e.matches(es)?e:$t.findOne(es,e)}_getOuterElement(e){return e.closest(".nav-item, .list-group-item")||e}static jQueryInterface(e){return this.each(function(){const t=os.getOrCreateInstance(this);if("string"==typeof e){if(void 0===t[e]||e.startsWith("_")||"constructor"===e)throw new TypeError(`No method named "${e}"`);t[e]()}})}}ft.on(document,Fn,Zn,function(e){["A","AREA"].includes(this.tagName)&&e.preventDefault(),Be(this)||os.getOrCreateInstance(this).show()}),ft.on(window,Rn,()=>{for(const e of $t.find(ts))os.getOrCreateInstance(e)}),Xe(os);const is=".bs.toast",ns=`mouseover${is}`,ss=`mouseout${is}`,as=`focusin${is}`,rs=`focusout${is}`,ls=`hide${is}`,cs=`hidden${is}`,ds=`show${is}`,hs=`shown${is}`,ms="hide",ps="show",us="showing",fs={animation:"boolean",autohide:"boolean",delay:"number"},_s={animation:!0,autohide:!0,delay:5e3};class gs extends yt{constructor(e,t){super(e,t),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return _s}static get DefaultType(){return fs}static get NAME(){return"toast"}show(){if(ft.trigger(this._element,ds).defaultPrevented)return;this._clearTimeout(),this._config.animation&&this._element.classList.add("fade");this._element.classList.remove(ms),Ve(this._element),this._element.classList.add(ps,us),this._queueCallback(()=>{this._element.classList.remove(us),ft.trigger(this._element,hs),this._maybeScheduleHide()},this._element,this._config.animation)}hide(){if(!this.isShown())return;if(ft.trigger(this._element,ls).defaultPrevented)return;this._element.classList.add(us),this._queueCallback(()=>{this._element.classList.add(ms),this._element.classList.remove(us,ps),ft.trigger(this._element,cs)},this._element,this._config.animation)}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(ps),super.dispose()}isShown(){return this._element.classList.contains(ps)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(e,t){switch(e.type){case"mouseover":case"mouseout":this._hasMouseInteraction=t;break;case"focusin":case"focusout":this._hasKeyboardInteraction=t}if(t)return void this._clearTimeout();const o=e.relatedTarget;this._element===o||this._element.contains(o)||this._maybeScheduleHide()}_setListeners(){ft.on(this._element,ns,e=>this._onInteraction(e,!0)),ft.on(this._element,ss,e=>this._onInteraction(e,!1)),ft.on(this._element,as,e=>this._onInteraction(e,!0)),ft.on(this._element,rs,e=>this._onInteraction(e,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(e){return this.each(function(){const t=gs.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError(`No method named "${e}"`);t[e](this)}})}}qt(gs),Xe(gs);const bs=Object.freeze(Object.defineProperty({__proto__:null,Alert:St,Button:Tt,Carousel:ho,Collapse:ko,Dropdown:Xo,Modal:Oi,Offcanvas:Qi,Popover:xn,ScrollSpy:In,Tab:os,Toast:gs,Tooltip:gn},Symbol.toStringTag,{value:"Module"}));function vs(e){const t=new TextDecoder("utf-8").decode(e);return JSON.parse(t)}function ws(e,t){try{t(vs(pako.inflate(e)),null)}catch(o){console.error("Decompression with pako failed:",o),t(null,o)}}window.decompressPlotData=function(e,t){const o=atob(e),i=Uint8Array.from(o,e=>e.codePointAt(0));"DecompressionStream"in window?function(e,t){const o=new DecompressionStream("gzip"),i=new Response(e.buffer).body.pipeThrough(o);new Response(i).arrayBuffer().then(e=>{const o=new Uint8Array(e);t(vs(o),null)}).catch(o=>(console.error("Decompression with DecompressionStream failed:",o),ws(e,t)))}(i,t):ws(i,t)},window.callAfterDecompressed=[],window.notEmptyObj=function(e){try{if(void 0===e)return!1;if(0===e.length)return!1}catch(t){return!1}return!0},window.showToast=function(e,t,o="info",i=5e3){const n=document.getElementById("toast-template"),s=document.getElementById("toast-container"),a=n.cloneNode(!0),r="toast-"+Date.now()+"-"+Math.random().toString(36).substr(2,9);a.id=r,a.classList.remove("d-none");const l={success:{bg:"text-bg-success",icon:"✓"},warning:{bg:"text-bg-warning",icon:"⚠"},error:{bg:"text-bg-danger",icon:"✗"},info:{bg:"text-bg-info",icon:"ℹ"}},c=l[o]||l.info,d=a.querySelector(".toast-header"),h=a.querySelector(".toast-icon"),m=a.querySelector(".toast-heading"),p=a.querySelector(".toast-body");d.className=`toast-header ${c.bg}`,h.textContent=c.icon,m.textContent=e,p.innerHTML=t,s.appendChild(a);new bootstrap.Toast(a,{autohide:!1!==i,delay:i}).show(),a.addEventListener("hidden.bs.toast",()=>{a.remove()})},$(function(){[...document.querySelectorAll('[data-bs-toggle="tooltip"]')].map(e=>new bootstrap.Tooltip(e)),$("#side-nav-handle").click(function(e){$(".mainpage, .side-nav").toggleClass("hidden-nav");const t=$("#side-nav-handle svg"),o="none"!==t.css("transform")&&"matrix(1, 0, 0, 1, 0, 0)"!==t.css("transform");t.css("transform",o?"rotate(0deg)":"rotate(180deg)"),setTimeout(function(){$(document).resize()},510)});try{"true"!==localStorage.getItem("mqc_hide_welcome")&&$("#mqc_welcome").show(),$("#mqc_hide_welcome_btn").click(function(e){localStorage.setItem("mqc_hide_welcome","true")})}catch(e){console.log("Could not access localStorage: "+e+"\nPlease disable 'Block third-party cookies and site data' or browser equivalent.")}$("#mqc_hide_welcome_btn, #mqc_welcome .close").click(function(e){$("#mqc_header_hr").show()})}),$(function(){function e(e){let t=$(e).closest(".mqc_mplplot_plotgroup"),o=t.data("plot-anchor"),i=t.find(".dataset-switch-group button.active");void 0!==i&&i.length>0&&(o=i.data("datasetUid"));let n=t.find(".percent-switch"),s=t.find(".log10-switch");if(n.length>0||s.length>0){let e=n.length>0&&n.hasClass("active"),t=s.length>0&&s.hasClass("active");e&&(o+="-pct"),t&&(o+="-log"),e||t||(o+="-cnt")}t.find(".mqc_mplplot").hide(),$("#"+o).show()}$(".mpl_switch_group.percent-switch,.mpl_switch_group.log10-switch").click(function(t){t.preventDefault(),$(this).toggleClass("active"),e(this)}),$(".mpl_switch_group.dataset-switch-group button").click(function(t){t.preventDefault(),$(this).hasClass("active")||($(this).siblings("button.active").removeClass("active"),$(this).addClass("active"),e(this))})}),window.mqc_plots={},window.mqc_highlight_f_texts=[],window.mqc_highlight_f_cols=[],window.mqc_highlight_regex_mode=!1,window.mqc_rename_f_texts=[],window.mqc_rename_t_texts=[],window.mqc_hide_mode="hide",window.mqc_hide_f_texts=[],window.mqc_hide_regex_mode=!1;let ys;window.Plot=class{constructor(e){this.anchor=e.anchor,this.layout=e.layout,this.datasets=e.datasets,this.pctAxisUpdate=e.pct_axis_update,this.axisControlledBySwitches=e.axis_controlled_by_switches,this.square=e.square,this.rendered=!1,this.activeDatasetIdx=0,this.lActive=e.l_active,this.pActive=e.p_active,this.deferRender=e.defer_render,this.plotType=e.plot_type,this.pconfig=e.pconfig}activeDatasetSize(){throw new Error("activeDatasetSize() not implemented")}resize(e,t){null!=e?(this.layout.height=e,this.layout.autosize=!0,null!=t?(this.layout.width=t,this.layout.autosize=!1):this.square?this.layout.width=e:this.layout.width=null,Plotly.relayout(this.anchor,this.layout)):console.error("Plot.resize: newHeight is "+e)}buildTraces(){throw new Error("buildTraces() not implemented")}afterPlotCreated(){}plotAiHeader(e){return"Plot type: "+this.plotType+"\n"}formatDatasetForAiPrompt(e){return""}formatForAiPrompt(e){let t=this.plotAiHeader(e)+"\n\n";if(1===this.datasets.length)return t+this.formatDatasetForAiPrompt(this.datasets[0]);for(let o of this.datasets){let e=this.formatDatasetForAiPrompt(o);e&&(t+="### "+o.label+"\n",t+="\n",t+=e,t+="\n\n")}return t}recalculateTicks(e,t,o){let i=e.filter(e=>e.highlight),n=this.firstHighlightedSample(e);if(0===i.length)t.tickmode=null,t.tickvals=null,t.ticktext=null;else{t.tickmode="array";let i=function(e,t,o=0,i=!0){if(e.length<=t)return e;if(e.length<=1)return e;if(0===t)return[];if(1===t)return[e[o]];let n=(e.length-1)/(t-1);i&&(n=Math.ceil(n));let s=Array.from({length:e.length},(e,t)=>t),a=s.slice(o),r=s.slice(0,o+1);return a=Array.from({length:a.length},(e,t)=>Math.ceil(n*t)).filter(e=>ea[e]),r.reverse(),r=Array.from({length:r.length},(e,t)=>Math.ceil(n*t)).filter(e=>er[e]),r.reverse(),r=r.slice(0,r.length-1),s=r.concat(a),s.map(t=>e[t])}(e,o,n);t.tickvals=i.map(e=>e.name),t.ticktext=i.map(e=>""+e.name+"")}}firstHighlightedSample(e){let t=0;return e.filter(e=>e.highlight).length>0&&(t=e.findIndex(e=>e.highlight)),t}},$(function(){ys=$(".mqc_loading_warning").show()}),window.callAfterDecompressed.push(function(e){window.mqc_plots=Object.fromEntries(Object.values(e).map(e=>{return[e.anchor,(t=e,"bar plot"===t.plot_type?new BarPlot(t):"x/y line"===t.plot_type?new LinePlot(t):"box plot"===t.plot_type?new BoxPlot(t):"scatter plot"===t.plot_type?new ScatterPlot(t):"violin plot"===t.plot_type?new ViolinPlot(t):"heatmap"===t.plot_type?new HeatmapPlot(t):(console.log("Did not recognise plot type: "+t.plot_type),null))];var t}));let t=$(".hc-plot.not_loaded:visible");t.each(function(){let e=$(this).attr("id"),t=mqc_plots[e];setTimeout(function(){t.deferRender?$("#"+e).removeClass("not_loaded").html(''):$s(e),0===$(".hc-plot.not_loaded:visible").length&&$(".mqc_loading_warning").hide()},50)}),0===t.length&&ys.hide(),$("body").on("click",".render_plot",function(e){$s($(this).parent().attr("id"))}),$("#mqc-render-all-plots").click(function(){$(".hc-plot").each(function(){$s($(this).attr("id"))}),$(this).parent().hide()}),$(document).on("mqc_highlights mqc_renamesamples mqc_hidesamples",function(){$(".hc-plot:not(.not_rendered)").each(function(){$s($(this).attr("id"))})}),$("button.interactive-switch-group.percent-switch").click(function(e){e.preventDefault();let t=$(this).data("plot-anchor");mqc_plots[t].pActive=!$(this).hasClass("active"),$(this).toggleClass("active"),mqc_plots[t].rendered&&$s(t)}),$("button.interactive-switch-group.log10-switch").click(function(e){e.preventDefault();let t=$(this).data("plot-anchor");mqc_plots[t].lActive=!$(this).hasClass("active"),$(this).toggleClass("active"),mqc_plots[t].rendered&&$s(t)}),$(".interactive-switch-group.dataset-switch-group button").click(function(e){e.preventDefault();let t=$(this);if(t.hasClass("active"))return;t.siblings("button.active").removeClass("active"),t.addClass("active");let o=t.data("plot-anchor"),i=mqc_plots[o].activeDatasetIdx,n=t.data("datasetIndex");mqc_plots[o].activeDatasetIdx=n,i!==n&&mqc_plots[o].rendered&&$s(o)}),$(".hc-plot:not(.no-handle)").each(function(){let e=$(this);e.parent().hasClass("hc-plot-wrapper")||e.wrap('
                '),e.siblings().hasClass("hc-plot-handle")||e.after('
                '),e.css({height:"auto",top:0,bottom:"6px",position:"absolute"})}),$(".hc-plot-handle").on("mousedown",function(e){let t=$(this).parent(),o=t.children(".hc-plot")[0].id,i=t.height(),n=e.pageY,s=$(document);s.on("mouseup",function(){s.off("mousemove"),s.off("mouseup"),$(t.parent().find(".hc-plot, .beeswarm-plot")).trigger("mqc_plotresize")}),$(document).on("mousemove",function(e){let s=i+(e.pageY-n)+2;t.css("height",s),void 0!==mqc_plots[o]&&mqc_plots[o].resize(s-7)})})});class xs{constructor(e){this.originalName=e,this.name=e,this.highlight=null,this.hidden=!1,this.pseudonym=function(e){if(!getStoredSampleAnonymizationEnabled())return;if(!aiPseudonymMap)return;if(aiPseudonymMap[e])return aiPseudonymMap[e];let t=Object.keys(aiPseudonymMap).sort((e,t)=>t.length-e.length),o=e;for(let i of t)o.includes(i)&&(o=o.replace(i,aiPseudonymMap[i]));return o}(e)}}function $s(e){let t=mqc_plots[e];if(void 0===t)return!1;if(0===t.datasets.length)return!1;let o,i=$("#"+e);t.rendered?o=Plotly.react:(o=Plotly.newPlot,t.rendered=!0,i.removeClass("not_rendered").removeClass("not_loaded").parent().find(".render_plot").remove(),0===$(".hc-plot.not_rendered").length&&$("#mqc-warning-many-samples").hide());let n=t.datasets[t.activeDatasetIdx];qs(t.layout,n.layout,!1);const s=ks();t.layout.font&&(t.layout.font.color=s.textcolor),t.layout.title&&(t.layout.title.font??={},t.layout.title.font.color=s.textcolor),t.layout.legend&&(t.layout.legend.font??={},t.layout.legend.font.color=s.textcolor),t.layout.hoverlabel=t.layout.hoverlabel||{},t.layout.hoverlabel.bgcolor=s.hoverlabel_bgcolor,t.layout.hoverlabel.bordercolor=s.hoverlabel_bordercolor,t.layout.hoverlabel.font=t.layout.hoverlabel.font||{},t.layout.hoverlabel.font.color=s.hoverlabel_fontcolor,t.layout.xaxis&&!0===t.layout.xaxis.showspikes&&(t.layout.xaxis.spikecolor=s.spike_color),t.layout.yaxis&&!0===t.layout.yaxis.showspikes&&(t.layout.yaxis.spikecolor=s.spike_color);for(let r=1;r<=20;r++){let e="xaxis"+(1===r?"":r),o="yaxis"+(1===r?"":r);t.layout[e]&&(t.layout[e].gridcolor=s.gridcolor,t.layout[e].zerolinecolor=s.zerolinecolor,t.layout[e].color=s.axiscolor,t.layout[e].tickfont??={},t.layout[e].tickfont.color=s.tickcolor,!0===t.layout[e].showspikes&&(t.layout[e].spikecolor=s.spike_color)),t.layout[o]&&(t.layout[o].gridcolor=s.gridcolor,t.layout[o].zerolinecolor=s.zerolinecolor,t.layout[o].color=s.axiscolor,t.layout[o].tickfont??={},t.layout[o].tickfont.color=s.tickcolor,!0===t.layout[o].showspikes&&(t.layout[o].spikecolor=s.spike_color))}t.axisControlledBySwitches.map(e=>{t.layout[e].type="linear";let o=t.layout[e].autorangeoptions.minallowed,i=t.layout[e].autorangeoptions.maxallowed;t.pActive&&(qs(t.layout[e],t.pctAxisUpdate,!1),o=n.pct_range[e].min,i=n.pct_range[e].max),t.lActive&&(t.layout[e].type="log",o=o&&o>0?Math.log10(o):null,i=o&&i>0?Math.log10(i):null),t.layout[e].autorangeoptions.minallowed=o,t.layout[e].autorangeoptions.maxallowed=i});let a=t.buildTraces();a.length>0&&a[0].constructor===Array&&(a=[].concat.apply([],a)),0!==a.length?(i.show(),o(e,a,t.layout,{responsive:!0,displaylogo:!1,displayModeBar:!0,toImageButtonOptions:{filename:e},modeBarButtonsToRemove:["lasso2d","autoScale2d","pan2d","select2d","zoom2d","zoomIn2d","zoomOut2d","resetScale2d","toImage"]}),t.afterPlotCreated()):i.hide()}function qs(e,t,o=!1){for(const i in t)Array.isArray(t[i])?o&&void 0!==e[i]&&null!==e[i]||(e[i]=[...t[i]]):"object"==typeof t[i]?(e[i]&&"object"==typeof e[i]||(e[i]={}),qs(e[i],t[i],o)):o&&void 0!==e[i]&&null!==e[i]||(e[i]=t[i])}function ks(){return"dark"===document.documentElement.getAttribute("data-bs-theme")?{paper_bgcolor:"rgba(0,0,0,0)",plot_bgcolor:"rgba(0,0,0,0)",gridcolor:"rgba(180,180,180,0.25)",zerolinecolor:"rgba(180,180,180,0.3)",axiscolor:"rgba(200,200,200,1)",tickcolor:"rgba(220,220,220,1)",textcolor:"rgba(220,220,220,1)",modebar_color:"rgba(200, 200, 200, 0.5)",modebar_activecolor:"rgba(220, 220, 220, 1)",hoverlabel_bgcolor:"rgba(40,40,40,1)",hoverlabel_bordercolor:"rgba(100,100,100,1)",hoverlabel_fontcolor:"rgba(220,220,220,1)",spike_color:"rgba(220,220,220,1)"}:{paper_bgcolor:"rgba(0,0,0,0)",plot_bgcolor:"rgba(0,0,0,0)",gridcolor:"rgba(128,128,128,0.15)",zerolinecolor:"rgba(128,128,128,0.2)",axiscolor:"rgba(100,100,100,1)",tickcolor:"rgba(80,80,80,1)",textcolor:"rgba(60,60,60,1)",modebar_color:"rgba(100, 100, 100, 0.5)",modebar_activecolor:"rgba(80, 80, 80, 1)",hoverlabel_bgcolor:"rgba(255,255,255,1)",hoverlabel_bordercolor:"rgba(100,100,100,1)",hoverlabel_fontcolor:"rgba(30,30,30,1)",spike_color:"rgba(80,80,80,1)"}}function Cs(e){let t=e.attr("id"),o=t.replace("_config_modal_table",""),i=[];$("#"+t+" tbody tr").each(function(){i.push($(this).attr("class"))}),$("#"+o+" tr").each(function(){let e={},t=$(this);t.find("td, th").each(function(){let t=$(this);$.each(i,function(o,i){t.hasClass(i)&&(e[i]=t.detach())})});for(let o in i){let n=i[o];void 0!==e[n]&&t.append(e[n])}})}window.applyToolboxSettings=function(e,t){let o=e.map(e=>new xs(e));if(window.mqc_rename_f_texts.length>0&&o.map(e=>{for(let t=0;t0&&o.map(e=>{for(let t=0;t-1&&(n=!0),n&&(e.highlight=i)}}),window.mqc_hide_f_texts.length>0){let e=$("#"+t).closest(".mqc_hcplot_plotgroup");e.parent().find(".samples-hidden-warning").remove(),e.show(),o.map(e=>{let t=!1;for(let o=0;o-1&&(t=!0)}"show"===window.mqc_hide_mode&&(t=!t),t&&(e.hidden=!0)});let i=o.filter(e=>e.hidden).length;if(i>0){const t=`\n
                \n ⚠ Warning: ${i} samples hidden.\n See toolbox.\n
                `;e.before(t)}if(i===o.length)return e.hide(),o}return o},window.renderPlot=$s,window.updateObject=qs,window.getPlotlyThemeColors=ks,$(function(){new MutationObserver(e=>{e.forEach(e=>{"attributes"===e.type&&"data-bs-theme"===e.attributeName&&function(){const e=ks();$(".hc-plot:not(.not_rendered)").each(function(){const t=$(this).attr("id"),o=mqc_plots[t];if(!o||!o.rendered)return;const i={paper_bgcolor:e.paper_bgcolor,plot_bgcolor:e.plot_bgcolor,"font.color":e.textcolor,"title.font.color":e.textcolor,"legend.font.color":e.textcolor,"xaxis.gridcolor":e.gridcolor,"xaxis.zerolinecolor":e.zerolinecolor,"xaxis.color":e.axiscolor,"xaxis.tickfont.color":e.tickcolor,"xaxis.title.font.color":e.textcolor,"yaxis.gridcolor":e.gridcolor,"yaxis.zerolinecolor":e.zerolinecolor,"yaxis.color":e.axiscolor,"yaxis.tickfont.color":e.tickcolor,"yaxis.title.font.color":e.textcolor,"modebar.color":e.modebar_color,"modebar.activecolor":e.modebar_activecolor,"hoverlabel.bgcolor":e.hoverlabel_bgcolor,"hoverlabel.bordercolor":e.hoverlabel_bordercolor,"hoverlabel.font.color":e.hoverlabel_fontcolor};o.layout.xaxis&&!0===o.layout.xaxis.showspikes&&(i["xaxis.spikecolor"]=e.spike_color),o.layout.yaxis&&!0===o.layout.yaxis.showspikes&&(i["yaxis.spikecolor"]=e.spike_color);for(let a=2;a<=20;a++)o.layout["xaxis"+a]&&(i["xaxis"+a+".gridcolor"]=e.gridcolor,i["xaxis"+a+".zerolinecolor"]=e.zerolinecolor,i["xaxis"+a+".color"]=e.axiscolor,i["xaxis"+a+".tickfont.color"]=e.tickcolor,i["xaxis"+a+".title.font.color"]=e.textcolor),o.layout["yaxis"+a]&&(i["yaxis"+a+".gridcolor"]=e.gridcolor,i["yaxis"+a+".zerolinecolor"]=e.zerolinecolor,i["yaxis"+a+".color"]=e.axiscolor,i["yaxis"+a+".tickfont.color"]=e.tickcolor,i["yaxis"+a+".title.font.color"]=e.textcolor);Plotly.relayout(t,i);const n="dark"===document.documentElement.getAttribute("data-bs-theme"),s=document.getElementById(t);s&&s.data&&s.data.forEach((e,o)=>{if("scatter"===e.type&&e.marker&&e.marker.color){const i=e.marker.color;let s=null;n?"#000000"===i?s="#ffffff":"#0b79e6"===i&&(s="#5dade2"):"#ffffff"===i?s="#000000":"#5dade2"===i&&(s="#0b79e6"),s&&Plotly.restyle(t,{"marker.color":s},[o]),Plotly.restyle(t,{"hoverlabel.bgcolor":n?"rgba(40,40,40,1)":"rgba(255,255,255,1)","hoverlabel.font.color":n?"rgba(220,220,220,1)":"rgba(30,30,30,1)"},[o])}})});const t=document.getElementById("table_scatter_plot");if(t&&!$(t).hasClass("not_rendered")){const t={paper_bgcolor:e.paper_bgcolor,plot_bgcolor:e.plot_bgcolor,"font.color":e.textcolor,"title.font.color":e.textcolor,"xaxis.gridcolor":e.gridcolor,"xaxis.zerolinecolor":e.zerolinecolor,"xaxis.color":e.axiscolor,"xaxis.tickfont.color":e.tickcolor,"xaxis.title.font.color":e.textcolor,"yaxis.gridcolor":e.gridcolor,"yaxis.zerolinecolor":e.zerolinecolor,"yaxis.color":e.axiscolor,"yaxis.tickfont.color":e.tickcolor,"yaxis.title.font.color":e.textcolor,"hoverlabel.bgcolor":e.hoverlabel_bgcolor,"hoverlabel.bordercolor":e.hoverlabel_bordercolor,"hoverlabel.font.color":e.hoverlabel_fontcolor};Plotly.relayout("table_scatter_plot",t)}}()})}).observe(document.documentElement,{attributes:!0,attributeFilter:["data-bs-theme"]})}),$(function(){if($(".mqc_per_sample_table").length>0){let e=function(e){let t=$(e).data("sorting-val");if(null!=t){if(""===t)return t;let e=parseFloat(t);return isNaN(e),t}const o=e.innerText;return o.length>0&&o[0].match(/\d/)?o.replace(/[^\d.]/g,""):o};$(".mqc_per_sample_table").tablesorter({sortInitialOrder:"desc",textExtraction:e,cancelSelection:!1,headers:null}),$(document).on("mqc_renamesamples",function(e,t,o,i){$(".mqc_per_sample_table").trigger("update")}),$(".mqc-table-to-violin").click(function(e){e.preventDefault();let t=$(this).data("table-anchor"),o=$(this).data("violin-anchor");$("#mqc_violintable_wrapper_"+t).hide(),$("#mqc_violintable_wrapper_"+o).show(),renderPlot(o)}),$(".mqc-violin-to-table").click(function(e){e.preventDefault();let t=$(this).data("table-anchor"),o=$(this).data("violin-anchor");$("#mqc_violintable_wrapper_"+t).show(),$("#mqc_violintable_wrapper_"+o).hide()}),$(".mqc_table_copy_btn").click(function(){let e=$(this),t=$(e.data("clipboard-target"))[0];const o=document.createRange();o.selectNode(t),window.getSelection().removeAllRanges(),window.getSelection().addRange(o);try{document.execCommand("copy"),window.getSelection().removeAllRanges();const t='';e.addClass("active").html(t+" Copied!"),setTimeout(()=>{e.removeClass("active").html(t+" Copy table")},2e3)}catch(i){console.error("Failed to copy table: ",i)}}),$(".mqc-table-responsive").scroll(function(){$(this).find("thead").css("transform","translate(0,"+$(this).scrollTop()+"px)")}),$(".mqc_table_tooltip").tooltip({container:"body"}),$(".mqc-table-expand").click(function(){$(this).find("span").hasClass("glyphicon-chevron-down")?($(this).parent().find(".mqc-table-responsive").css("max-height","none"),$(this).find("span").removeClass("glyphicon-chevron-down").addClass("glyphicon-chevron-up")):($(this).parent().find(".mqc-table-responsive").css("max-height","400px"),$(this).find("span").removeClass("glyphicon-chevron-down").addClass("glyphicon-chevron-down"))}),$(document).on("mqc_highlights",function(e,t,o,i){$(".mqc_table_sortHighlight").hide(),$(".mqc_per_sample_table tbody th").removeClass("highlighted").removeData("highlight"),$(".mqc_per_sample_table tbody th").each(function(e){let n=$(this),s=$(this).text(),a="#333";$.each(t,function(e,t){(i&&s.match(t)||!i&&s.indexOf(t)>-1)&&(a=o[e]??"#cccccc",n.addClass("highlighted").data("highlight",e),$(".mqc_table_sortHighlight").show())}),$(this).css("color",a)})}),$(".mqc_table_sortHighlight").click(function(e){e.preventDefault();let t=$(this).data("table-anchor"),o=$("#"+t+" tbody th.highlighted").parent().detach();o=o.sort(function(e,t){return $(e).find("th").data("highlight")-$(t).find("th").data("highlight")}),"desc"===$(this).data("direction")?(o=o.get().reverse(),$("#"+t+" tbody").prepend(o),$(this).data("direction","asc")):($("#"+t+" tbody").append(o),$(this).data("direction","desc"))}),$(document).on("mqc_renamesamples",function(e,t,o,i){$(".mqc_per_sample_table tbody th span.th-sample-name").each(function(){let e=String($(this).data("original-sn"));$.each(t,function(t,n){if(i){let i=new RegExp(n,"g");e=e.replace(i,o[t])}else e=e.replace(n,o[t])}),$(this).text(e)})}),$(document).on("mqc_hidesamples",function(e,t,o){$(".mqc_per_sample_table tbody tr").each(function(){let e=$(this),i=e.find("th"),n=!1,s=i.find(".th-sample-name").text(),a=e.data("sample-group");$.each(t,function(e,t){o?(s.match(t)||a.match(t))&&(n=!0):(s.indexOf(t)>-1||a.indexOf(t)>-1)&&(n=!0)}),"show"===window.mqc_hide_mode&&(n=!n),n?e.addClass("sample-hidden"):e.removeClass("sample-hidden")}),$(".mqc_table_numrows").each(function(){let e=$(this).attr("id").replace("_numrows","");$(this).text($("#"+e+" tbody tr:visible").length)}),$(".mqc_per_sample_table").each(function(){let e=$(this),t=0;e.find("thead th").each(function(){let o=$(this);if(0===t)return t+=1,!0;let i=0,n=0;e.find("tbody tr td:nth-child("+(t+2)+")").filter(":visible").each(function(){let e=$(this);i+=1,""===e.text()&&(n+=1)}),i>0&&i===n&&(o.addClass("column-hidden"),e.find("tbody tr td:nth-child("+(t+2)+")").addClass("column-hidden")),t+=1})}),$(".mqc_table_numcols").each(function(){let e=$(this).attr("id").replace("_numcols","");$(this).text($("#"+e+" thead th:visible").length-1)})}),$(".expandable-row-primary").click(function(e){if(e.preventDefault(),window.getSelection().toString().length>0)return;let t=$(this),o=t.closest("table"),i=t.data("table-id"),n=t.data("sample-group");o.find("tbody tr.expandable-row-secondary[data-sample-group='"+n+"'][data-table-id='"+i+"']").toggleClass("expandable-row-secondary-hidden"),t.toggleClass("expanded")}),$(".th-sample-name").click(function(e){})}$("#table_scatter_form").submit(function(e){e.preventDefault()}),$(".mqc_table_make_scatter").click(function(e){let t=$(this).data("table-anchor"),o=$("#table_scatter_table_anchor");o.val()!==t&&($("#table_scatter_col1, #table_scatter_col2").html(''),$("#"+t+" thead tr th").each(function(e){let t=$(this).attr("id");if(void 0!==t){let e=$(this).text(),o=$(this).data("namespace");o&&(e=o+": "+e),$("#table_scatter_col1, #table_scatter_col2").append('`).val(e+(o?" [default]":"")),$('

                Settings saved.

                ').hide().insertBefore($("#mqc_saveconfig_form")).slideDown(function(){setTimeout(function(){$("#mqc-save-success").slideUp(function(){$(this).remove()})},5e3)}))}catch(a){console.log("Error updating localstorage: "+a)}}function Is(e,t){if(void 0===e)return!1;var o={};if(t)o=t;else try{var i=localStorage.getItem("mqc_config");null!=i&&(i=JSON.parse(i),o=i[e])}catch(n){console.log("Could not access localStorage")}return notEmptyObj(o.rename_regex)&&!0===o.rename_regex&&($("#mqc_renamesamples .mqc_regex_mode input").prop("checked",!0),window.mqc_rename_regex_mode=!0),notEmptyObj(o.rename_from_texts)&¬EmptyObj(o.rename_to_texts)&&(window.mqc_rename_f_texts=[],window.mqc_rename_t_texts=[],$.each(o.rename_from_texts,function(e,t){var i=o.rename_to_texts[e];if(0===t.length)return!0;window.mqc_rename_f_texts.push(t),window.mqc_rename_t_texts.push(i),$("#mqc_renamesamples_filters").append(window.make_renamesamples_filter(t,i))}),$(document).trigger("mqc_renamesamples",[window.mqc_rename_f_texts,window.mqc_rename_t_texts,o.rename_regex])),notEmptyObj(o.highlight_regex)&&!0===o.highlight_regex&&($("#mqc_cols .mqc_regex_mode input").prop("checked",!0),window.mqc_highlight_regex_mode=!0),notEmptyObj(o.highlights_f_texts)&¬EmptyObj(o.highlights_f_cols)&&(window.mqc_highlight_f_texts=[],window.mqc_highlight_f_cols=[],$.each(o.highlights_f_texts,function(e,t){if(0===t.length)return!0;var i=o.highlights_f_cols[e];$("#"+hashCode(t+i)).remove(),$("#mqc_col_filters").append(window.make_colorsamples_filter(t,i)),window.mqc_highlight_f_texts.push(t),window.mqc_highlight_f_cols.push(i)}),$("#mqc_colour_filter_color").val(mqc_colours[window.mqc_colours_idx%mqc_colours.length]),$(document).trigger("mqc_highlights",[window.mqc_highlight_f_texts,window.mqc_highlight_f_cols,o.highlight_regex])),notEmptyObj(o.hidesamples_regex)&&1==o.hidesamples_regex&&($("#mqc_hidesamples .mqc_regex_mode input").prop("checked",!0),window.mqc_hide_regex_mode=!0),notEmptyObj(o.hidesamples_mode)&&"show"==o.hidesamples_mode&&($(".mqc_hidesamples_showhide").prop("checked",!1),$(".mqc_hidesamples_showhide[val=show]").prop("checked",!0),window.mqc_hide_mode="show"),notEmptyObj(o.hidesamples_f_texts)&&(window.mqc_hide_f_texts=[],$.each(o.hidesamples_f_texts,function(e,t){if(0===t.length)return!0;$("#mqc_hidesamples_filters").append(window.make_hidesamples_filter(t)),window.mqc_hide_f_texts.push(t)}),$(document).trigger("mqc_hidesamples",[window.mqc_hide_f_texts,o.hidesamples_regex])),$(document).trigger("mqc_config_loaded"),!0}$(function(){callAfterDecompressed.push(function(e){$(".mqc-status-data").each(function(e,t){const o=JSON.parse(t.innerHTML),[i,n,s]=o;As[i]||(As[i]={}),As[i][n]=s}),$(".mqc-status-progress-wrapper").each(function(){const e=$(this),t=e.data("module-key"),o=e.data("section-key");!function(e,t,o){const i=As[t]?.[o];if(!i)return;let n=null;e.find(".mqc-status-progress > .progress").each(function(){const e=this,t=$(this).find(".progress-bar"),o=t.hasClass("bg-success"),s=t.hasClass("bg-warning"),a=t.hasClass("bg-danger");let r="";o&&(r="success"),s&&(r="warning"),a&&(r="danger");let l=[];$.each(i,function(e,t){("pass"===t&&o||"warn"===t&&s||"fail"===t&&a)&&l.push(e)});const c=`\n
                \n

                Click bar to fix in place

                \n
                \n
                \n \n
                \n
                \n \n
                \n
                \n
                \n
                  ${l.sort().map(e=>`
                • ${e}
                • `).join("")}
                `,d=new bootstrap.Popover(e,{title:$(this).attr("aria-label"),content:c,html:!0,sanitize:!1,trigger:"manual",placement:"bottom",customClass:`popover-mqc-status popover-${r}`});$(e).data("popover-pinned",!1),$(e).on("mouseenter",function(){const e=this;n&&(clearTimeout(n),n=null),$(e).data("popover-pinned")||(d.show(),setTimeout(function(){const t=document.querySelector(".popover-mqc-status");t&&$(t).off("mouseenter mouseleave").on("mouseenter",function(){n&&(clearTimeout(n),n=null)}).on("mouseleave",function(){$(e).data("popover-pinned")||(n=setTimeout(function(){$(e).data("popover-pinned")||d.hide()},200))})},50))}),$(e).on("mouseleave",function(){const e=this;$(e).data("popover-pinned")||setTimeout(function(){$(".popover-mqc-status:hover").length||(n=setTimeout(function(){$(e).data("popover-pinned")||d.hide()},200))},50)}),$(e).on("click",function(e){e.stopPropagation(),n&&(clearTimeout(n),n=null),$(this).data("popover-pinned",!0),d.show()})}),$(document).on("click",function(t){$(t.target).closest(".mqc-status-progress .progress, .popover").length||e.find(".mqc-status-progress > .progress").each(function(){const e=bootstrap.Popover.getInstance(this);e&&(e.hide(),$(this).data("popover-pinned",!1))})})}(e,t,o)})})}),$(document).on("click",".mqc-status-highlight",function(e){e.preventDefault();let t=$(this).closest(".popover-body").find("ul li").map(function(){return $(this).text()}).get();$(this).closest(".popover-mqc-status").length&&$(".mqc-status-progress > .progress").each(function(){const e=bootstrap.Popover.getInstance(this);e&&(e.hide(),$(this).data("popover-pinned",!1))});let o=$("#mqc_colour_filter_color").val();for(let i=0;i .progress").each(function(){const e=bootstrap.Popover.getInstance(this);e&&(e.hide(),$(this).data("popover-pinned",!1))}),$("#mqc_hidesamples_filters li").length>0){if(!confirm($("#mqc_hidesamples_filters li").length+" Hide filters already exist - discard?"))return!1;$("#mqc_hidesamples_filters").empty()}$('.mqc_hidesamples_showhide[value="show"]').prop("checked",!0),$("#mqc_hidesamples .mqc_regex_mode input").prop("checked",!1);for(let o=0;o{if(window.mqc_config&&!1===window.mqc_config.template_dark_mode)return;const e=()=>localStorage.getItem("mqc-theme"),t=()=>{const t=e();return t&&"auto"!==t?t:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"},o=e=>{"auto"===e?document.documentElement.setAttribute("data-bs-theme",window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"):document.documentElement.setAttribute("data-bs-theme",e)};o(t());const i=(e,t=!1)=>{const o=document.querySelector("#bd-theme");if(!o)return;const i=document.querySelector("#bd-theme-text"),n=document.querySelector(".theme-icon-active"),s=document.querySelector(`[data-bs-theme-value="${e}"]`);if(!s)return;document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.classList.remove("active"),e.setAttribute("aria-pressed","false");const t=e.querySelector(".check-icon");t&&t.classList.add("d-none")}),s.classList.add("active"),s.setAttribute("aria-pressed","true");const a=s.querySelector(".check-icon");if(a&&a.classList.remove("d-none"),n&&s){const e=s.querySelector(".me-2");e&&(n.innerHTML=e.innerHTML)}const r=`${i.textContent} (${s.dataset.bsThemeValue})`;o.setAttribute("aria-label",r),t&&o.focus()};window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{const i=e();"auto"!==i&&i||o(t())}),window.addEventListener("DOMContentLoaded",()=>{i(t()),document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.addEventListener("click",()=>{const t=e.getAttribute("data-bs-theme-value");(e=>{localStorage.setItem("mqc-theme",e)})(t),o(t),i(t,!0)})})})})(),window.mqc_colours=["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#a9a904","#a65628","#f781bf","#999999"],window.zip_threshold=8,window.AI_PROVIDERS={seqera:{name:"Seqera AI",apiKeysUrl:"https://cloud.seqera.io/tokens"},anthropic:{name:"Anthropic",defaultModel:"claude-3-5-sonnet-latest",suggestedModels:["claude-3-5-sonnet-latest","claude-3-5-haiku-latest"],apiKeysUrl:"https://console.anthropic.com/settings/keys",modelsUrl:"https://docs.anthropic.com/en/docs/intro-to-claude#model-options"},openai:{name:"OpenAI",defaultModel:"gpt-4o",suggestedModels:["gpt-4o","gpt-4o-mini"],apiKeysUrl:"https://platform.openai.com/api-keys",modelsUrl:"https://platform.openai.com/docs/models"},aws_bedrock:{name:"AWS Bedrock",modelsUrl:"https://docs.anthropic.com/en/docs/intro-to-claude#model-options"},custom:{name:"Custom",defaultModel:""},clipboard:{name:"Copy prompts"},none:{name:"Remove AI buttons"}},window.AI_PROVIDER_GROUPS={"In-report summaries":["seqera","anthropic","openai","custom"],Alternatives:["clipboard","none"]},window.AUTO_SAVE_PREFIX="autosave_",window.hashCode=function(e){var t=0;if(0==e.length)return t;for(i=0;i"+e+'

                For more help and testing, try it out at regex101.com.',"error"),!1}},window.mqc_toolbox_confirmapply=function(){$("#mqc_cols_apply").is(":enabled")&&$("#mqc_cols").is(":visible")&&showToast("Highlights Not Applied","Careful - your changes haven't been applied yet! Click the Apply button in the toolbox to set your changes.","warning"),$("#mqc_renamesamples_apply").is(":enabled")&&$("#mqc_renamesamples").is(":visible")&&showToast("Rename Patterns Not Applied","Careful - your changes haven't been applied yet! Click the Apply button in the toolbox to set your changes.","warning"),$("#mqc_hidesamples_apply").is(":enabled")&&$("#mqc_hidesamples").is(":visible")&&showToast("Hide Samples Not Applied","Careful - your changes haven't been applied yet! Click the Apply button in the toolbox to set your changes.","warning")},window.saveToLocalStorage=function(e,t){try{localStorage.setItem(e,t)}catch(o){console.warn("Failed to save to localStorage:",o)}},window.getFromLocalStorage=function(e){try{return localStorage.getItem(e)}catch(t){return console.warn("Failed to read from localStorage:",t),null}},window.notEmptyObj=function(e){return null!=e&&""!==e},window.dataUrlToBlob=function(e,t){const o=atob(e.split(",")[1]),i=new Array(o.length);for(let s=0;s0&&e.indexOf(s)<0?(e.push(s),t.push(n.css("color"))):t[e.indexOf(s)]=n.css("color")}),!(i>0)&&(e.length>0&&e.indexOf("")<0?(e.unshift(""),t.unshift(null),$('.mqc-toolbox-buttons a[href="#mqc_cols"]').addClass("in_use")):$('.mqc-toolbox-buttons a[href="#mqc_cols"]').removeClass("in_use"),window.mqc_highlight_f_texts=e,window.mqc_highlight_f_cols=t,window.mqc_highlight_regex_mode=o,$(document).trigger("mqc_highlights",[e,t,o]),!0)},window.initHighlights=function(){if($("#mqc_color_form").submit(function(e){e.preventDefault();let t=$("#mqc_colour_filter").val().trim(),o=$("#mqc_colour_filter_color").val().trim();$("#mqc_col_filters").append(window.make_colorsamples_filter(t,o)),$("#mqc_cols_apply").attr("disabled",!1).removeClass("btn-default").addClass("btn-primary"),$("#mqc_colour_filter").val(""),$("#mqc_colour_filter_color").val(mqc_colours[window.mqc_colours_idx%mqc_colours.length])}),$("#mqc_cols_apply").click(function(e){apply_mqc_highlights()&&($(this).attr("disabled",!0).removeClass("btn-primary").addClass("btn-default"),mqc_auto_save_config())}),$("#mqc_col_filters").sortable(),$("#mqc_col_filters").on("sortstop",function(e,t){$("#mqc_cols_apply").attr("disabled",!1).removeClass("btn-default").addClass("btn-primary")}),!($("#mqc_col_filters").children().length>0)&&mqc_config.highlight_patterns&&mqc_config.highlight_patterns.length>0){mqc_config.highlight_regex&&$("#mqc_cols .mqc_regex_mode input").prop("checked",!0);for(let e=0;e\n \n \n \n `;return window.mqc_colours_idx+=1,o},window.apply_mqc_renamesamples=function(){let e=[],t=[],o=$("#mqc_renamesamples .mqc_regex_mode input").prop("checked"),i=0;return $("#mqc_renamesamples_filters > li").each(function(){let n=$(this).find(".from_text").val();if($(this).removeClass("bg-danger"),o){if(!validate_regexp(n))return $(this).addClass("bg-danger"),void i++;n=new RegExp(n,"g")}e.push(n);let s=$(this).find(".to_text").val();t.push(s)}),!(i>0)&&(e.length>0?$('.mqc-toolbox-buttons a[href="#mqc_renamesamples"]').addClass("in_use"):$('.mqc-toolbox-buttons a[href="#mqc_renamesamples"]').removeClass("in_use"),window.mqc_rename_f_texts=e,window.mqc_rename_t_texts=t,$(document).trigger("mqc_renamesamples",[window.mqc_rename_f_texts,window.mqc_rename_t_texts]),!0)},window.initRename=function(){if($(".mqc_sname_switches").click(function(e){if(e.preventDefault(),$(this).hasClass("active"))return!1;$("#mqc_sname_switches button").removeClass("active"),$(this).addClass("active"),$("#mqc_renamesamples_filters li").remove();var t=$(this).data("index");if(0==t)apply_mqc_renamesamples();else{for(i=0;i0)&&mqc_config.sample_names_rename&&mqc_config.sample_names_rename.length>0){for(let e=0;e\n \n »\n \n \n `;return window.mqc_renamesamples_idx+=2,o},window.apply_mqc_hidesamples=function(e){void 0===e&&(e="show"===$(".mqc_hidesamples_showhide:checked").val()?"show":"hide");let t=$("#mqc_hidesamples .mqc_regex_mode input").prop("checked"),o=[],i=0;return $("#mqc_hidesamples_filters li").each(function(){let e=$(this).find(".f_text").val();$(this).removeClass("bg-danger"),t&&!validate_regexp(e)&&($(this).addClass("bg-danger"),i++),o.push(e)}),!(i>0)&&(o.length>0?$('.mqc-toolbox-buttons a[href="#mqc_hidesamples"]').addClass("in_use"):$('.mqc-toolbox-buttons a[href="#mqc_hidesamples"]').removeClass("in_use"),window.mqc_hide_mode=e,window.mqc_hide_f_texts=o,window.mqc_hide_regex_mode=t,$(document).trigger("mqc_hidesamples",[o,t]),!0)},window.initHideSamples=function(){if($(".mqc_hide_switches").click(function(e){if(e.preventDefault(),$(this).hasClass("active"))return!1;$("#mqc_hide_switches button").removeClass("active"),$(this).addClass("active"),$("#mqc_hidesamples_filters").empty();var t=$(this).data("index"),o=mqc_config.show_hide_patterns[t],i=mqc_config.show_hide_mode[t],n=mqc_config.show_hide_regex[t];Array.isArray(o)||(o=[o]),void 0===i&&(i="show");var s=document.getElementsByClassName("mqc_switch re_mode")[2];s.className.includes(" on")&&!n&&s.click(),s.className.includes(" off")&&n&&s.click(),$(".mqc_hidesamples_showhide[value="+i+"]").prop("checked",!0),$(o).each(function(e,t){$("#mqc_hidesamples_filters").append(window.make_hidesamples_filter(t))}),apply_mqc_hidesamples(i)}),$("#mqc_hidesamples_form").submit(function(e){e.preventDefault();var t=$("#mqc_hidesamples_filter").val().trim();if(0==t.length)return alert("Error - filter text must not be blank."),!1;$("#mqc_hidesamples_filters").append(window.make_hidesamples_filter(t)),$("#mqc_hidesamples_apply").attr("disabled",!1).removeClass("btn-default").addClass("btn-primary"),$("#mqc_hidesamples_filter").val("")}),$(".mqc_hidesamples_showhide").change(function(e){$("#mqc_hidesamples_apply").attr("disabled",!1).removeClass("btn-default").addClass("btn-primary")}),$("#mqc_hidesamples_apply").click(function(e){apply_mqc_hidesamples()&&($(this).attr("disabled",!0).removeClass("btn-primary").addClass("btn-default"),mqc_auto_save_config())}),!($("#mqc_hidesamples_filters").children().length>0)&&mqc_config.show_hide_patterns&&mqc_config.show_hide_patterns.length>0){for(let e=1;e\n \n \n `;return window.mqc_hidesamples_idx+=2,t},window.initExport=function(){if($('#mqc_exportplots a[data-bs-toggle="tab"]').on("shown.bs.tab",function(e){"#mqc_data_download"===$(e.target).attr("href")?$("#mqc-dl-plot-txt").text("Data"):$("#mqc-dl-plot-txt").text("Images")}),$(".hc-plot").length>0){$(".hc-plot").each(function(){var e=$(this).attr("id");$("#mqc_export_selectplots").append(`
                \n \n \n
                `)}),$("#mqc_export_sall").click(function(e){e.preventDefault(),$("#mqc_export_selectplots input").prop("checked",!0)}),$("#mqc_export_snone").click(function(e){e.preventDefault(),$("#mqc_export_selectplots input").prop("checked",!1)});var e=$("#mqc_exp_width").val()/$("#mqc_exp_height").val();$("#mqc_export_aspratio").change(function(){$(this).is(":checked")&&(e=$("#mqc_exp_width").val()/$("#mqc_exp_height").val())}),$("#mqc_exp_width").keyup(function(){$("#mqc_export_aspratio").is(":checked")&&$("#mqc_exp_height").val($(this).val()/e)}),$("#mqc_exp_height").keyup(function(){$("#mqc_export_aspratio").is(":checked")&&$("#mqc_exp_width").val($(this).val()*e)}),$("#mqc_exportplots").submit(function(e){e.preventDefault();let t=$("#mqc_export_selectplots input:checked"),o=new JSZip,i=[];if($("#mqc_image_download").is(":visible")){let e=$("#mqc_export_ft").val(),n=e.replace("image/","").split("+")[0],s=parseInt($("#mqc_exp_width").val()),a=parseInt($("#mqc_exp_height").val());const r=parseFloat($("#mqc_export_scaling").val());t.each(function(){const l=$(this).val();i.push(Plotly.toImage(l,{format:n,width:s/r,height:a/r,scale:r}).then(function(i){"svg"===n?Plotly.Snapshot.downloadImage(l,{format:n,width:s/r,height:a/r,scale:r,filename:l}):addLogo(i,function(i){if(t.length<=zip_threshold){const t=dataUrlToBlob(i,e);saveAs(t,l+"."+n)}else{const e=l+"."+n,t=i.replace(/^data:image\/png;base64,/,"");o.file(e,t,{base64:!0})}})}))}),t.length>zip_threshold&&Promise.all(i).then(()=>{o.generateAsync({type:"blob"}).then(function(e){saveAs(e,"multiqc_plots.zip")})})}else if($("#mqc_data_download").is(":visible")){const e=$("#mqc_export_data_ft").val();console.log("Exporting data in "+e+" format");let i=0;t.each(function(){try{const n=$(this).val(),s=n+"."+e;if("json"===e){const e=JSON.stringify(mqc_plots[n],null,2),i=new Blob([e],{type:"text/plain;charset=utf-8"});t.length<=zip_threshold?saveAs(i,s):o.file(s,i)}else if("tsv"===e||"csv"===e){let a=mqc_plots[n];if(void 0!==a){let i=a.exportData(e);const n=new Blob([i],{type:"text/plain;charset=utf-8"});t.length<=zip_threshold?saveAs(n,s):o.file(s,n)}else i+=1}else i+=1}catch(n){console.error(n),i+=1}}),i>0&&alert("Warning: Could not export data from "+i+" plots."),t.length>zip_threshold&&o.generateAsync({type:"blob"}).then(function(e){saveAs(e,"multiqc_data.zip")})}else alert("Error - don't know what to export!")})}else $("#mqc_exportplots").hide(),$(".mqc-toolbox-buttons a[href=#mqc_exportplots]").parent().hide();$(".export-plot").click(function(e){e.preventDefault();let t=e.target.dataset.plotAnchor,o="table"===e.target.dataset.type;$("#mqc_export_selectplots input").prop("checked",!1),$('#mqc_export_selectplots input[value="'+t+'"]').prop("checked",!0),"table_scatter_plot"===t&&$("#table_scatter_modal").modal("hide"),mqc_toolbox_openclose("#mqc_exportplots",!0,o)})},window.getStoredSampleAnonymizationEnabled=Os,window.initAI=function(){const e=$("#ai-provider");e.empty(),Object.entries(AI_PROVIDER_GROUPS).forEach(([t,o])=>{let i=$("",{label:t});o.forEach(e=>{i.append($("").val(t);!1!==default_name?$('#mqc_loadconfig_form select option:contains("'+default_name+'")').prop("selected",!0):$("#mqc_loadconfig_form select option:first").prop("selected",!0)}}catch(o){console.log("Could not load local config: "+o),$("#mqc_saveconfig").html('

                Error accessing localStorage

                This feature uses a web browser feature called "localStorage". We\'re not able to access this at the moment, which probably means that you have the "Block third-party cookies and site data" setting ticked (Chrome) or equivalent in other browsers.

                Please change this browser setting to save MultiQC report configs.

                ')}}(),$("#mqc_saveconfig_form").submit(function(e){e.preventDefault();var t=$(this).find("input").val().trim();""==t?alert("Error - you must name the saved settings."):Ds(t)}),$("#mqc_loadconfig_form").submit(function(e){e.preventDefault();var t=$(this).find("select").val().trim();""==t?alert("Error - No saved setting selected."):Is(t)?showToast("Configuration Loaded","Settings have been loaded successfully","success"):showToast("Error Loading Configuration","Could not load the configuration","error")}),$(".mqc_config_clear").click(function(e){e.preventDefault();var t=$("#mqc_loadconfig_form select").val().trim();""==t?alert("Error - no saved settings selected."):confirm("Delete saved settings '"+t+"'?")&&Ds(t,!0)}),$(".mqc_config_set_default").click(function(e){e.preventDefault();var t=$("#mqc_loadconfig_form select").val().trim();""==t?alert("Error - no saved settings selected."):(Is(t),Ds(t,!1,!0))}),$(".mqc_config_clear_default").click(function(e){e.preventDefault(),function(){try{var t=localStorage.getItem("mqc_config");if(!t)return;for(var o in t=JSON.parse(t))t.hasOwnProperty(o)&&(t[o].default=!1);localStorage.setItem("mqc_config",JSON.stringify(t)),$('

                Unset default.

                ').hide().insertBefore($("#mqc_loadconfig_form .actions")).slideDown(function(){setTimeout(function(){$("#mqc-cleared-success").slideUp(function(){$(this).remove()})},5e3);var e=$('#mqc_loadconfig_form select option:contains("default")').text();$('#mqc_loadconfig_form select option:contains("default")').remove(),e=e.replace(" [default]",""),$("#mqc_loadconfig_form select").append(``).val(e)})}catch(e){console.log("Could not access localStorage")}}()});let e=!1;$("#mqc_saveconfig").on("mouseenter",function(){e||($("#mqc_load_config_file_wrapper").append(''),$("#mqc_load_config_file").on("change",function(e){this.files&&this.files[0]&&function(e){const t=new FileReader;t.onload=function(e){try{const t=JSON.parse(e.target.result);if(t.report_id&&t.report_id!==window.reportUuid&&!confirm("This configuration is from a different report. Load it anyway?"))return;Is("custom_file_load",t),mqc_auto_save_config(),showToast("Configuration Loaded","Settings have been loaded from file successfully","success")}catch(t){showToast("Error Loading Configuration","Could not parse the configuration file: "+t.message,"error")}},t.readAsText(e)}(this.files[0])}),e=!0)}),$("#mqc_download_config").click(function(e){e.preventDefault();!function(e){const t=getConfigObject(),o=new Blob([JSON.stringify(t,null,2)],{type:"application/json"});saveAs(o,`multiqc_config_${e}.json`)}($("#mqc_saveconfig_form input").val().trim()||"settings")});try{const e=AUTO_SAVE_PREFIX+window.reportUuid,t=localStorage.getItem("mqc_config");if(t){JSON.parse(t)[e]&&Is(e)}}catch(t){console.log("Could not load auto-saved config:",t)}},window.initCitations=function(){var e={"10.1093/bioinformatics/btw354":"MultiQC"};for(var t in $(".module-doi").each(function(){var t=$(this).closest(".mqc-module-section-first").find("h2").attr("id");e[$(this).data("doi")]=t}),e)$("#mqc_citations_list").append(`\n \n ${e[t]}\n ${t}\n \n `);$(".download-citations-btn").click(function(t){if(t.preventDefault(),"bibtex"==$(this).data("format")){var o="",i=[];for(var n in e)i.push($.get("https://api.crossref.org/works/"+n+"/transform/application/x-bibtex",function(e){o+=e+"\n"}));$.when.apply(null,i).then(function(){var e=new Blob([o],{type:"text/plain;charset=utf-8"});saveAs(e,"multiqc_references.bib")})}else{var s="";for(var n in e)s+=n+new Array(50-n.length).join(" ")+" # "+e[n]+"\n";var a=new Blob([s],{type:"text/plain;charset=utf-8"});saveAs(a,"multiqc_dois.txt")}})},window.initHelp=function(){function e(){var e=$(".regex_example_demo input").val();console.log("Testing "+e),$(".regex_example_demo pre span").each(function(){$(this).removeClass(),$(this).find("hl").contents().unwrap();var t=$(this).text(),o=t.match(e);if(o){console.log("Matches "+t);var i=t.indexOf(o[0]),n=t.substring(0,i),s=o[0],a=t.substring(i+s.length);$(this).html($("").text(n).addClass("text-muted").prop("outerHTML")+$("").text(s).addClass("mark text-success").prop("outerHTML")+$("").text(a).addClass("text-muted").prop("outerHTML"))}else console.log("Matches "+t),$(this).addClass("text-muted")})}$(".regex_example_buttons button").click(function(t){t.preventDefault(),$(".regex_example_demo input").val($(this).data("example")),e()}),$(".regex_example_demo input").keyup(function(t){e()})},window.mqc_toolbox_openclose=function(e,t=!1){const o=document.getElementById("mqc-toolbox"),i=bootstrap.Offcanvas.getOrCreateInstance(o),n=o.classList.contains("show");if(t&&!n)i.show();else{if(!t&&n)return void i.hide();t||n||i.show()}e&&setTimeout(()=>{const t=$(`.mqc-toolbox-buttons a[href="${e}"]`)[0];if(t){new bootstrap.Tab(t).show()}},100)},$(function(){const e=document.getElementById("mqc-toolbox"),t=new bootstrap.Offcanvas("#mqc-toolbox");$(".mqc-toolbox-buttons a").click(function(o){e.classList.contains("show")||t.show();new bootstrap.Tab(this).show()}),$(".mobile-nav-toolbox-btns a[href^='#mqc_']").click(function(o){o.preventDefault();const i=$(this).attr("href");e.classList.contains("show")||t.show();const n=$(`.mqc-toolbox-buttons a[href="${i}"]`)[0];new bootstrap.Tab(n).show();const s=document.getElementById("mqc-nav-collapse");if(s&&s.classList.contains("show")){new bootstrap.Collapse(s).hide()}}),e.addEventListener("hidden.bs.offcanvas",e=>{mqc_toolbox_confirmapply(),$(".mqc-toolbox-buttons .list-group-item").removeClass("active"),$("#mqc-toolbox > .offcanvas-body > .tab-content > .tab-pane").removeClass("active show")}),$(".modal").on("show.bs.modal",function(o){e.classList.contains("show")&&t.hide()}),$(document).on("mqc_config_loaded",function(e){$(".hc-plot:not(.not_rendered)").each(function(){let e=$(this).attr("id");renderPlot(e)})}),initHighlights(),initRename(),initHideSamples(),initSaveLoad(),initExport(),initAI(),initFilters(),initCitations(),initHelp(),initAICookies()});let Ps=class extends Plot{constructor(e){super(e),this.filteredSettings=[]}activeDatasetSize(){if(0===this.datasets.length)return 0;let e=this.datasets[this.activeDatasetIdx].cats;return 0===e.length?0:e[0].data.length}prepData(e){let t=(e=e??this.datasets[this.activeDatasetIdx]).cats,o=e.samples,i=applyToolboxSettings(o);return this.filteredSettings=i.filter(e=>!e.hidden),t=t.map(e=>({data:(this.pActive?e.data_pct:e.data).filter((e,t)=>!i[t].hidden),color:e.color,name:e.name})),[t]}plotAiHeader(){let e=super.plotAiHeader();return this.pconfig.ylab&&(e+=`Values: ${this.pconfig.ylab}\n`),e}formatDatasetForAiPrompt(e){let t="",o=e.cats,i=e.samples,n=applyToolboxSettings(i);if(n.every(e=>e.hidden))return t+="All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n",t;t+="|Sample|"+o.map(e=>e.name).join("|")+"|\n",t+="|---|"+o.map(()=>"---").join("|")+"|\n";let s="";return this.pActive?(s+="%",this.layout.xaxis.ticksuffix&&"%"!==this.layout.xaxis.ticksuffix&&(s+=" "+this.layout.xaxis.ticksuffix)):this.layout.xaxis.ticksuffix&&(s+=" "+this.layout.xaxis.ticksuffix),n.forEach((e,i)=>{e.hidden||(t+=`|${e.pseudonym??e.name}|`+o.map(e=>{let t=this.pActive?e.data_pct[i]:e.data[i];return t=Number.isFinite(t)?Number.isInteger(t)?t:parseFloat(t.toFixed(2)):"",""!==t&&s&&(t+=s),t}).join("|")+"|\n")}),t}resize(e){this.layout.height=e;const t=(this.layout.height-140)/12;this.recalculateTicks(this.filteredSettings,this.layout.yaxis,t),super.resize(e)}buildTraces(){let[e]=this.prepData();if(0===e.length||0===this.filteredSettings.length)return[];const t=(this.layout.height-140)/12;this.recalculateTicks(this.filteredSettings,this.layout.yaxis,t);let o=this.filteredSettings.filter(e=>e.highlight),i=this.firstHighlightedSample(this.filteredSettings),n=this.datasets[this.activeDatasetIdx].trace_params;return e.map(e=>{if("group"!==this.layout.barmode)return this.filteredSettings.map((t,s)=>{let a=JSON.parse(JSON.stringify(n)),r=o.length>0&&null===t.highlight?.1:1;return a.marker.color="rgba("+e.color+","+r+")",{type:"bar",x:[e.data[s]],y:[t.name],name:e.name,meta:e.name,showlegend:s===i,legendgroup:e.name,...a}});{let t=JSON.parse(JSON.stringify(n)),o=this.filteredSettings.map(e=>e.name);return t.marker.color="rgb("+e.color+")",{type:"bar",x:e.data,y:o,name:e.name,meta:e.name,...t}}})}exportData(e){let[t]=this.prepData(),o="tsv"===e?"\t":",",i="Sample"+o+t.map(e=>e.name).join(o)+"\n";for(let n=0;ne.data[n]).join(o)+"\n";return i}};window.BarPlot=Ps;let Ns=class extends Plot{constructor(e){super(e),this.filteredSettings=[]}activeDatasetSize(){return 0===this.datasets.length?0:this.datasets[this.activeDatasetIdx].samples.length}prepData(e){let t=(e=e??this.datasets[this.activeDatasetIdx]).data,o=e.samples,i=applyToolboxSettings(o);return this.filteredSettings=i.filter(e=>!e.hidden),o=this.filteredSettings.map(e=>e.name),t=t.filter((e,t)=>!i[t].hidden),[t,o]}formatDatasetForAiPrompt(e){let t="",[o,i]=this.prepData(e);if(0===i.length)return"All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n";t+="|Sample|Min|Q1|Median|Q3|Max|Mean|\n",t+="|---|---|---|---|---|---|---|\n";const n=this.layout.xaxis.ticksuffix?" "+this.layout.xaxis.ticksuffix:"";return i.forEach((e,i)=>{const s=o[i].filter(e=>Number.isFinite(e)).sort((e,t)=>e-t);if(0===s.length)return;let a=s.length,r=s[0],l=s[a-1],c=a%2==1?s[Math.floor(a/2)]:(s[a/2-1]+s[a/2])/2,d=a>=4?s[Math.floor(a/4)]:s[0],h=a>=4?s[Math.floor(3*a/4)]:s[a-1],m=s.reduce((e,t)=>e+t,0)/a,p=e=>{if(!Number.isFinite(e))return"";return(Number.isInteger(e)?e:parseFloat(e.toFixed(2)))+n};t+=`|${anonymizeSampleName(e)}|${p(r)}|${p(d)}|${p(c)}|${p(h)}|${p(l)}|${p(m)}|\n`}),t}resize(e){this.layout.height=e;const t=(this.layout.height-140)/12;this.recalculateTicks(this.filteredSettings,this.layout.yaxis,t),super.resize(e)}buildTraces(){let[e,t]=this.prepData();if(0===e.length||0===t.length)return[];const o=(this.layout.height-140)/12;this.recalculateTicks(this.filteredSettings,this.layout.yaxis,o);let i=this.filteredSettings.filter(e=>e.highlight),n=this.datasets[this.activeDatasetIdx].trace_params;return this.filteredSettings.map((t,o)=>{let s=JSON.parse(JSON.stringify(n));return void 0!==mqc_config.boxplot_boxpoints&&(s.boxpoints=mqc_config.boxplot_boxpoints),i.length>0&&(null!==t.highlight?s.marker.color=t.highlight:s.marker.color="grey"),{type:"box",x:e[o],name:t.name,...s}})}exportData(e){let[t,o]=this.prepData(),i="tsv"===e?"\t":",",n="";for(let s=0;se.name),i=applyToolboxSettings(o);return this.filtSampleSettings=i.filter(e=>!e.hidden),t=t.filter((e,t)=>!i[t].hidden),t=t.map((e,t)=>(e.highlight=i[t].highlight,e.pseudonym=i[t].pseudonym,e)),[o,t]}plotAiHeader(){let e=super.plotAiHeader();return this.pconfig.xlab&&(e+=`X axis: ${this.pconfig.xlab}\n`),this.pconfig.ylab&&(e+=`Y axis: ${this.pconfig.ylab}\n`),e}formatDatasetForAiPrompt(e){let[t,o]=this.prepData(e);if(0===t.length)return"All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n";const i=this.layout.xaxis.ticksuffix||"",n=this.layout.yaxis.ticksuffix||"";let s="Samples: "+t.join(", ")+"\n\n";n&&(s+=`Y values are in ${n}\n\n`),i&&(s+=`X values are in ${i}\n\n`);return s+"\n\n"+o.map(e=>({name:e.pseudonym??e.name,pairs:e.pairs.map(e=>e.map((e,t)=>Number.isFinite(e)?Number.isInteger(e)?e:parseFloat(e.toFixed(2)):""))})).map(e=>e.name+" "+e.pairs.map(e=>e.join(": ")).join(", ")).join("\n\n")}buildTraces(){let e=this.datasets[this.activeDatasetIdx],[t,o]=this.prepData();if(0===o.length||0===t.length)return[];let i=o.filter(e=>e.highlight),n=o.filter(e=>!e.highlight);return o=n.concat(i),o.map(t=>{let o=t.color;i.length>0&&(o=t.highlight??"#cccccc");let n={line:{color:o,dash:t.dash,width:t.width},marker:{color:o},showlegend:t.showlegend??null,mode:t.mode??null},s=t.marker??null;return s&&(n.mode="lines+markers",n.marker={symbol:s.symbol,color:s.fill_color??s.color??o,line:{width:s.width,color:s.line_color??s.color??o}}),updateObject(n,e.trace_params,!0),{type:"scatter",x:t.pairs.map(e=>e[0]),y:t.pairs.map(e=>e[1]),name:t.name,text:t.pairs.map(()=>t.name),...n}})}exportData(e){let[t,o]=this.prepData(),i=!0,n=null;o.forEach(e=>{let t;t=e.pairs.map(e=>e[0]),null===n?n=t:(n.length!==t.length||n.some((e,o)=>e!==t[o]))&&(i=!1)});let s="tsv"===e?"\t":",",a="";return i?(a+="Sample"+s+n.join(s)+"\n",o.forEach(e=>{a+=e.name+s+e.pairs.map(e=>e[1]).join(s)+"\n"})):o.forEach(e=>{a+=e.name+s+"X"+s+e.pairs.map(e=>e[0]).join(s)+"\n",a+=e.name+s+"Y"+s+e.pairs.map(e=>e[1]).join(s)+"\n"}),a}};window.LinePlot=Ls;let js=class extends Plot{activeDatasetSize(){return 0===this.datasets.length?0:this.datasets[this.activeDatasetIdx].points}prepData(e){let t=(e=e??this.datasets[this.activeDatasetIdx]).points,o=t.map(e=>e.name),i=applyToolboxSettings(o);return t=t.map((e,t)=>{if(e.pseudonym=i[t].pseudonym,e.name=i[t].name??e.name,e.highlight=i[t].highlight,!i[t].hidden)return e}),[o,t]}plotAiHeader(){let e=super.plotAiHeader();return this.pconfig.xlab&&(e+=`X axis: ${this.pconfig.xlab}\n`),this.pconfig.ylab&&(e+=`Y axis: ${this.pconfig.ylab}\n`),this.pconfig.categories&&(e+=`X categories: ${this.pconfig.categories.join(", ")}\n`),e}formatDatasetForAiPrompt(e){let[t,o]=this.prepData(e,!0);if(0===t.length)return"All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n";const i=this.layout.xaxis.ticksuffix,n=this.layout.yaxis.ticksuffix;return o=o.map(e=>({name:e.name,x:Number.isFinite(e.x)?(Number.isInteger(e.x)?e.x:parseFloat(e.x.toFixed(2)))+(i??""):"",y:Number.isFinite(e.y)?(Number.isInteger(e.y)?e.y:parseFloat(e.y.toFixed(2)))+(n??""):""})),o.map(e=>`${e.name} (${e.x}, ${e.y})`).join("\n")}buildTraces(){let e=this.datasets[this.activeDatasetIdx],[t,o]=this.prepData();if(0===o.length||0===t.length)return[];let i=o.filter(e=>e.highlight),n=o.filter(e=>!e.highlight);return o=n.concat(i),o.map(t=>{let o=JSON.parse(JSON.stringify(e.trace_params));return o.marker.size=t.marker_size??o.marker.size,o.marker.line={width:t.marker_line_width??o.marker.line.width},o.marker.opacity=t.opacity??o.marker.opacity,o.marker.color=t.color??o.marker.color,o.marker.symbol=t.marker_symbol??o.marker.symbol,i.length>0&&(o.marker.color=t.highlight??"#cccccc"),{type:"scatter",x:[t.x],y:[t.y],name:t.name,text:[t.annotation??t.name],...o}})}exportData(e){let[t,o]=this.prepData(),i="tsv"===e?"\t":",",n=["Name","X","Y"].join(i)+"\n";for(let s=0;st.filter((t,o)=>!e[o].hidden)),this.filtXCatsSettings=e.filter(e=>!e.hidden),o=this.filtXCatsSettings.map(e=>e.name)}if(this.yCatsAreSamples){let e=applyToolboxSettings(i);if(null===e)return;t=t.filter((t,o)=>!e[o].hidden),this.filtYCatsSettings=e.filter(e=>!e.hidden),i=this.filtYCatsSettings.map(e=>e.name)}return[t,o,i]}plotAiHeader(){let e=super.plotAiHeader();return this.pconfig.xlab&&(e+=`X axis: ${this.pconfig.xlab}\n`),this.pconfig.ylab&&(e+=`Y axis: ${this.pconfig.ylab}\n`),this.pconfig.zlab&&(e+=`Z axis: ${this.pconfig.zlab}\n`),e}formatDatasetForAiPrompt(e){let t="",[o,i,n]=this.prepData(e);if(0===i.length||0===n.length)return t+="All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n",t;if(i){if(n&&(t="|",this.yCatsAreSamples&&(t+="Sample")),this.xCatsAreSamples){t+="|"+this.filtXCatsSettings.map(e=>e.pseudonym??e.name).join("|")+"|\n"}else t+="|"+i.join("|")+"|\n";n&&(t+="|---"),t+="|"+i.map(()=>"---").join("|")+"|\n"}for(let s=0;se.pseudonym??e.name)[s]}else t+="|"+n[s];t+="|"+o[s].map(e=>Number.isFinite(e)?Number.isInteger(e)?e:parseFloat(e.toFixed(2)):"").join("|")+"|\n"}return t}buildTraces(){let[e,t,o]=this.prepData();if(0===e.length||0===t.length||0===o.length)return[];if(this.filtYCatsSettings.length>0){const e=(this.layout.height-200)/12;this.recalculateTicks(this.filtYCatsSettings,this.layout.yaxis,e)}if(this.filtXCatsSettings.length>0){const e=(this.layout.width-250)/18;this.recalculateTicks(this.filtXCatsSettings,this.layout.xaxis,e)}let i=this.datasets[this.activeDatasetIdx];return[{type:"heatmap",z:e,x:t,y:o,...JSON.parse(JSON.stringify(i.trace_params))}]}exportData(e){let[t,o,i]=this.prepData(),n="tsv"===e?"\t":",",s=[".",...o].join(n)+"\n";for(let a=0;a{let o=i[e],n=$(`#${this.tableAnchor}_config_modal_table .mqc_table_col_visible[value="${e}"]`),s=n.length>0&&!n.is(":checked");return(!0!==o.hidden||t)&&!s});let n=e.violin_value_by_sample_by_metric,s={};o.forEach(t=>{let o=i[t],a={};o.show_points&&(a=o.show_only_outliers?e.scatter_value_by_sample_by_metric[t]:n[t]),s[t]=a});let a=e.all_samples,r=applyToolboxSettings(a);if(r.filter(e=>e.hidden).length>0){let e={},t={};o.map(t=>{e[t]={},Object.keys(n[t]).map(o=>{r[a.indexOf(o)].hidden||(e[t][o]=n[t][o])})}),n=e,o.forEach(e=>{let o,s=i[e];o=s.show_points&&s.show_only_outliers?t[e]:n[e],t[e]={},Object.keys(o).map(i=>{r[a.indexOf(i)].hidden||(t[e][i]=o[i])})})}return[o,i,a,r,n,s]}plotAiHeader(e){let t="";return t+="table"===e?"Plot type: table\n":"Plot type: violin plot\n",t}formatDatasetForAiPrompt(e){let[t,o,i,n,s,a]=this.prepData(e,!0),r="Number of samples: "+i.length+"\n";return this.isDownsampled&&(r+="Note: sample number "+i.length+" is greater than the threshold so data points were downsampled to fit the context window. However, outliers for each metric were identified and kept in the datasets.\n"),r+="\n",0===t.length?(r+='All columns are hidden by user, so no data to analyse. Please inform user to use the "Configure columns" button to make some columns visible.\n',r):n.every(e=>e.hidden)?(r+="All samples are hidden by user, so no data to analyse. Please inform user to use the toolbox to unhide samples.\n",r):(r+="Metrics:\n",r+=t.map(e=>`${o[e].title} - ${o[e].description}`).join("\n"),r+="\n\n",r+=`|${this.pconfig.col1_header}|`+t.map(e=>o[e].title).join("|")+"|\n",r+="|---|"+t.map(()=>"---").join("|")+"|\n",r+=n.map(e=>e.hidden||t.every(t=>void 0===s[t][e.originalName]&&void 0===a[t][e.originalName])?"":`|${e.pseudonym??e.name}|`+t.map(t=>{const i=s[t][e.originalName]??a[t][e.originalName],n=o[t].suffix;return null==i?"":"string"==typeof i?i+(n??""):Number.isFinite(i)?Number.isInteger(i)?i:i.toFixed(2):""}).join("|")+"|\n").join(""),r)}buildTraces(){let e=this.datasets[this.activeDatasetIdx],[t,o,i,n,s,a]=this.prepData(),r=!0;t.forEach(e=>{let t=o[e];t.show_points&&t.show_only_outliers||(r=!1)}),r&&$("#table-violin-info-"+this.anchor).append(" For efficiency, separate points are shown only for outliers.");let l=this.layout;if(l.height=this.violinHeight*t.length+this.extraHeight,$("#"+this.anchor+"-wrapper").css("height",l.height+"px"),0===t.length)return[];l.grid.rows=t.length,l.grid.subplots=t.map((e,t)=>{let o=0===t?"":t+1;return["x"+o+"y"+o]}),t.map((e,t)=>{let i=o[e];l["yaxis"+(t+1)]={automargin:l.yaxis.automargin,color:l.yaxis.color,gridcolor:l.yaxis.gridcolor,zerolinecolor:l.yaxis.zerolinecolor,hoverformat:l.yaxis.hoverformat,tickfont:{size:l.yaxis.tickfont.size,color:l.yaxis.tickfont.color}},l["xaxis"+(t+1)]={automargin:l.xaxis.automargin,color:l.xaxis.color,gridcolor:l.xaxis.gridcolor,zerolinecolor:l.xaxis.zerolinecolor,hoverformat:l.xaxis.hoverformat,tickfont:{size:l.xaxis.tickfont.size,color:l.xaxis.tickfont.color}},void 0!==i.xaxis&&null!==i.xaxis&&(l["xaxis"+(t+1)]=Object.assign(l["xaxis"+(t+1)],i.xaxis));let n=i.title+" ";i.namespace&&(n=i.namespace+"
                "+n),l["yaxis"+(t+1)].tickmode="array",l["yaxis"+(t+1)].tickvals=[t],l["yaxis"+(t+1)].ticktext=[n],void 0!==i.hoverformat&&null!==i.hoverformat&&(l["xaxis"+(t+1)].hoverformat=i.hoverformat)}),l.xaxis=l.xaxis1,l.yaxis=l.yaxis1;let c=[];t.map((t,i)=>{let n=o[t],a=JSON.parse(JSON.stringify(e.trace_params));const r=e=>{if(!e)return null;if(/^\d+,\s*\d+,\s*\d+$/.test(e))return e;if(e.startsWith("#")){const t=e.replace("#","");if(6===t.length){return`${parseInt(t.substr(0,2),16)},${parseInt(t.substr(2,2),16)},${parseInt(t.substr(4,2),16)}`}}return null};let l=r(n.color)||r(a.fillcolor);const d="dark"===document.documentElement.getAttribute("data-bs-theme")?.3:.5;l&&(a.fillcolor=`rgba(${l},${d})`);let h=s[t],m=[],p=[];Object.entries(h).map(([e,t])=>{m.push(e),p.push(t)});let u=0===i?"":i+1;c.push({type:"violin",x:p,name:i,text:m,xaxis:"x"+u,yaxis:"y"+u,...a})});let d=c.length,h={},m={},p=[];t.map((e,t)=>{let o=0===t?"":t+1,i=a[e],n=[];Object.entries(i).map(([e,t])=>{n.push([e,t]),h[e]||(h[e]=[],m[e]=[]),h[e].push(d++),m[e].push("x"+o+"y"+o)}),p.push(n)});let u=n.filter(e=>e.highlight).length>0,f=42.1231;function _(){let e=1e4*Math.sin(f++);return e-Math.round(e)}let g=[];return p.map((t,o)=>{let s=0===o?"":o+1;t.map(([t,a])=>{let r=n[i.indexOf(t)],l=JSON.parse(JSON.stringify(e.scatter_trace_params)),c=l.marker.color,d=l.marker.size;const p="dark"===document.documentElement.getAttribute("data-bs-theme");p&&!u&&("#000000"===c?c="#ffffff":"#0b79e6"===c&&(c="#5dade2")),u&&(c=r.highlight??"#ddd",d=null!==r.highlight?8:d);let f={curveNumbers:h[t],curveAxis:m[t]};g.push({type:"scatter",x:[a],y:[o+.3*_()],text:[r.name??t],xaxis:"x"+s,yaxis:"y"+s,customdata:f,...l,marker:{color:c,size:d},hoverlabel:{bgcolor:p?"rgba(40,40,40,1)":"rgba(255,255,255,1)",bordercolor:"rgba(100,100,100,1)",font:{color:p?"rgba(220,220,220,1)":"rgba(30,30,30,1)"}}})})}),c=c.concat(g),c}exportData(e){let[t,o,i,n,s,a]=this.prepData(),r="tsv"===e?"\t":",",l=t.map(e=>o[e].title);l=l.map(e=>e.includes(r)?`"${e}"`:e);let c="Sample"+r+l.join(r)+"\n";for(let d=0;d{let o=s[t][e];return void 0===o&&(o="."),o}).join(r),c+="\n")}return c}afterPlotCreated(){let e=this.anchor;document.getElementById(e).on("plotly_hover",function(t){if(!t.points)return;let o=t.points[0];if("scatter"===o.data.type){let t=o.data.customdata.curveNumbers,i=o.data.customdata.curveAxis,n=t.map(e=>({curveNumber:e,pointNumber:0}));Plotly.Fx.hover(e,n,i)}})}};function zs(e){return e.toString().includes("Failed to fetch")&&(e="Failed to connect to AI provider. Please check your internet connection and the API key, and try again."),e}function Rs(e,t,o,i,n,s){const a=new TextDecoder;let r,l,c="",d=!1;return function h(){return t.read().then(({value:t,done:m})=>{if(m)return void s(l);c+=a.decode(t,{stream:!0});const p=c.split("\n");return c=p.reduce((t,a)=>{if(!(a=a.trim()))return t;if(!a.startsWith("data: "))return a.includes('"type":"invalid_request_error"')?(n(a),t):t;let c,h,m,p,u,f=a.slice(6);if("[DONE]"===f)return s(),t;try{c=JSON.parse(f)}catch(_){return t}switch("seqera"===e?(h=c.type,m=c.content,r=c.metadata?.ls_model_name,l=l||c.thread_id,u=c.response_metadata?.stop_reason,u&&"end_turn"!==u&&(h="error",error=`Streaming unexpectedly stopped. Reason: ${u}`)):"openai"===e||"custom"===e?(m=c.choices[0].delta.content,r=c.model,p=c.choices[0].delta.role,u=c.choices[0].finish_reason,m?h="on_chat_model_stream":"stop"===u?h="on_chat_model_end":"assistant"===p?h="on_chat_model_start":u&&"stop"!==u?(h="error",error=`Streaming unexpectedly stopped. Reason: ${u}`):h="unknown"):"anthropic"===e&&(h=c.type,"content_block_delta"===h&&"text_delta"===c.delta.type?(m=c.delta.text,h="on_chat_model_stream"):"message_start"===h?(r=c.message.model,p=c.message.role,h="on_chat_model_start"):c.finish_reason?"end_turn"===c.finish_reason?h="on_chat_model_end":(h="error",error=`Streaming unexpectedly stopped. Reason: ${c.finish_reason}`,c.error&&(error+=`. Error: ${c.error}`)):c.error&&(h="error",error=`Streaming unexpectedly stopped. Error: ${c.error}`)),h){case"on_chat_model_start":d=!0,o(r);break;case"on_chat_model_stream":d||(d=!0,o(r)),m&&i(m);break;case"on_chat_model_end":return s(l),t;case"error":n(error)}return t},""),h()}).catch(e=>n(e))}()}function Hs(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function Bs(e,t=!1,o="table"){let i="",n=e??0;const s=$("#ai-provider").val(),a=window.AI_PROVIDERS[s],r=Us($("#ai-model").val());i+="\n----------------------\n\n",i+="Tools used in the report:\n\n",Object.entries(aiReportMetadata.tools).forEach(([e,t],o)=>{const n=`${o+1}. ${t.name}`+(t.info?`\nDescription: ${t.info}`:"")+(t.href&&t.href.length>0?`\nLinks: ${t.href}`:"")+(t.comment?`\nComment: ${t.comment}`:"")+"\n\n";i+=n}),i+="\n----------------------\n",n+=Ys(i);const l=mqc_plots.general_stats_table;if(l){const e=`\nMultiQC General Statistics (overview of key QC metrics for each sample, across all tools)\n${l.formatForAiPrompt(o)}\n----------------------\n`;i+=e;const t=Ys(e);if(!(n+t<=r))return console.error(`General stats alone would already exceed the token limit of ${a.name} (${n+t} > ${r}). Cannot summarize the report`),i;n+=t}let c="";if(!t)for(const[d,h]of Object.entries(aiReportMetadata.sections)){c+=`\nTool: ${aiReportMetadata.tools[h.module_anchor].name}\n`,c+=Ws(d),c+="\n\n----------------------";const e=Ys(c);if(n+e>r)return console.warn(`Truncating prompt to only the general stats to fit within the context window of ${a.name} (${r} tokens). Tokens estimate: ${n}, with sections: at least ${n+e}`),i}return i+=c,i}function Ws(e,t,o){let i=function(e,t){if("general_stats_table"===e)return"";let o="";t&&(o+=function(e){let t=`Tool that produced data: ${e.name}`;return e.info&&(t+=`\nTool description: ${e.info}`),e.href&&e.href.length>0&&(t+=`\nTool URL: ${e.href}`),e.comment&&(t+=`\nTool comment: ${e.comment}`),t}(aiReportMetadata.tools[t])+"\n\n");return o+=function(e){let t=`Section: ${e.name}`;return e.description&&(t+=`\nSection description: ${e.description}`),e.comment&&(t+=`\nSection comment: ${e.comment}`),e.helptext&&(t+=`\nSection help text: ${e.helptext}`),t}(aiReportMetadata.sections[e]),o}(e,t);i&&(i+="\n");const n=aiReportMetadata.sections[e];n.content_before_plot&&(i+=n.content_before_plot+"\n\n"),n.content&&(i+=n.content+"\n\n");const s=n.plot_anchor;let a=mqc_plots[s];return a?(a.pconfig&&a.pconfig.title&&(i+=`Title: ${a.pconfig.title}\n`),i+="\n"+a.formatForAiPrompt(o),i):i}function Us(e){let t=$("#ai-context-window").val();if(t)try{return parseInt(t)}catch(o){console.error("Error parsing custom context window",o)}return e.startsWith("claude")?2e5:128e3}async function Vs(e){e.preventDefault();const t=$(e.currentTarget),o=t.hasClass("ai-generate-button-more"),i=t.data("action"),n=$("#"+t.data("response-div")),s=$("#"+t.data("detailed-analysis-div")),a=$("#"+t.data("error-div")),r=$("#"+t.data("wrapper-div")),l=t.data("original-html"),c=t.data("plot-anchor")||"global",d=$("#"+t.data("continue-in-chat-button"));"clear"===i?(e.preventDefault(),localStorage.removeItem(`ai_response_${reportUuid}_${c}${o?"_more":""}`),n.html("").hide(),s.html("").hide(),a.html("").hide(),r&&r.hide(),d.hide(),t.html(l).data("action","generate").removeClass("ai-local-content")):async function(e){const t=e.hasClass("ai-generate-button-global"),o=e.hasClass("ai-generate-button-more"),i=$("#"+e.data("response-div")),n=$("#"+e.data("error-div")).hide(),s=$("#"+e.data("disclaimer-div")),a=$("#"+e.data("wrapper-div")),r=$("#"+e.data("continue-in-chat-button")),l=e.data("section-anchor")||"global",c=e.data("module-anchor"),d=e.data("plot-view"),h=e.data("clear-text");let m,p,u="None"==configTitle?"":configTitle+": ";if(t)p=o?window.systemPromptReportFull:window.systemPromptReportShort,m=Bs(Ys(p)),u+="MultiQC report";else if("general_stats_table"===l)p=window.systemPromptPlot,m=Bs(Ys(p),!0,d),u+="MultiQC General Statistics";else{p=window.systemPromptPlot,m=Ws(l,c,d);const e=aiReportMetadata.sections[l];u+=`MultiQC ${e.name}`}const f=Ys(p+m),_=$("#ai-provider").val(),g=window.AI_PROVIDERS[_];let b=$("#ai-model").val(),v=$("#ai-api-key").val(),w=$("#ai-endpoint").val();if(!b&&g.defaultModel&&(b=g.defaultModel,$("#ai-model").val(b),storeModelName(_,b)),"custom"===_&&!w||"seqera"!==_&&!b||!v){const e=$("#ai_endpoint_group").find("input"),t=$("#ai_model_group"),o=t.find("input"),i=$("#ai_api_key_group"),n=i.find("input");if("custom"===_&&!w){const e=$("#ai_endpoint_group"),t=e.find("input"),o=e.find("label"),i=o.css("color");o.css("color","#a94442"),e.addClass("has-error"),t.one("change",function(){e.removeClass("has-error"),o.css("color",i)})}if(!b){const e=t.find("label");t.addClass("has-error");const i=e.css("color");e.css("color","#a94442"),o.focus(),o.one("change",function(){t.removeClass("has-error"),e.css("color",i)})}if(!v){const e=i.find("label");i.addClass("has-error");const t=i.find("#ai_api_key_info_required"),o=t.css("color"),s=e.css("color");t.css("color","#a94442"),e.css("color","#a94442"),n.focus(),n.one("input",function(){i.removeClass("has-error"),t.css("color",o),e.css("color",s)})}return mqc_toolbox_openclose("#mqc_ai",!0),void("custom"!==_||w?b?v||n.focus():o.focus():e.focus())}const y=Us(b);if(f>y)return n.html(`Content exceeds the token limit of ${g.name} (${f} > ${y})`).show(),void(a&&a.show());const x="custom"===_?"custom endpoint":g.name;function q(){const t=$("#ai-endpoint").val(),o="custom"===_?t:g.name;s.find(".ai-summary-disclaimer-provider").text(o),s.find(".ai-summary-disclaimer-model").text(b),s.show(),e.data("action","clear").prop("disabled",!1).html(h).addClass("ai-local-content")}e.prop("disabled",!0).html(`Requesting ${x}...`);const k=performance.now();await(async()=>{let n="";window.runStreamGeneration({title:u+`, created on ${configCreationDate}`,systemPrompt:p,userMessage:m,tags:["multiqc"],onStreamStart:t=>{b=t,e.html("Starting generation...")},onStreamNewToken:t=>{i.show(),a&&a.show(),n+=t,i.html(window.markdownToHtml(n)),e.html("Generating...")},onStreamError:e=>{showToast("Error generating summary",e,"error"),q(g.name),!o&&t&&$("#global_ai_summary_more_button_and_disclaimer").hide(),s.hide()},onStreamComplete:a=>{q(g.name),a&&r.attr("href",`${seqeraWebsite}/ask-ai/chat/${a}`).show(),i.find('[data-bs-toggle="tooltip"]').each(function(){new bootstrap.Tooltip(this)});const l=e.data("plot-anchor")||"global";localStorage.setItem(`ai_response_${reportUuid}_${l}${o?"_more":""}`,JSON.stringify({text:n,provider:_,model:b,timestamp:Date.now(),threadId:a,endpoint:w}));const c=performance.now();console.log(`Time to generate more: ${c-k}ms`),!o&&t&&$("#global_ai_summary_more_button_and_disclaimer").show(),s.show()}})})()}(t)}function Ys(e){return Math.ceil(e.length/1.5)}window.ViolinPlot=Fs,window.runStreamGeneration=function({onStreamStart:e,onStreamNewToken:t,onStreamError:o,onStreamComplete:i,systemPrompt:n,userMessage:s,tags:a=[],title:r=""}){const l=$("#ai-provider").val(),c=window.AI_PROVIDERS[l],d=$("#ai-model").val(),h=$("#ai-api-key").val(),m=$("#ai-endpoint").val(),p={method:"POST",headers:{"Content-Type":"application/json"}};if("Seqera AI"===c.name)h&&(p.headers.Authorization=`Bearer ${h}`),p.body=JSON.stringify({message:r+"\n\n:::details\n\n"+n+"\n\n"+s+"\n\n:::\n\n",stream:!0,tags:["multiqc",...a],title:r}),fetch(`${seqeraApiUrl}/internal-ai/query`,p).then(e=>e.ok?e.body.getReader():e.json().then(t=>{const i=`HTTP ${e.status}: ${e.statusText} ${t.error?.message||"Unknown error"}`;throw o(i),new Error(i)})).then(n=>Rs(l,n,e,t,o,i)).catch(e=>{o(zs(e))});else if("OpenAI"===c.name||"Custom"===c.name){let a={};if("Custom"===c.name)try{a=JSON.parse($("#ai-query-options").val())}catch(u){console.error("Error parsing extra query options",u)}a={...a,model:d,messages:[{role:"user",content:n+"\n\n"+s}],stream:!0},p.body=JSON.stringify(a),p.headers.Authorization=`Bearer ${h}`;const r="Custom"===c.name?m:"https://api.openai.com/v1/chat/completions";fetch(r,p).then(async e=>{if(!e.ok){const t=await e.json();throw o(`HTTP ${e.status}: ${e.statusText} ${t.error?.message||"Unknown error"}`),new Error(t.error?.message||"Unknown error")}return e.body.getReader()}).then(n=>Rs(l,n,e,t,o,i)).catch(e=>{o(zs(e))})}else"Anthropic"===c.name?(p.headers={...p.headers,"x-api-key":h,"anthropic-version":"2023-06-01","anthropic-dangerous-direct-browser-access":"true"},p.body=JSON.stringify({model:d,max_tokens:4096,messages:[{role:"user",content:n+"\n\n"+s}],stream:!0}),fetch("https://api.anthropic.com/v1/messages",p).then(async e=>{if(!e.ok){const t=await e.json(),i=t.error?`${t.error.type}: ${t.error.message}`:"Unknown error";throw o(`HTTP ${e.status}: ${e.statusText} ${i}`),new Error(i)}return e.body.getReader()}).then(n=>Rs(l,n,e,t,o,i)).catch(e=>{o(zs(e))})):o(`Unsupported AI provider: ${c.name}`)},window.markdownToHtml=function(e){if(!e)return"";e=(e=(e=function(e){if(!aiPseudonymMap)return e;if(!getStoredSampleAnonymizationEnabled())return e;const t=Object.fromEntries(Object.entries(aiPseudonymMap).map(([e,t])=>[t,e]));for(const[o,i]of Object.entries(t))e=(e=e.replace(new RegExp(`:sample\\[${Hs(o)}\\](\\{[^}]+\\})`,"g"),`:sample[${i}]$1`)).replace(new RegExp(`\\b${Hs(o)}\\b`,"g"),i);return e}(e)).replace(/:span\[([^\]]+?)\]\{\.text-(green|red|yellow)\}/g,(e,t,o)=>`${t}`)).replace(/:sample\[([^\]]+?)\]\{\.text-(green|red|yellow)\}/g,(e,t,o)=>`${t}`);try{return new showdown.Converter({literalMidWordUnderscores:!0}).makeHtml(e)}catch(t){return e}},window.multiqcDescription='You are an expert in bioinformatics, sequencing technologies, genomics data analysis, and adjacent fields.\n\nYou are given findings from a MultiQC report, generated by a bioinformatics workflow.\nMultiQC supports various bioinformatics tools that output QC metrics, and aggregates those metrics\ninto a single report. It outputs a "General Statistics" table with key metrics for each sample across\nall tools. That table is followed by more detailed sections from specific tools, that can include tables,\nas well as plots of different types (bar plot, line plot, scatter plot, heatmap, etc.)\n',window.systemPromptReport=window.multiqcDescription+"\nYou are given data from such a report. Your task is to analyse this data and\ngenerate an overall summary for the results.\n\nPlease don't print any introductory words, just get to the point.\nYou task is to just generate a concise summary of the report, nothing else.\nDon't waste words: mention only the important QC issues. If there are no issues, just say so.\nTry to format the response with bullet points. Please do not add any extra headers to the response.\n\nUse markdown to format your reponse for readability. Use directives with pre-defined classes\n.text-green, .text-red, and .text-yellow to highlight severity, e.g. :span[39.2%]{.text-red}.\nIf there are any sample names mentioned, or sample name prefixes or suffixes, you must warp them in\na sample directive, making sure to use same color classes as for severity, for example: :sample[A1001.2003]{.text-yellow}\nor :sample[A1001]{.text-yellow}. But never put multiple sample names inside one directive.\n\nYou must use only multiples of 4 spaces to indent nested lists.\n",window.systemPromptReportShort=window.systemPromptReport+"\nLimit the response to 1-2 bullet points. Two such examples of short summaries:\n\n- :span[11/13 samples]{.text-green} show consistent metrics within expected ranges.\n- :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} exhibit extremely high percentage of :span[duplicates]{.text-red} (:span[65.54%]{.text-red} and :span[83.14%]{.text-red}, respectively).\n\n- All samples show good quality metrics with :span[75.7-77.0%]{.text-green} CpG methylation and :span[76.3-86.0%]{.text-green} alignment rates\n- :sample[2wk]{.text-yellow} samples show slightly higher duplication (:span[11-15%]{.text-yellow}) compared to :sample[1wk]{.text-green} samples (:span[6-9%]{.text-green})'\n",window.systemPromptReportFull=window.systemPromptReport+"\nFollow up with recommendations for the next steps.\n\nThis is the example response:\n\n##### Analysis\n\n- :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.\n- :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} show severe quality issues:\n - Extremely high duplicate rates (:span[65.54%]{.text-red} and :span[83.14%]{.text-red})\n - Low percentages of valid pairs (:span[37.2%]{.text-red} and :span[39.2%]{.text-red})\n - High percentages of failed modules in FastQC (:span[33.33%]{.text-red})\n - Significantly higher total sequence counts (:span[141.9M]{.text-red} and :span[178.0M]{.text-red}) compared to other samples\n - FastQC results indicate that :sample[A1001.2003]{.text-red} and :sample[A1001.2004]{.text-red} have a slight :span[GC content]{.text-red} bias at 39.5% against most other samples having 38.0%, which indicates a potential contamination that could be the source of other anomalies in quality metrics.\n\n- :sample[A1002-1007]{.text-yellow} shows some quality concerns:\n - Low percentage of valid pairs (:span[48.08%]{.text-yellow})\n - Low percentage of passed Di-Tags (:span[22.51%]{.text-yellow})\n\n- 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).\n- 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.\n\n##### Recommendations\n\n- Remove :sample[A1001.2003]{.text-red} and :sample[A1200.2004]{.text-red} from further analysis due to severe quality issues.\n- 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.\n- Perform adapter trimming on all samples, particularly focusing on :sample[A1001]{.text-red} group.\n- Re-run the Hi-C analysis pipeline after removing problematic samples and performing adapter trimming.\n- Investigate the cause of higher duplication rates in :sample[A1002]{.text-yellow} group compared to :sample[A1003]{.text-green} group, although they are still within acceptable ranges.\n- Consider adjusting the Hi-C protocol or library preparation steps to improve the percentage of valid pairs, especially for :sample[A1002]{.text-yellow} group.\n",window.systemPromptPlot=window.multiqcDescription+"\nYou are given a single MultiQC report section with a plot or a table.\nYour task is to analyse the data and give a very short and concise overall summary of the results.\nDon't waste words: mention only the important QC issues. If there are no issues, just say so.\nLimit it to 1-2 sentences.\n\nMake sure to use markdown to format your reponse for readability. Use directives with pre-defined classes\n.text-green, .text-red, and .text-yellow to highlight severity, e.g. :span[39.2%]{.text-red}.\nIf there are any sample names mentioned, or sample name prefixes or suffixes, you must warp them in\na sample directive, making sure to use same color classes as for severity, for example: :sample[A1001.2003]{.text-yellow}\nor :sample[A1001]{.text-yellow}. But never put multiple sample names inside one directive.\n\nPlease do not add any extra headers to the response.\n\nMake sure to use a multiple of 4 spaces to indent nested lists.",window.continueInSeqeraChatHandler=function(e){let t=$(e.currentTarget),o=t.data("seqera-website"),i=t.data("thread-id"),n=o+"/ask-ai/";i&&(n+="/chat/="+i),window.open(n,"_blank")},$(function(){function e(e,t){e.preventDefault();const o=$(e.currentTarget),{wholeReport:i,table:n}=t,s=o.data("section-anchor"),a=o.data("module-anchor"),r=o.data("plot-view");let l,c;i?(c="You are given data of a MultiQC report",l=Bs()):"general_stats_table"===s?(c="You are given the general statistics report table",l=Bs(0,!0,r)):n?(c="You are given a single MultiQC report table",l=Ws(s,a,r)):(c="You are given data of a single MultiQC report section with a plot",l=Ws(s,a,r)),c+=". Your task is to analyse the data and give a concise summary.";const d=window.multiqcDescription+"\n"+c+"\n\n"+l;navigator.clipboard.writeText(d);const h=o.find(".button-text").text();o.find(".button-text").text("Copied!"),setTimeout(()=>{o.find(".button-text").text(h)},2e3)}$("#global_ai_summary_expand").each(function(){const e=$("#global_ai_summary_detailed_analysis_response"),t=e.hasClass("ai-local-content"),o=$("#global_ai_summary_expand"),i=o.find("svg"),n=o.find("span");let s=e.is(":visible");const a=localStorage.getItem("mqc_ai_global_summary_expanded");"expanded"===a&&(s=!0),"collapsed"===a&&(s=!1),s&&!t?(e.show(),i.css("transform","rotate(180deg)"),n.text("Hide full summary")):(e.hide(),i.css("transform","rotate(0deg)"),n.text("View full summary")),o.on("click",t=>{t.preventDefault(),s=!s,s?(e.show(),i.css("transform","rotate(180deg)"),n.text("Hide full summary")):(e.hide(),i.css("transform","rotate(0deg)"),n.text("View full summary")),localStorage.setItem("mqc_ai_global_summary_expanded",s?"expanded":"collapsed")}),e.on("click",function(e){e.preventDefault()})}),$("button.ai-generate-button").each(function(){const e=$(this),t=e.html();e.data("original-html",t).removeClass("ai-local-content");const o=e.data("clear-text"),i=e.hasClass("ai-generate-button-more"),n=e.data("action"),s=$("#"+e.data("response-div")),a=$("#"+e.data("disclaimer-div")),r=$("#"+e.data("wrapper-div")),l=$("#"+e.data("continue-in-chat-button"));if(r&&r.addClass("ai-local-content"),"clear"===n)e.html(o).addClass("ai-local-content");else{const t=e.data("plot-anchor")||"global",n=localStorage.getItem(`ai_response_${reportUuid}_${t}${i?"_more":""}`);if(n){const t=JSON.parse(n);s.show().html(window.markdownToHtml(t.text)),r&&r.show();const i=window.AI_PROVIDERS[t.provider];a.find(".ai-summary-disclaimer-provider").text("Custom"==i.name?t.endpoint:i.name),a.find(".ai-summary-disclaimer-model").text(t.model),a.show(),e.html(o).data("action","clear").prop("disabled",!1).addClass("ai-local-content");const c=t.threadId;c&&(l.attr("href",`${seqeraWebsite}/ask-ai/chat/${c}`),l.show())}}e.on("click",Vs)}),$(document).on("click","sample",function(e){e.preventDefault();let t=$(this).text();t.includes("*")&&(t=t.replace(/\*/g,".*"),$(".mqc_regex_mode input").prop("checked",!0));let o=$(this).css("color");window.mqc_highlight_f_texts.includes(t)?($("#mqc_col_filters li").each(function(){$(this).children("input").attr("value")===t&&$(this).children(".close").click()}),$(this).css("font-weight","normal"),$("sample").each(function(){$(this).text().indexOf(t)>-1&&$(this).css("font-weight","normal")})):($("#mqc_colour_filter").val(t),$("#mqc_colour_filter_color").val(rgbToHex(o)),$(this).css("font-weight","bold"),$("sample").each(function(){$(this).text().indexOf(t)>-1&&$(this).css("font-weight","bold")})),$("#mqc_color_form").trigger("submit"),$("#mqc_cols_apply").click()}),$("button.ai-copy-content-report").click(t=>e(t,{wholeReport:!0,table:!1})),$("button.ai-copy-content-plot").click(t=>e(t,{wholeReport:!1,table:!1})),$("button.ai-copy-content-table").click(t=>e(t,{wholeReport:!1,table:!0}))}),$(function(){decompressPlotData(mqc_compressed_plotdata,(e,t)=>{t?console.error(t):window.callAfterDecompressed.forEach(function(t){t(e)})})}),window.bootstrap=bs; diff --git a/multiqc/templates/disco/package-lock.json b/multiqc/templates/disco/package-lock.json new file mode 100644 index 0000000000..b3e583669f --- /dev/null +++ b/multiqc/templates/disco/package-lock.json @@ -0,0 +1,1739 @@ +{ + "name": "multiqc-default-template", + "version": "1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "multiqc-default-template", + "version": "1.0", + "devDependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.7", + "sass": "^1.83.0", + "terser": "^5.43.1", + "vite": "^7.1.11" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/terser": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/vite": { + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + } + } +} diff --git a/multiqc/templates/disco/package.json b/multiqc/templates/disco/package.json new file mode 100644 index 0000000000..e8de66b1d7 --- /dev/null +++ b/multiqc/templates/disco/package.json @@ -0,0 +1,19 @@ +{ + "name": "multiqc-disco-template", + "version": "1.0", + "description": "90s Disco theme for MultiQC reports", + "private": true, + "type": "module", + "scripts": { + "build": "vite build --emptyOutDir", + "watch": "vite build --emptyOutDir --watch", + "dev": "vite build --emptyOutDir --watch" + }, + "devDependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.7", + "sass": "^1.83.0", + "terser": "^5.43.1", + "vite": "^7.1.11" + } +} diff --git a/multiqc/templates/disco/src/js/main.js b/multiqc/templates/disco/src/js/main.js new file mode 100644 index 0000000000..88a1eaf1fd --- /dev/null +++ b/multiqc/templates/disco/src/js/main.js @@ -0,0 +1,5 @@ +// Import Brite theme CSS +import "../scss/main.scss"; + +// Import all JavaScript from the default theme +export * from "../../../default/src/js/main-js.js"; diff --git a/multiqc/templates/disco/src/scss/_colors.scss b/multiqc/templates/disco/src/scss/_colors.scss new file mode 100644 index 0000000000..c07ef23b4d --- /dev/null +++ b/multiqc/templates/disco/src/scss/_colors.scss @@ -0,0 +1,11 @@ +$body-bg: #833ab4; + +$font-family-base: "Rock Salt", cursive; + +$primary: #7300ff; +$secondary: #00ff0d; + +$body-primary-bg: #f00; +$body-secondary-bg: #00f; +$body-tertiary-bg: #e1ff00; +$info: #3300aa; diff --git a/multiqc/templates/disco/src/scss/custom.scss b/multiqc/templates/disco/src/scss/custom.scss new file mode 100644 index 0000000000..f20c61fbbd --- /dev/null +++ b/multiqc/templates/disco/src/scss/custom.scss @@ -0,0 +1,8 @@ +@import "https://fonts.googleapis.com/css2?family=Rock+Salt&display=swap"; + +body { + background: linear-gradient(90deg, rgba(131, 58, 180, 1) 0%, rgba(253, 29, 29, 1) 50%, rgba(252, 176, 69, 1) 100%); +} +.side-nav-wrapper { + background: linear-gradient(0deg, rgba(34, 193, 195, 1) 0%, rgba(253, 187, 45, 1) 100%); +} diff --git a/multiqc/templates/disco/src/scss/main.scss b/multiqc/templates/disco/src/scss/main.scss new file mode 100644 index 0000000000..03d715a0cc --- /dev/null +++ b/multiqc/templates/disco/src/scss/main.scss @@ -0,0 +1,70 @@ +// Main SCSS file for MultiQC Crazy template +// This file imports Bootstrap and custom MultiQC styles + +$enable-dark-mode: false; + +// Default template colours +@import "../../../default/src/scss/_colors"; + +// Theme colours +@import "_colors"; + +// Bootstrap 5 - Functions and base variables +@import "../../node_modules/bootstrap/scss/functions"; +@import "../../node_modules/bootstrap/scss/variables"; + +// Default template variables +// @import "../../../default/src/scss/_variables"; + +// Theme variables +// @import "_variables"; + +// Required Bootstrap parts +@import "../../node_modules/bootstrap/scss/maps"; +@import "../../node_modules/bootstrap/scss/mixins"; +@import "../../node_modules/bootstrap/scss/root"; +@import "../../node_modules/bootstrap/scss/utilities"; + +// Bootstrap - Layout & components +@import "../../node_modules/bootstrap/scss/reboot"; +@import "../../node_modules/bootstrap/scss/type"; +@import "../../node_modules/bootstrap/scss/images"; +@import "../../node_modules/bootstrap/scss/containers"; +@import "../../node_modules/bootstrap/scss/grid"; +@import "../../node_modules/bootstrap/scss/tables"; +@import "../../node_modules/bootstrap/scss/forms"; +@import "../../node_modules/bootstrap/scss/buttons"; +@import "../../node_modules/bootstrap/scss/transitions"; +@import "../../node_modules/bootstrap/scss/dropdown"; +@import "../../node_modules/bootstrap/scss/button-group"; +@import "../../node_modules/bootstrap/scss/nav"; +@import "../../node_modules/bootstrap/scss/navbar"; +@import "../../node_modules/bootstrap/scss/card"; +// @import "../../node_modules/bootstrap/scss/accordion"; +// @import "../../node_modules/bootstrap/scss/breadcrumb"; +// @import "../../node_modules/bootstrap/scss/pagination"; +@import "../../node_modules/bootstrap/scss/badge"; +@import "../../node_modules/bootstrap/scss/alert"; +@import "../../node_modules/bootstrap/scss/progress"; +@import "../../node_modules/bootstrap/scss/list-group"; +@import "../../node_modules/bootstrap/scss/close"; +@import "../../node_modules/bootstrap/scss/toasts"; +@import "../../node_modules/bootstrap/scss/modal"; +@import "../../node_modules/bootstrap/scss/tooltip"; +@import "../../node_modules/bootstrap/scss/popover"; +// @import "../../node_modules/bootstrap/scss/carousel"; +// @import "../../node_modules/bootstrap/scss/spinners"; +@import "../../node_modules/bootstrap/scss/offcanvas"; +// @import "../../node_modules/bootstrap/scss/placeholders"; + +// Bootstrap - Helpers +@import "../../node_modules/bootstrap/scss/helpers"; + +// Bootstrap - Utilities +@import "../../node_modules/bootstrap/scss/utilities/api"; + +// Custom MultiQC styles +@import "../../../default/src/scss/custom"; + +// Theme styles +@import "custom"; diff --git a/multiqc/templates/disco/vite.config.js b/multiqc/templates/disco/vite.config.js new file mode 100644 index 0000000000..476eece615 --- /dev/null +++ b/multiqc/templates/disco/vite.config.js @@ -0,0 +1,26 @@ +import { resolve } from "path"; + +export default { + root: resolve(__dirname, "src"), + build: { + outDir: "../compiled", + rollupOptions: { + input: { + main: resolve(__dirname, "src/js/main.js"), + }, + output: { + entryFileNames: "js/multiqc.min.js", + assetFileNames: "css/multiqc.min.css", + }, + }, + minify: "terser", + cssMinify: true, + }, + css: { + preprocessorOptions: { + scss: { + silenceDeprecations: ["import", "mixed-decls", "color-functions", "global-builtin"], + }, + }, + }, +}; diff --git a/multiqc/templates/gathered/__init__.py b/multiqc/templates/gathered/__init__.py index b7c819b2be..d5aeeede7e 100644 --- a/multiqc/templates/gathered/__init__.py +++ b/multiqc/templates/gathered/__init__.py @@ -11,5 +11,5 @@ import os template_dir = os.path.dirname(__file__) -template_parent = "default" +template_parent = "original" base_fn = "base.html" diff --git a/multiqc/templates/gathered/content.html b/multiqc/templates/gathered/content.html index 276bf09912..8494dac84e 100644 --- a/multiqc/templates/gathered/content.html +++ b/multiqc/templates/gathered/content.html @@ -10,30 +10,48 @@

                Detailed Statistics

                - {% for m in report.modules %} - {% if m.sections | length > 0 %} - {% if m['comment'] %}
                {{ m['comment'] }}
                {% endif %} - {% for s in m.sections %} - {% if s['print_section'] %} - {% if (s['name'] is none or s['name'] | length == 0) and s['helptext'] is not none and s['helptext'] | length > 0 %} - - {% endif %} + {% for m in report.modules %} + {% if m.sections | length > 0 %} + {% if m['comment'] %}
                {{ m['comment'] }}
                {% endif %} + {% for s in m.sections %} + {% if s['print_section'] %} + {% if (s['name'] is none or s['name'] | length == 0) and s['helptext'] is not none and s['helptext'] | length > 0 %} + + {% endif %} {% if s['name'] is not none and s['name'] | length > 0 %}

                - {{ s['name'] }} - {% if s['helptext'] is not none and s['helptext'] | length > 0 %} - - {% endif %} + {{ s['name'] }} + {% if s['helptext'] is not none and s['helptext'] | length > 0 %} + + {% endif %}

                {% endif %} - {% if s['description'] is not none and s['description'] | length > 0 %}
                {{ s['description'] }}
                {% endif %} - {% if s['comment'] is not none and s['comment'] | length > 0 %}
                {{ s['comment'] }}
                {% endif %} + {% if s['description'] is not none and s['description'] | length > 0 %} +
                {{ s['description'] }}
                + {% endif %} + {% if s['comment'] is not none and s['comment'] | length > 0 %} +
                {{ s['comment'] }}
                + {% endif %} {% if s['helptext'] is not none and s['helptext'] | length > 0 %}
                {{ s['helptext'] }}
                @@ -42,10 +60,10 @@

                {% if s['plot'] is not none %}
                {{ s['plot'] }}
                {% endif %} {{ s['content'] if s['content'] }} {{ '
                ' if not loop.last }} - {% endif %} + {% endif %} + {% endfor %} + {{ '
                ' if not loop.last }} + {% endif %} {% endfor %} - {{ '
                ' if not loop.last }} - {% endif %} - {% endfor %}

                diff --git a/multiqc/templates/gathered/nav.html b/multiqc/templates/gathered/nav.html index d0a4c6e219..8913ec34b9 100644 --- a/multiqc/templates/gathered/nav.html +++ b/multiqc/templates/gathered/nav.html @@ -16,8 +16,8 @@

                - - + +

                @@ -27,25 +27,25 @@

                {{ config.title }}

                Loading report..

                diff --git a/multiqc/templates/geo/__init__.py b/multiqc/templates/geo/__init__.py index 407410587e..5d2233137c 100644 --- a/multiqc/templates/geo/__init__.py +++ b/multiqc/templates/geo/__init__.py @@ -12,7 +12,7 @@ import os -template_parent = "default" +template_parent = "original" template_dir = os.path.dirname(__file__) base_fn = "base.html" diff --git a/multiqc/templates/original/__init__.py b/multiqc/templates/original/__init__.py new file mode 100644 index 0000000000..806b01d88f --- /dev/null +++ b/multiqc/templates/original/__init__.py @@ -0,0 +1,22 @@ +""" +=========== + original +=========== + +The artist formerly known as "default", now renamed to be the OG: "original". + +Note, this is where most of the MultiQC report interactive functionality +is based and will be developed. Unless you want to do some really radical +changes, you probably don't want to replace this theme. Instead, you can +create a child theme that starts with 'default' and then overwrites +certain files. + +For more information about creating child themes, see the docs: +docs/templates.md + +""" + +import os + +template_dir = os.path.dirname(__file__) +base_fn = "base.html" diff --git a/multiqc/templates/default/assets/css/bootstrap.min.css b/multiqc/templates/original/assets/css/bootstrap.min.css similarity index 100% rename from multiqc/templates/default/assets/css/bootstrap.min.css rename to multiqc/templates/original/assets/css/bootstrap.min.css diff --git a/multiqc/templates/default/assets/css/default_multiqc.css b/multiqc/templates/original/assets/css/default_multiqc.css similarity index 97% rename from multiqc/templates/default/assets/css/default_multiqc.css rename to multiqc/templates/original/assets/css/default_multiqc.css index 7bc54bc4a3..8e4ee99781 100644 --- a/multiqc/templates/default/assets/css/default_multiqc.css +++ b/multiqc/templates/original/assets/css/default_multiqc.css @@ -1,5 +1,20 @@ /* CSS Styles for Default MultiQC Report Template */ +/* NEW: Maintain approximate visual parity post Bootstrap 5 upgrade in main MultiQC codebase */ + +.btn-outline-secondary { + color: #333; + background-color: #fff; + border-color: #ccc; +} +.btn-sm { + padding: 5px 10px 5px 8px; +} +.col-12 { + padding-right: 15px; + padding-left: 15px; +} + /* General Styles */ code { background-color: #f3f3f3; @@ -231,10 +246,19 @@ kbd { column-gap: 10px; } +.general-stats-help-btn { + overflow: auto; +} + h2.mqc-module-title { margin: 0; } +/* help button is floated, ensure it is still bounded/contained */ +h3.mqc-section-name-helpbutton { + overflow: auto; +} + #analysis_dirs_wrapper { max-height: 80px; overflow: auto; @@ -958,10 +982,10 @@ tbody .sorthandle { display: none; } - /* make sure logos aren't dominantly huge */ + /* make sure logos aren't excessively huge - can be overwritten with config.custom_logo_width */ .multiqc_logo, .custom_logo { - width: 200px; + max-width: 200px; } /* print cell background colors in table */ diff --git a/multiqc/templates/default/assets/css/jquery.toast.css b/multiqc/templates/original/assets/css/jquery.toast.css similarity index 100% rename from multiqc/templates/default/assets/css/jquery.toast.css rename to multiqc/templates/original/assets/css/jquery.toast.css diff --git a/multiqc/templates/default/assets/fonts/glyphicons-halflings-regular.eot b/multiqc/templates/original/assets/fonts/glyphicons-halflings-regular.eot similarity index 100% rename from multiqc/templates/default/assets/fonts/glyphicons-halflings-regular.eot rename to multiqc/templates/original/assets/fonts/glyphicons-halflings-regular.eot diff --git a/multiqc/templates/default/assets/fonts/glyphicons-halflings-regular.svg b/multiqc/templates/original/assets/fonts/glyphicons-halflings-regular.svg similarity index 100% rename from multiqc/templates/default/assets/fonts/glyphicons-halflings-regular.svg rename to multiqc/templates/original/assets/fonts/glyphicons-halflings-regular.svg diff --git a/multiqc/templates/default/assets/fonts/glyphicons-halflings-regular.ttf b/multiqc/templates/original/assets/fonts/glyphicons-halflings-regular.ttf similarity index 100% rename from multiqc/templates/default/assets/fonts/glyphicons-halflings-regular.ttf rename to multiqc/templates/original/assets/fonts/glyphicons-halflings-regular.ttf diff --git a/multiqc/templates/default/assets/fonts/glyphicons-halflings-regular.woff b/multiqc/templates/original/assets/fonts/glyphicons-halflings-regular.woff similarity index 100% rename from multiqc/templates/default/assets/fonts/glyphicons-halflings-regular.woff rename to multiqc/templates/original/assets/fonts/glyphicons-halflings-regular.woff diff --git a/multiqc/templates/default/assets/fonts/glyphicons-halflings-regular.woff2 b/multiqc/templates/original/assets/fonts/glyphicons-halflings-regular.woff2 similarity index 100% rename from multiqc/templates/default/assets/fonts/glyphicons-halflings-regular.woff2 rename to multiqc/templates/original/assets/fonts/glyphicons-halflings-regular.woff2 diff --git a/multiqc/templates/default/assets/img/MultiQC_logo.png b/multiqc/templates/original/assets/img/MultiQC_logo.png similarity index 100% rename from multiqc/templates/default/assets/img/MultiQC_logo.png rename to multiqc/templates/original/assets/img/MultiQC_logo.png diff --git a/multiqc/templates/default/assets/img/MultiQC_logo_dark.svg b/multiqc/templates/original/assets/img/MultiQC_logo_dark.svg similarity index 100% rename from multiqc/templates/default/assets/img/MultiQC_logo_dark.svg rename to multiqc/templates/original/assets/img/MultiQC_logo_dark.svg diff --git a/multiqc/templates/default/assets/img/favicon-16x16.png b/multiqc/templates/original/assets/img/favicon-16x16.png similarity index 100% rename from multiqc/templates/default/assets/img/favicon-16x16.png rename to multiqc/templates/original/assets/img/favicon-16x16.png diff --git a/multiqc/templates/default/assets/img/favicon-32x32.png b/multiqc/templates/original/assets/img/favicon-32x32.png similarity index 100% rename from multiqc/templates/default/assets/img/favicon-32x32.png rename to multiqc/templates/original/assets/img/favicon-32x32.png diff --git a/multiqc/templates/default/assets/img/favicon-96x96.png b/multiqc/templates/original/assets/img/favicon-96x96.png similarity index 100% rename from multiqc/templates/default/assets/img/favicon-96x96.png rename to multiqc/templates/original/assets/img/favicon-96x96.png diff --git a/multiqc/templates/default/assets/img/seqera_logo.png b/multiqc/templates/original/assets/img/seqera_logo.png similarity index 100% rename from multiqc/templates/default/assets/img/seqera_logo.png rename to multiqc/templates/original/assets/img/seqera_logo.png diff --git a/multiqc/templates/default/assets/img/seqera_logo_mono.png b/multiqc/templates/original/assets/img/seqera_logo_mono.png similarity index 100% rename from multiqc/templates/default/assets/img/seqera_logo_mono.png rename to multiqc/templates/original/assets/img/seqera_logo_mono.png diff --git a/multiqc/templates/default/assets/js/ai-helpers.js b/multiqc/templates/original/assets/js/ai-helpers.js similarity index 99% rename from multiqc/templates/default/assets/js/ai-helpers.js rename to multiqc/templates/original/assets/js/ai-helpers.js index 73c443fbd8..422acf7516 100644 --- a/multiqc/templates/default/assets/js/ai-helpers.js +++ b/multiqc/templates/original/assets/js/ai-helpers.js @@ -18,8 +18,11 @@ function isReasoningModel(model) { "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", ]; return reasoningModels.some((prefix) => model.toLowerCase().startsWith(prefix)); diff --git a/multiqc/templates/default/assets/js/ai.js b/multiqc/templates/original/assets/js/ai.js similarity index 99% rename from multiqc/templates/default/assets/js/ai.js rename to multiqc/templates/original/assets/js/ai.js index 4457e50480..5dfe4d7c38 100644 --- a/multiqc/templates/default/assets/js/ai.js +++ b/multiqc/templates/original/assets/js/ai.js @@ -10,7 +10,7 @@ window.continueInSeqeraChatHandler = function (event) { let threadId = el.data("thread-id"); let url = seqeraWebsite + "/ask-ai/"; - if (threadId) url += "?messages=" + threadId; + if (threadId) url += "/chat/" + threadId; window.open(url, "_blank"); }; @@ -330,7 +330,7 @@ async function summarizeWithAi(button) { wrapUpResponse(disclaimerDiv, provider.name, modelName); // Update the "Chat with Seqera AI" button to point to new thread if (threadId) { - continueInChatButton.attr("href", `${seqeraWebsite}/ask-ai/?messages=${threadId}`).show(); + continueInChatButton.attr("href", `${seqeraWebsite}/ask-ai/chat/${threadId}`).show(); } // Save response to localStorage const elementId = button.data("plot-anchor") || "global"; @@ -464,7 +464,7 @@ $(function () { const threadId = cachedSummary.threadId; if (threadId) { - continueInChatButton.attr("href", `${seqeraWebsite}/ask-ai/?messages=${threadId}`); + continueInChatButton.attr("href", `${seqeraWebsite}/ask-ai/chat/${threadId}`); continueInChatButton.show(); } } diff --git a/multiqc/templates/default/assets/js/decompress.js b/multiqc/templates/original/assets/js/decompress.js similarity index 100% rename from multiqc/templates/default/assets/js/decompress.js rename to multiqc/templates/original/assets/js/decompress.js diff --git a/multiqc/templates/original/assets/js/flat.js b/multiqc/templates/original/assets/js/flat.js new file mode 100644 index 0000000000..dd75870cdf --- /dev/null +++ b/multiqc/templates/original/assets/js/flat.js @@ -0,0 +1,42 @@ +//////////////////////////////////////////////// +// Flat plots controls +//////////////////////////////////////////////// + +// On page load +$(function () { + function switchPlot(button) { + let plotGroup = $(button).closest(".mqc_mplplot_plotgroup"); + let plotAnchor = plotGroup.data("plot-anchor"); + let activeDs = plotGroup.find(".dataset-switch-group button.active"); + if (activeDs !== undefined && activeDs.length > 0) plotAnchor = activeDs.data("datasetUid"); + + let p = plotGroup.find(".percent-switch"); + let l = plotGroup.find(".log10-switch"); + if (p.length > 0 || l.length > 0) { + let pActive = p.length > 0 && p.hasClass("active"); + let lActive = l.length > 0 && l.hasClass("active"); + if (pActive) plotAnchor += "-pct"; + if (lActive) plotAnchor += "-log"; + if (!pActive && !lActive) plotAnchor += "-cnt"; + } + + plotGroup.find(".mqc_mplplot").hide(); + $("#" + plotAnchor).show(); + } + + // Switch between counts and percentages in a bar plot + $(".mpl_switch_group.percent-switch,.mpl_switch_group.log10-switch").click(function (e) { + e.preventDefault(); + $(this).toggleClass("active"); + switchPlot(this); + }); + + // Switch datasets in a bar plot + $(".mpl_switch_group.dataset-switch-group button").click(function (e) { + e.preventDefault(); + if ($(this).hasClass("active")) return; + $(this).siblings("button.active").removeClass("active"); + $(this).addClass("active"); + switchPlot(this); + }); +}); diff --git a/multiqc/templates/default/assets/js/multiqc.js b/multiqc/templates/original/assets/js/multiqc.js similarity index 100% rename from multiqc/templates/default/assets/js/multiqc.js rename to multiqc/templates/original/assets/js/multiqc.js diff --git a/multiqc/templates/original/assets/js/packages/FileSaver.min.js b/multiqc/templates/original/assets/js/packages/FileSaver.min.js new file mode 100755 index 0000000000..9a1e397f20 --- /dev/null +++ b/multiqc/templates/original/assets/js/packages/FileSaver.min.js @@ -0,0 +1,2 @@ +/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */ +var saveAs=saveAs||function(e){"use strict";if(typeof e==="undefined"||typeof navigator!=="undefined"&&/MSIE [1-9]\./.test(navigator.userAgent)){return}var t=e.document,n=function(){return e.URL||e.webkitURL||e},r=t.createElementNS("http://www.w3.org/1999/xhtml","a"),o="download"in r,a=function(e){var t=new MouseEvent("click");e.dispatchEvent(t)},i=/constructor/i.test(e.HTMLElement)||e.safari,f=/CriOS\/[\d]+/.test(navigator.userAgent),u=function(t){(e.setImmediate||e.setTimeout)(function(){throw t},0)},s="application/octet-stream",d=1e3*40,c=function(e){var t=function(){if(typeof e==="string"){n().revokeObjectURL(e)}else{e.remove()}};setTimeout(t,d)},l=function(e,t,n){t=[].concat(t);var r=t.length;while(r--){var o=e["on"+t[r]];if(typeof o==="function"){try{o.call(e,n||e)}catch(a){u(a)}}}},p=function(e){if(/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(e.type)){return new Blob([String.fromCharCode(65279),e],{type:e.type})}return e},v=function(t,u,d){if(!d){t=p(t)}var v=this,w=t.type,m=w===s,y,h=function(){l(v,"writestart progress write writeend".split(" "))},S=function(){if((f||m&&i)&&e.FileReader){var r=new FileReader;r.onloadend=function(){var t=f?r.result:r.result.replace(/^data:[^;]*;/,"data:attachment/file;");var n=e.open(t,"_blank");if(!n)e.location.href=t;t=undefined;v.readyState=v.DONE;h()};r.readAsDataURL(t);v.readyState=v.INIT;return}if(!y){y=n().createObjectURL(t)}if(m){e.location.href=y}else{var o=e.open(y,"_blank");if(!o){e.location.href=y}}v.readyState=v.DONE;h();c(y)};v.readyState=v.INIT;if(o){y=n().createObjectURL(t);setTimeout(function(){r.href=y;r.download=u;a(r);h();c(y);v.readyState=v.DONE});return}S()},w=v.prototype,m=function(e,t,n){return new v(e,t||e.name||"download",n)};if(typeof navigator!=="undefined"&&navigator.msSaveOrOpenBlob){return function(e,t,n){t=t||e.name||"download";if(!n){e=p(e)}return navigator.msSaveOrOpenBlob(e,t)}}w.abort=function(){};w.readyState=w.INIT=0;w.WRITING=1;w.DONE=2;w.error=w.onwritestart=w.onprogress=w.onwrite=w.onabort=w.onerror=w.onwriteend=null;return m}(typeof self!=="undefined"&&self||typeof window!=="undefined"&&window||this.content);if(typeof module!=="undefined"&&module.exports){module.exports.saveAs=saveAs}else if(typeof define!=="undefined"&&define!==null&&define.amd!==null){define("FileSaver.js",function(){return saveAs})} diff --git a/multiqc/templates/default/assets/js/packages/bootstrap.min.js b/multiqc/templates/original/assets/js/packages/bootstrap.min.js similarity index 100% rename from multiqc/templates/default/assets/js/packages/bootstrap.min.js rename to multiqc/templates/original/assets/js/packages/bootstrap.min.js diff --git a/multiqc/templates/original/assets/js/packages/jquery-3.1.1.min.js b/multiqc/templates/original/assets/js/packages/jquery-3.1.1.min.js new file mode 100644 index 0000000000..4c5be4c0fb --- /dev/null +++ b/multiqc/templates/original/assets/js/packages/jquery-3.1.1.min.js @@ -0,0 +1,4 @@ +/*! jQuery v3.1.1 | (c) jQuery Foundation | jquery.org/license */ +!function(a,b){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){"use strict";var c=[],d=a.document,e=Object.getPrototypeOf,f=c.slice,g=c.concat,h=c.push,i=c.indexOf,j={},k=j.toString,l=j.hasOwnProperty,m=l.toString,n=m.call(Object),o={};function p(a,b){b=b||d;var c=b.createElement("script");c.text=a,b.head.appendChild(c).parentNode.removeChild(c)}var q="3.1.1",r=function(a,b){return new r.fn.init(a,b)},s=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,t=/^-ms-/,u=/-([a-z])/g,v=function(a,b){return b.toUpperCase()};r.fn=r.prototype={jquery:q,constructor:r,length:0,toArray:function(){return f.call(this)},get:function(a){return null==a?f.call(this):a<0?this[a+this.length]:this[a]},pushStack:function(a){var b=r.merge(this.constructor(),a);return b.prevObject=this,b},each:function(a){return r.each(this,a)},map:function(a){return this.pushStack(r.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(f.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(a<0?b:0);return this.pushStack(c>=0&&c0&&b-1 in a)}var x=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C={}.hasOwnProperty,D=[],E=D.pop,F=D.push,G=D.push,H=D.slice,I=function(a,b){for(var c=0,d=a.length;c+~]|"+K+")"+K+"*"),S=new RegExp("="+K+"*([^\\]'\"]*?)"+K+"*\\]","g"),T=new RegExp(N),U=new RegExp("^"+L+"$"),V={ID:new RegExp("^#("+L+")"),CLASS:new RegExp("^\\.("+L+")"),TAG:new RegExp("^("+L+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+N),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+K+"*(even|odd|(([+-]|)(\\d*)n|)"+K+"*(?:([+-]|)"+K+"*(\\d+)|))"+K+"*\\)|)","i"),bool:new RegExp("^(?:"+J+")$","i"),needsContext:new RegExp("^"+K+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+K+"*((?:-\\d)?\\d*)"+K+"*\\)|)(?=[^-]|$)","i")},W=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,Y=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,$=/[+~]/,_=new RegExp("\\\\([\\da-f]{1,6}"+K+"?|("+K+")|.)","ig"),aa=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:d<0?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ba=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ca=function(a,b){return b?"\0"===a?"\ufffd":a.slice(0,-1)+"\\"+a.charCodeAt(a.length-1).toString(16)+" ":"\\"+a},da=function(){m()},ea=ta(function(a){return a.disabled===!0&&("form"in a||"label"in a)},{dir:"parentNode",next:"legend"});try{G.apply(D=H.call(v.childNodes),v.childNodes),D[v.childNodes.length].nodeType}catch(fa){G={apply:D.length?function(a,b){F.apply(a,H.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s=b&&b.ownerDocument,w=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==w&&9!==w&&11!==w)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==w&&(l=Z.exec(a)))if(f=l[1]){if(9===w){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(s&&(j=s.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(l[2])return G.apply(d,b.getElementsByTagName(a)),d;if((f=l[3])&&c.getElementsByClassName&&b.getElementsByClassName)return G.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==w)s=b,r=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(ba,ca):b.setAttribute("id",k=u),o=g(a),h=o.length;while(h--)o[h]="#"+k+" "+sa(o[h]);r=o.join(","),s=$.test(a)&&qa(b.parentNode)||b}if(r)try{return G.apply(d,s.querySelectorAll(r)),d}catch(x){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(P,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("fieldset");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&a.sourceIndex-b.sourceIndex;if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return function(b){return"form"in b?b.parentNode&&b.disabled===!1?"label"in b?"label"in b.parentNode?b.parentNode.disabled===a:b.disabled===a:b.isDisabled===a||b.isDisabled!==!a&&ea(b)===a:b.disabled===a:"label"in b&&b.disabled===a}}function pa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function qa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return!!b&&"HTML"!==b.nodeName},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),v!==n&&(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Y.test(n.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){return a.getAttribute("id")===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}}):(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c,d,e,f=b.getElementById(a);if(f){if(c=f.getAttributeNode("id"),c&&c.value===a)return[f];e=b.getElementsByName(a),d=0;while(f=e[d++])if(c=f.getAttributeNode("id"),c&&c.value===a)return[f]}return[]}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){if("undefined"!=typeof b.getElementsByClassName&&p)return b.getElementsByClassName(a)},r=[],q=[],(c.qsa=Y.test(n.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){a.innerHTML="";var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+K+"*[*^$|!~]?="),2!==a.querySelectorAll(":enabled").length&&q.push(":enabled",":disabled"),o.appendChild(a).disabled=!0,2!==a.querySelectorAll(":disabled").length&&q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Y.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"*"),s.call(a,"[s!='']:x"),r.push("!=",N)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Y.test(o.compareDocumentPosition),t=b||Y.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?I(k,a)-I(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?I(k,a)-I(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?la(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(S,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.escape=function(a){return(a+"").replace(ba,ca)},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(_,aa),a[3]=(a[3]||a[4]||a[5]||"").replace(_,aa),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return V.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&T.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(_,aa).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:!b||(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(O," ")+" ").indexOf(c)>-1:"|="===b&&(e===c||e.slice(0,c.length+1)===c+"-"))}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(P,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(_,aa),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return U.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(_,aa).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:oa(!1),disabled:oa(!0),checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:pa(function(){return[0]}),last:pa(function(a,b){return[b-1]}),eq:pa(function(a,b,c){return[c<0?c+b:c]}),even:pa(function(a,b){for(var c=0;c=0;)a.push(d);return a}),gt:pa(function(a,b,c){for(var d=c<0?c+b:c;++d1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function va(a,b,c){for(var d=0,e=b.length;d-1&&(f[j]=!(g[j]=l))}}else r=wa(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ya(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ta(function(a){return a===b},h,!0),l=ta(function(a){return I(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];i1&&ua(m),i>1&&sa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(P,"$1"),c,i0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=E.call(i));u=wa(u)}G.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&ga.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=ya(b[c]),f[u]?d.push(f):e.push(f);f=A(a,za(e,d)),f.selector=a}return f},i=ga.select=function(a,b,c,e){var f,i,j,k,l,m="function"==typeof a&&a,n=!e&&g(a=m.selector||a);if(c=c||[],1===n.length){if(i=n[0]=n[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&9===b.nodeType&&p&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(_,aa),b)||[])[0],!b)return c;m&&(b=b.parentNode),a=a.slice(i.shift().value.length)}f=V.needsContext.test(a)?0:i.length;while(f--){if(j=i[f],d.relative[k=j.type])break;if((l=d.find[k])&&(e=l(j.matches[0].replace(_,aa),$.test(i[0].type)&&qa(b.parentNode)||b))){if(i.splice(f,1),a=e.length&&sa(i),!a)return G.apply(c,e),c;break}}}return(m||h(a,n))(e,b,!p,c,!b||$.test(a)&&qa(b.parentNode)||b),c},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("fieldset"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){if(!c)return a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){if(!c&&"input"===a.nodeName.toLowerCase())return a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(J,function(a,b,c){var d;if(!c)return a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);r.find=x,r.expr=x.selectors,r.expr[":"]=r.expr.pseudos,r.uniqueSort=r.unique=x.uniqueSort,r.text=x.getText,r.isXMLDoc=x.isXML,r.contains=x.contains,r.escapeSelector=x.escape;var y=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&r(a).is(c))break;d.push(a)}return d},z=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},A=r.expr.match.needsContext,B=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i,C=/^.[^:#\[\.,]*$/;function D(a,b,c){return r.isFunction(b)?r.grep(a,function(a,d){return!!b.call(a,d,a)!==c}):b.nodeType?r.grep(a,function(a){return a===b!==c}):"string"!=typeof b?r.grep(a,function(a){return i.call(b,a)>-1!==c}):C.test(b)?r.filter(b,a,c):(b=r.filter(b,a),r.grep(a,function(a){return i.call(b,a)>-1!==c&&1===a.nodeType}))}r.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?r.find.matchesSelector(d,a)?[d]:[]:r.find.matches(a,r.grep(b,function(a){return 1===a.nodeType}))},r.fn.extend({find:function(a){var b,c,d=this.length,e=this;if("string"!=typeof a)return this.pushStack(r(a).filter(function(){for(b=0;b1?r.uniqueSort(c):c},filter:function(a){return this.pushStack(D(this,a||[],!1))},not:function(a){return this.pushStack(D(this,a||[],!0))},is:function(a){return!!D(this,"string"==typeof a&&A.test(a)?r(a):a||[],!1).length}});var E,F=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,G=r.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||E,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:F.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof r?b[0]:b,r.merge(this,r.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),B.test(e[1])&&r.isPlainObject(b))for(e in b)r.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&(this[0]=f,this.length=1),this}return a.nodeType?(this[0]=a,this.length=1,this):r.isFunction(a)?void 0!==c.ready?c.ready(a):a(r):r.makeArray(a,this)};G.prototype=r.fn,E=r(d);var H=/^(?:parents|prev(?:Until|All))/,I={children:!0,contents:!0,next:!0,prev:!0};r.fn.extend({has:function(a){var b=r(a,this),c=b.length;return this.filter(function(){for(var a=0;a-1:1===c.nodeType&&r.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?r.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?i.call(r(a),this[0]):i.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(r.uniqueSort(r.merge(this.get(),r(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function J(a,b){while((a=a[b])&&1!==a.nodeType);return a}r.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return y(a,"parentNode")},parentsUntil:function(a,b,c){return y(a,"parentNode",c)},next:function(a){return J(a,"nextSibling")},prev:function(a){return J(a,"previousSibling")},nextAll:function(a){return y(a,"nextSibling")},prevAll:function(a){return y(a,"previousSibling")},nextUntil:function(a,b,c){return y(a,"nextSibling",c)},prevUntil:function(a,b,c){return y(a,"previousSibling",c)},siblings:function(a){return z((a.parentNode||{}).firstChild,a)},children:function(a){return z(a.firstChild)},contents:function(a){return a.contentDocument||r.merge([],a.childNodes)}},function(a,b){r.fn[a]=function(c,d){var e=r.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=r.filter(d,e)),this.length>1&&(I[a]||r.uniqueSort(e),H.test(a)&&e.reverse()),this.pushStack(e)}});var K=/[^\x20\t\r\n\f]+/g;function L(a){var b={};return r.each(a.match(K)||[],function(a,c){b[c]=!0}),b}r.Callbacks=function(a){a="string"==typeof a?L(a):r.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),c<=h&&h--}),this},has:function(a){return a?r.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||b||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j};function M(a){return a}function N(a){throw a}function O(a,b,c){var d;try{a&&r.isFunction(d=a.promise)?d.call(a).done(b).fail(c):a&&r.isFunction(d=a.then)?d.call(a,b,c):b.call(void 0,a)}catch(a){c.call(void 0,a)}}r.extend({Deferred:function(b){var c=[["notify","progress",r.Callbacks("memory"),r.Callbacks("memory"),2],["resolve","done",r.Callbacks("once memory"),r.Callbacks("once memory"),0,"resolved"],["reject","fail",r.Callbacks("once memory"),r.Callbacks("once memory"),1,"rejected"]],d="pending",e={state:function(){return d},always:function(){return f.done(arguments).fail(arguments),this},"catch":function(a){return e.then(null,a)},pipe:function(){var a=arguments;return r.Deferred(function(b){r.each(c,function(c,d){var e=r.isFunction(a[d[4]])&&a[d[4]];f[d[1]](function(){var a=e&&e.apply(this,arguments);a&&r.isFunction(a.promise)?a.promise().progress(b.notify).done(b.resolve).fail(b.reject):b[d[0]+"With"](this,e?[a]:arguments)})}),a=null}).promise()},then:function(b,d,e){var f=0;function g(b,c,d,e){return function(){var h=this,i=arguments,j=function(){var a,j;if(!(b=f&&(d!==N&&(h=void 0,i=[a]),c.rejectWith(h,i))}};b?k():(r.Deferred.getStackHook&&(k.stackTrace=r.Deferred.getStackHook()),a.setTimeout(k))}}return r.Deferred(function(a){c[0][3].add(g(0,a,r.isFunction(e)?e:M,a.notifyWith)),c[1][3].add(g(0,a,r.isFunction(b)?b:M)),c[2][3].add(g(0,a,r.isFunction(d)?d:N))}).promise()},promise:function(a){return null!=a?r.extend(a,e):e}},f={};return r.each(c,function(a,b){var g=b[2],h=b[5];e[b[1]]=g.add,h&&g.add(function(){d=h},c[3-a][2].disable,c[0][2].lock),g.add(b[3].fire),f[b[0]]=function(){return f[b[0]+"With"](this===f?void 0:this,arguments),this},f[b[0]+"With"]=g.fireWith}),e.promise(f),b&&b.call(f,f),f},when:function(a){var b=arguments.length,c=b,d=Array(c),e=f.call(arguments),g=r.Deferred(),h=function(a){return function(c){d[a]=this,e[a]=arguments.length>1?f.call(arguments):c,--b||g.resolveWith(d,e)}};if(b<=1&&(O(a,g.done(h(c)).resolve,g.reject),"pending"===g.state()||r.isFunction(e[c]&&e[c].then)))return g.then();while(c--)O(e[c],h(c),g.reject);return g.promise()}});var P=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;r.Deferred.exceptionHook=function(b,c){a.console&&a.console.warn&&b&&P.test(b.name)&&a.console.warn("jQuery.Deferred exception: "+b.message,b.stack,c)},r.readyException=function(b){a.setTimeout(function(){throw b})};var Q=r.Deferred();r.fn.ready=function(a){return Q.then(a)["catch"](function(a){r.readyException(a)}),this},r.extend({isReady:!1,readyWait:1,holdReady:function(a){a?r.readyWait++:r.ready(!0)},ready:function(a){(a===!0?--r.readyWait:r.isReady)||(r.isReady=!0,a!==!0&&--r.readyWait>0||Q.resolveWith(d,[r]))}}),r.ready.then=Q.then;function R(){d.removeEventListener("DOMContentLoaded",R), +a.removeEventListener("load",R),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",R),a.addEventListener("load",R));var S=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)S(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h1,null,!0)},removeData:function(a){return this.each(function(){W.remove(this,a)})}}),r.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=V.get(a,b),c&&(!d||r.isArray(c)?d=V.access(a,b,r.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=r.queue(a,b),d=c.length,e=c.shift(),f=r._queueHooks(a,b),g=function(){r.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return V.get(a,c)||V.access(a,c,{empty:r.Callbacks("once memory").add(function(){V.remove(a,[b+"queue",c])})})}}),r.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length\x20\t\r\n\f]+)/i,ka=/^$|\/(?:java|ecma)script/i,la={option:[1,""],thead:[1,"","
                "],col:[2,"","
                "],tr:[2,"","
                "],td:[3,"","
                "],_default:[0,"",""]};la.optgroup=la.option,la.tbody=la.tfoot=la.colgroup=la.caption=la.thead,la.th=la.td;function ma(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&r.nodeName(a,b)?r.merge([a],c):c}function na(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=ma(l.appendChild(f),"script"),j&&na(g),c){k=0;while(f=g[k++])ka.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var qa=d.documentElement,ra=/^key/,sa=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ta=/^([^.]*)(?:\.(.+)|)/;function ua(){return!0}function va(){return!1}function wa(){try{return d.activeElement}catch(a){}}function xa(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)xa(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=va;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=V.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(qa,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(K)||[""],j=b.length;while(j--)h=ta.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=V.hasData(a)&&V.get(a);if(q&&(i=q.events)){b=(b||"").match(K)||[""],j=b.length;while(j--)if(h=ta.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&V.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(V.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,za=/\s*$/g;function Da(a,b){return r.nodeName(a,"table")&&r.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a:a}function Ea(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function Fa(a){var b=Ba.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Ga(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(V.hasData(a)&&(f=V.access(a),g=V.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;c1&&"string"==typeof q&&!o.checkClone&&Aa.test(q))return a.each(function(e){var f=a.eq(e);s&&(b[0]=q.call(this,e,f.html())),Ia(f,b,c,d)});if(m&&(e=pa(b,a[0].ownerDocument,!1,a,d),f=e.firstChild,1===e.childNodes.length&&(e=f),f||d)){for(h=r.map(ma(e,"script"),Ea),i=h.length;l")},clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=r.contains(a.ownerDocument,a);if(!(o.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||r.isXMLDoc(a)))for(g=ma(h),f=ma(a),d=0,e=f.length;d0&&na(g,!i&&ma(a,"script")),h},cleanData:function(a){for(var b,c,d,e=r.event.special,f=0;void 0!==(c=a[f]);f++)if(T(c)){if(b=c[V.expando]){if(b.events)for(d in b.events)e[d]?r.event.remove(c,d):r.removeEvent(c,d,b.handle);c[V.expando]=void 0}c[W.expando]&&(c[W.expando]=void 0)}}}),r.fn.extend({detach:function(a){return Ja(this,a,!0)},remove:function(a){return Ja(this,a)},text:function(a){return S(this,function(a){return void 0===a?r.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=a)})},null,a,arguments.length)},append:function(){return Ia(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Da(this,a);b.appendChild(a)}})},prepend:function(){return Ia(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Da(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ia(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ia(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(r.cleanData(ma(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null!=a&&a,b=null==b?a:b,this.map(function(){return r.clone(this,a,b)})},html:function(a){return S(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!za.test(a)&&!la[(ja.exec(a)||["",""])[1].toLowerCase()]){a=r.htmlPrefilter(a);try{for(;c1)}});function Ya(a,b,c,d,e){return new Ya.prototype.init(a,b,c,d,e)}r.Tween=Ya,Ya.prototype={constructor:Ya,init:function(a,b,c,d,e,f){this.elem=a,this.prop=c,this.easing=e||r.easing._default,this.options=b,this.start=this.now=this.cur(),this.end=d,this.unit=f||(r.cssNumber[c]?"":"px")},cur:function(){var a=Ya.propHooks[this.prop];return a&&a.get?a.get(this):Ya.propHooks._default.get(this)},run:function(a){var b,c=Ya.propHooks[this.prop];return this.options.duration?this.pos=b=r.easing[this.easing](a,this.options.duration*a,0,1,this.options.duration):this.pos=b=a,this.now=(this.end-this.start)*b+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),c&&c.set?c.set(this):Ya.propHooks._default.set(this),this}},Ya.prototype.init.prototype=Ya.prototype,Ya.propHooks={_default:{get:function(a){var b;return 1!==a.elem.nodeType||null!=a.elem[a.prop]&&null==a.elem.style[a.prop]?a.elem[a.prop]:(b=r.css(a.elem,a.prop,""),b&&"auto"!==b?b:0)},set:function(a){r.fx.step[a.prop]?r.fx.step[a.prop](a):1!==a.elem.nodeType||null==a.elem.style[r.cssProps[a.prop]]&&!r.cssHooks[a.prop]?a.elem[a.prop]=a.now:r.style(a.elem,a.prop,a.now+a.unit)}}},Ya.propHooks.scrollTop=Ya.propHooks.scrollLeft={set:function(a){a.elem.nodeType&&a.elem.parentNode&&(a.elem[a.prop]=a.now)}},r.easing={linear:function(a){return a},swing:function(a){return.5-Math.cos(a*Math.PI)/2},_default:"swing"},r.fx=Ya.prototype.init,r.fx.step={};var Za,$a,_a=/^(?:toggle|show|hide)$/,ab=/queueHooks$/;function bb(){$a&&(a.requestAnimationFrame(bb),r.fx.tick())}function cb(){return a.setTimeout(function(){Za=void 0}),Za=r.now()}function db(a,b){var c,d=0,e={height:a};for(b=b?1:0;d<4;d+=2-b)c=ba[d],e["margin"+c]=e["padding"+c]=a;return b&&(e.opacity=e.width=a),e}function eb(a,b,c){for(var d,e=(hb.tweeners[b]||[]).concat(hb.tweeners["*"]),f=0,g=e.length;f1)},removeAttr:function(a){return this.each(function(){r.removeAttr(this,a)})}}),r.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return"undefined"==typeof a.getAttribute?r.prop(a,b,c):(1===f&&r.isXMLDoc(a)||(e=r.attrHooks[b.toLowerCase()]||(r.expr.match.bool.test(b)?ib:void 0)), +void 0!==c?null===c?void r.removeAttr(a,b):e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:(a.setAttribute(b,c+""),c):e&&"get"in e&&null!==(d=e.get(a,b))?d:(d=r.find.attr(a,b),null==d?void 0:d))},attrHooks:{type:{set:function(a,b){if(!o.radioValue&&"radio"===b&&r.nodeName(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}},removeAttr:function(a,b){var c,d=0,e=b&&b.match(K);if(e&&1===a.nodeType)while(c=e[d++])a.removeAttribute(c)}}),ib={set:function(a,b,c){return b===!1?r.removeAttr(a,c):a.setAttribute(c,c),c}},r.each(r.expr.match.bool.source.match(/\w+/g),function(a,b){var c=jb[b]||r.find.attr;jb[b]=function(a,b,d){var e,f,g=b.toLowerCase();return d||(f=jb[g],jb[g]=e,e=null!=c(a,b,d)?g:null,jb[g]=f),e}});var kb=/^(?:input|select|textarea|button)$/i,lb=/^(?:a|area)$/i;r.fn.extend({prop:function(a,b){return S(this,r.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[r.propFix[a]||a]})}}),r.extend({prop:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return 1===f&&r.isXMLDoc(a)||(b=r.propFix[b]||b,e=r.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){var b=r.find.attr(a,"tabindex");return b?parseInt(b,10):kb.test(a.nodeName)||lb.test(a.nodeName)&&a.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),o.optSelected||(r.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null},set:function(a){var b=a.parentNode;b&&(b.selectedIndex,b.parentNode&&b.parentNode.selectedIndex)}}),r.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){r.propFix[this.toLowerCase()]=this});function mb(a){var b=a.match(K)||[];return b.join(" ")}function nb(a){return a.getAttribute&&a.getAttribute("class")||""}r.fn.extend({addClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).addClass(a.call(this,b,nb(this)))});if("string"==typeof a&&a){b=a.match(K)||[];while(c=this[i++])if(e=nb(c),d=1===c.nodeType&&" "+mb(e)+" "){g=0;while(f=b[g++])d.indexOf(" "+f+" ")<0&&(d+=f+" ");h=mb(d),e!==h&&c.setAttribute("class",h)}}return this},removeClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).removeClass(a.call(this,b,nb(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof a&&a){b=a.match(K)||[];while(c=this[i++])if(e=nb(c),d=1===c.nodeType&&" "+mb(e)+" "){g=0;while(f=b[g++])while(d.indexOf(" "+f+" ")>-1)d=d.replace(" "+f+" "," ");h=mb(d),e!==h&&c.setAttribute("class",h)}}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):r.isFunction(a)?this.each(function(c){r(this).toggleClass(a.call(this,c,nb(this),b),b)}):this.each(function(){var b,d,e,f;if("string"===c){d=0,e=r(this),f=a.match(K)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else void 0!==a&&"boolean"!==c||(b=nb(this),b&&V.set(this,"__className__",b),this.setAttribute&&this.setAttribute("class",b||a===!1?"":V.get(this,"__className__")||""))})},hasClass:function(a){var b,c,d=0;b=" "+a+" ";while(c=this[d++])if(1===c.nodeType&&(" "+mb(nb(c))+" ").indexOf(b)>-1)return!0;return!1}});var ob=/\r/g;r.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=r.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,r(this).val()):a,null==e?e="":"number"==typeof e?e+="":r.isArray(e)&&(e=r.map(e,function(a){return null==a?"":a+""})),b=r.valHooks[this.type]||r.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=r.valHooks[e.type]||r.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(ob,""):null==c?"":c)}}}),r.extend({valHooks:{option:{get:function(a){var b=r.find.attr(a,"value");return null!=b?b:mb(r.text(a))}},select:{get:function(a){var b,c,d,e=a.options,f=a.selectedIndex,g="select-one"===a.type,h=g?null:[],i=g?f+1:e.length;for(d=f<0?i:g?f:0;d-1)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),r.each(["radio","checkbox"],function(){r.valHooks[this]={set:function(a,b){if(r.isArray(b))return a.checked=r.inArray(r(a).val(),b)>-1}},o.checkOn||(r.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})});var pb=/^(?:focusinfocus|focusoutblur)$/;r.extend(r.event,{trigger:function(b,c,e,f){var g,h,i,j,k,m,n,o=[e||d],p=l.call(b,"type")?b.type:b,q=l.call(b,"namespace")?b.namespace.split("."):[];if(h=i=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!pb.test(p+r.event.triggered)&&(p.indexOf(".")>-1&&(q=p.split("."),p=q.shift(),q.sort()),k=p.indexOf(":")<0&&"on"+p,b=b[r.expando]?b:new r.Event(p,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=q.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:r.makeArray(c,[b]),n=r.event.special[p]||{},f||!n.trigger||n.trigger.apply(e,c)!==!1)){if(!f&&!n.noBubble&&!r.isWindow(e)){for(j=n.delegateType||p,pb.test(j+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),i=h;i===(e.ownerDocument||d)&&o.push(i.defaultView||i.parentWindow||a)}g=0;while((h=o[g++])&&!b.isPropagationStopped())b.type=g>1?j:n.bindType||p,m=(V.get(h,"events")||{})[b.type]&&V.get(h,"handle"),m&&m.apply(h,c),m=k&&h[k],m&&m.apply&&T(h)&&(b.result=m.apply(h,c),b.result===!1&&b.preventDefault());return b.type=p,f||b.isDefaultPrevented()||n._default&&n._default.apply(o.pop(),c)!==!1||!T(e)||k&&r.isFunction(e[p])&&!r.isWindow(e)&&(i=e[k],i&&(e[k]=null),r.event.triggered=p,e[p](),r.event.triggered=void 0,i&&(e[k]=i)),b.result}},simulate:function(a,b,c){var d=r.extend(new r.Event,c,{type:a,isSimulated:!0});r.event.trigger(d,null,b)}}),r.fn.extend({trigger:function(a,b){return this.each(function(){r.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];if(c)return r.event.trigger(a,b,c,!0)}}),r.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(a,b){r.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),r.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}}),o.focusin="onfocusin"in a,o.focusin||r.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){r.event.simulate(b,a.target,r.event.fix(a))};r.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=V.access(d,b);e||d.addEventListener(a,c,!0),V.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=V.access(d,b)-1;e?V.access(d,b,e):(d.removeEventListener(a,c,!0),V.remove(d,b))}}});var qb=a.location,rb=r.now(),sb=/\?/;r.parseXML=function(b){var c;if(!b||"string"!=typeof b)return null;try{c=(new a.DOMParser).parseFromString(b,"text/xml")}catch(d){c=void 0}return c&&!c.getElementsByTagName("parsererror").length||r.error("Invalid XML: "+b),c};var tb=/\[\]$/,ub=/\r?\n/g,vb=/^(?:submit|button|image|reset|file)$/i,wb=/^(?:input|select|textarea|keygen)/i;function xb(a,b,c,d){var e;if(r.isArray(b))r.each(b,function(b,e){c||tb.test(a)?d(a,e):xb(a+"["+("object"==typeof e&&null!=e?b:"")+"]",e,c,d)});else if(c||"object"!==r.type(b))d(a,b);else for(e in b)xb(a+"["+e+"]",b[e],c,d)}r.param=function(a,b){var c,d=[],e=function(a,b){var c=r.isFunction(b)?b():b;d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(null==c?"":c)};if(r.isArray(a)||a.jquery&&!r.isPlainObject(a))r.each(a,function(){e(this.name,this.value)});else for(c in a)xb(c,a[c],b,e);return d.join("&")},r.fn.extend({serialize:function(){return r.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=r.prop(this,"elements");return a?r.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!r(this).is(":disabled")&&wb.test(this.nodeName)&&!vb.test(a)&&(this.checked||!ia.test(a))}).map(function(a,b){var c=r(this).val();return null==c?null:r.isArray(c)?r.map(c,function(a){return{name:b.name,value:a.replace(ub,"\r\n")}}):{name:b.name,value:c.replace(ub,"\r\n")}}).get()}});var yb=/%20/g,zb=/#.*$/,Ab=/([?&])_=[^&]*/,Bb=/^(.*?):[ \t]*([^\r\n]*)$/gm,Cb=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Db=/^(?:GET|HEAD)$/,Eb=/^\/\//,Fb={},Gb={},Hb="*/".concat("*"),Ib=d.createElement("a");Ib.href=qb.href;function Jb(a){return function(b,c){"string"!=typeof b&&(c=b,b="*");var d,e=0,f=b.toLowerCase().match(K)||[];if(r.isFunction(c))while(d=f[e++])"+"===d[0]?(d=d.slice(1)||"*",(a[d]=a[d]||[]).unshift(c)):(a[d]=a[d]||[]).push(c)}}function Kb(a,b,c,d){var e={},f=a===Gb;function g(h){var i;return e[h]=!0,r.each(a[h]||[],function(a,h){var j=h(b,c,d);return"string"!=typeof j||f||e[j]?f?!(i=j):void 0:(b.dataTypes.unshift(j),g(j),!1)}),i}return g(b.dataTypes[0])||!e["*"]&&g("*")}function Lb(a,b){var c,d,e=r.ajaxSettings.flatOptions||{};for(c in b)void 0!==b[c]&&((e[c]?a:d||(d={}))[c]=b[c]);return d&&r.extend(!0,a,d),a}function Mb(a,b,c){var d,e,f,g,h=a.contents,i=a.dataTypes;while("*"===i[0])i.shift(),void 0===d&&(d=a.mimeType||b.getResponseHeader("Content-Type"));if(d)for(e in h)if(h[e]&&h[e].test(d)){i.unshift(e);break}if(i[0]in c)f=i[0];else{for(e in c){if(!i[0]||a.converters[e+" "+i[0]]){f=e;break}g||(g=e)}f=f||g}if(f)return f!==i[0]&&i.unshift(f),c[f]}function Nb(a,b,c,d){var e,f,g,h,i,j={},k=a.dataTypes.slice();if(k[1])for(g in a.converters)j[g.toLowerCase()]=a.converters[g];f=k.shift();while(f)if(a.responseFields[f]&&(c[a.responseFields[f]]=b),!i&&d&&a.dataFilter&&(b=a.dataFilter(b,a.dataType)),i=f,f=k.shift())if("*"===f)f=i;else if("*"!==i&&i!==f){if(g=j[i+" "+f]||j["* "+f],!g)for(e in j)if(h=e.split(" "),h[1]===f&&(g=j[i+" "+h[0]]||j["* "+h[0]])){g===!0?g=j[e]:j[e]!==!0&&(f=h[0],k.unshift(h[1]));break}if(g!==!0)if(g&&a["throws"])b=g(b);else try{b=g(b)}catch(l){return{state:"parsererror",error:g?l:"No conversion from "+i+" to "+f}}}return{state:"success",data:b}}r.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:qb.href,type:"GET",isLocal:Cb.test(qb.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Hb,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":r.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(a,b){return b?Lb(Lb(a,r.ajaxSettings),b):Lb(r.ajaxSettings,a)},ajaxPrefilter:Jb(Fb),ajaxTransport:Jb(Gb),ajax:function(b,c){"object"==typeof b&&(c=b,b=void 0),c=c||{};var e,f,g,h,i,j,k,l,m,n,o=r.ajaxSetup({},c),p=o.context||o,q=o.context&&(p.nodeType||p.jquery)?r(p):r.event,s=r.Deferred(),t=r.Callbacks("once memory"),u=o.statusCode||{},v={},w={},x="canceled",y={readyState:0,getResponseHeader:function(a){var b;if(k){if(!h){h={};while(b=Bb.exec(g))h[b[1].toLowerCase()]=b[2]}b=h[a.toLowerCase()]}return null==b?null:b},getAllResponseHeaders:function(){return k?g:null},setRequestHeader:function(a,b){return null==k&&(a=w[a.toLowerCase()]=w[a.toLowerCase()]||a,v[a]=b),this},overrideMimeType:function(a){return null==k&&(o.mimeType=a),this},statusCode:function(a){var b;if(a)if(k)y.always(a[y.status]);else for(b in a)u[b]=[u[b],a[b]];return this},abort:function(a){var b=a||x;return e&&e.abort(b),A(0,b),this}};if(s.promise(y),o.url=((b||o.url||qb.href)+"").replace(Eb,qb.protocol+"//"),o.type=c.method||c.type||o.method||o.type,o.dataTypes=(o.dataType||"*").toLowerCase().match(K)||[""],null==o.crossDomain){j=d.createElement("a");try{j.href=o.url,j.href=j.href,o.crossDomain=Ib.protocol+"//"+Ib.host!=j.protocol+"//"+j.host}catch(z){o.crossDomain=!0}}if(o.data&&o.processData&&"string"!=typeof o.data&&(o.data=r.param(o.data,o.traditional)),Kb(Fb,o,c,y),k)return y;l=r.event&&o.global,l&&0===r.active++&&r.event.trigger("ajaxStart"),o.type=o.type.toUpperCase(),o.hasContent=!Db.test(o.type),f=o.url.replace(zb,""),o.hasContent?o.data&&o.processData&&0===(o.contentType||"").indexOf("application/x-www-form-urlencoded")&&(o.data=o.data.replace(yb,"+")):(n=o.url.slice(f.length),o.data&&(f+=(sb.test(f)?"&":"?")+o.data,delete o.data),o.cache===!1&&(f=f.replace(Ab,"$1"),n=(sb.test(f)?"&":"?")+"_="+rb++ +n),o.url=f+n),o.ifModified&&(r.lastModified[f]&&y.setRequestHeader("If-Modified-Since",r.lastModified[f]),r.etag[f]&&y.setRequestHeader("If-None-Match",r.etag[f])),(o.data&&o.hasContent&&o.contentType!==!1||c.contentType)&&y.setRequestHeader("Content-Type",o.contentType),y.setRequestHeader("Accept",o.dataTypes[0]&&o.accepts[o.dataTypes[0]]?o.accepts[o.dataTypes[0]]+("*"!==o.dataTypes[0]?", "+Hb+"; q=0.01":""):o.accepts["*"]);for(m in o.headers)y.setRequestHeader(m,o.headers[m]);if(o.beforeSend&&(o.beforeSend.call(p,y,o)===!1||k))return y.abort();if(x="abort",t.add(o.complete),y.done(o.success),y.fail(o.error),e=Kb(Gb,o,c,y)){if(y.readyState=1,l&&q.trigger("ajaxSend",[y,o]),k)return y;o.async&&o.timeout>0&&(i=a.setTimeout(function(){y.abort("timeout")},o.timeout));try{k=!1,e.send(v,A)}catch(z){if(k)throw z;A(-1,z)}}else A(-1,"No Transport");function A(b,c,d,h){var j,m,n,v,w,x=c;k||(k=!0,i&&a.clearTimeout(i),e=void 0,g=h||"",y.readyState=b>0?4:0,j=b>=200&&b<300||304===b,d&&(v=Mb(o,y,d)),v=Nb(o,v,y,j),j?(o.ifModified&&(w=y.getResponseHeader("Last-Modified"),w&&(r.lastModified[f]=w),w=y.getResponseHeader("etag"),w&&(r.etag[f]=w)),204===b||"HEAD"===o.type?x="nocontent":304===b?x="notmodified":(x=v.state,m=v.data,n=v.error,j=!n)):(n=x,!b&&x||(x="error",b<0&&(b=0))),y.status=b,y.statusText=(c||x)+"",j?s.resolveWith(p,[m,x,y]):s.rejectWith(p,[y,x,n]),y.statusCode(u),u=void 0,l&&q.trigger(j?"ajaxSuccess":"ajaxError",[y,o,j?m:n]),t.fireWith(p,[y,x]),l&&(q.trigger("ajaxComplete",[y,o]),--r.active||r.event.trigger("ajaxStop")))}return y},getJSON:function(a,b,c){return r.get(a,b,c,"json")},getScript:function(a,b){return r.get(a,void 0,b,"script")}}),r.each(["get","post"],function(a,b){r[b]=function(a,c,d,e){return r.isFunction(c)&&(e=e||d,d=c,c=void 0),r.ajax(r.extend({url:a,type:b,dataType:e,data:c,success:d},r.isPlainObject(a)&&a))}}),r._evalUrl=function(a){return r.ajax({url:a,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,"throws":!0})},r.fn.extend({wrapAll:function(a){var b;return this[0]&&(r.isFunction(a)&&(a=a.call(this[0])),b=r(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this},wrapInner:function(a){return r.isFunction(a)?this.each(function(b){r(this).wrapInner(a.call(this,b))}):this.each(function(){var b=r(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=r.isFunction(a);return this.each(function(c){r(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(a){return this.parent(a).not("body").each(function(){r(this).replaceWith(this.childNodes)}),this}}),r.expr.pseudos.hidden=function(a){return!r.expr.pseudos.visible(a)},r.expr.pseudos.visible=function(a){return!!(a.offsetWidth||a.offsetHeight||a.getClientRects().length)},r.ajaxSettings.xhr=function(){try{return new a.XMLHttpRequest}catch(b){}};var Ob={0:200,1223:204},Pb=r.ajaxSettings.xhr();o.cors=!!Pb&&"withCredentials"in Pb,o.ajax=Pb=!!Pb,r.ajaxTransport(function(b){var c,d;if(o.cors||Pb&&!b.crossDomain)return{send:function(e,f){var g,h=b.xhr();if(h.open(b.type,b.url,b.async,b.username,b.password),b.xhrFields)for(g in b.xhrFields)h[g]=b.xhrFields[g];b.mimeType&&h.overrideMimeType&&h.overrideMimeType(b.mimeType),b.crossDomain||e["X-Requested-With"]||(e["X-Requested-With"]="XMLHttpRequest");for(g in e)h.setRequestHeader(g,e[g]);c=function(a){return function(){c&&(c=d=h.onload=h.onerror=h.onabort=h.onreadystatechange=null,"abort"===a?h.abort():"error"===a?"number"!=typeof h.status?f(0,"error"):f(h.status,h.statusText):f(Ob[h.status]||h.status,h.statusText,"text"!==(h.responseType||"text")||"string"!=typeof h.responseText?{binary:h.response}:{text:h.responseText},h.getAllResponseHeaders()))}},h.onload=c(),d=h.onerror=c("error"),void 0!==h.onabort?h.onabort=d:h.onreadystatechange=function(){4===h.readyState&&a.setTimeout(function(){c&&d()})},c=c("abort");try{h.send(b.hasContent&&b.data||null)}catch(i){if(c)throw i}},abort:function(){c&&c()}}}),r.ajaxPrefilter(function(a){a.crossDomain&&(a.contents.script=!1)}),r.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(a){return r.globalEval(a),a}}}),r.ajaxPrefilter("script",function(a){void 0===a.cache&&(a.cache=!1),a.crossDomain&&(a.type="GET")}),r.ajaxTransport("script",function(a){if(a.crossDomain){var b,c;return{send:function(e,f){b=r(" + + + + + + +{% raw %} + +{% endraw %} diff --git a/multiqc/templates/original/header.html b/multiqc/templates/original/header.html new file mode 100644 index 0000000000..87e8199e20 --- /dev/null +++ b/multiqc/templates/original/header.html @@ -0,0 +1,298 @@ +{# ####################### + header.html +########################## + +This block gives the introduction content at the top of each report. +That is, the page title, welcome message, details of where the report +was generated and the button that launches the welcome tour. + +#} + +

                + {% if config.custom_logo is not none %} +
                + {{ '' if config.custom_logo_url is not none }} + + {{ '' if config.custom_logo_url is not none }} +
                + {% endif %} + + + +

                +{% if config.title is not none or config.subtitle is not none %} +

                + {{ config.title if config.title is not none }} + {{ '
                ' if config.title is not none and config.subtitle is not none }} + {{ ''+config.subtitle+'' if config.subtitle is not none }} +

                +{% endif %} + +{% if config.intro_text != False %} +

                + {{ config.intro_text if config.intro_text is not none else 'A modular tool to aggregate results from bioinformatics analyses across many samples into a single report.' }} +

                +{% endif %} + +{% if config.report_comment and config.report_comment | length > 0 %} +
                {{ config.report_comment }}
                +{% endif %} + +{% if config.report_header_info and config.report_header_info | length > 0 %} +
                +
                + {% for d in config.report_header_info %}{% for k, v in d.items() %} +
                {{ k }}
                +
                {{ v }}
                + {% endfor %}{% endfor %} +
                +
                +{% endif %} + + + + + +{% if config.show_analysis_time or config.show_analysis_paths %} +
                + Report + {% if config.show_analysis_time %} + generated on {{ report.creation_date.strftime('%Y-%m-%d, %H:%M %Z') }} + {% endif %} + {% if config.show_analysis_paths %} + based on data in: + {% if report.analysis_files | length == 1 %} + {{ report.analysis_files[0] }} + {% else %} +
                  + {% for d in report.analysis_files %} +
                • {{ d }}
                • + {% endfor %} +
                + {% endif %} + {% endif %} +
                + {% if not config.no_ai and not report.ai_global_summary %} +
                + +
                + {% endif %} + {% if not config.no_ai %} +
                + +
                + {% endif %} +
                +{% endif %} + +{% if config.sample_names_rename_buttons | length > 0 %} +

                + Change sample names: + + {% for sn_t in config.sample_names_rename_buttons %} + + {%- endfor %} + +

                +{% endif %} + +{% if config.show_hide_buttons | length > 0 %} +

                + Show/Hide samples: + + {% for sn_t in config.show_hide_buttons %} + + {%- endfor %} + +

                +{% endif %} + +
                + + + +{% if report.plot_data | length > 0 and report.some_plots_are_deferred %} +
                + + + Because this report contains a lot of samples, you may need to click 'Show plot' to see some graphs. + +
                +{% endif %} + +
                +
                + Report AI Summary +
                +
                {{ report.ai_global_summary }}
                + + {% if report.ai_global_detailed_analysis %} +
                + {{ report.ai_global_detailed_analysis }} +
                + {% else %} +
                + {{ report.ai_global_detailed_analysis }} +
                + {% endif %} + +
                + {% if not report.ai_global_detailed_analysis %} + + {% endif %} +
                + + Provider: {{ report.ai_provider_title }}, model: + {{ report.ai_model_resolved or report.ai_model }} + + + Chat with Seqera AI + +
                +
                + + +
                + +
                +
                diff --git a/multiqc/templates/geo/includes.html b/multiqc/templates/original/includes.html similarity index 85% rename from multiqc/templates/geo/includes.html rename to multiqc/templates/original/includes.html index eff12ef0b5..e7ac7e08e5 100644 --- a/multiqc/templates/geo/includes.html +++ b/multiqc/templates/original/includes.html @@ -49,10 +49,12 @@ - + + + @@ -64,10 +66,16 @@ + + {% set included_js = [] %} -{%- for m in report.modules %}{% if m.js and m.js|length > 0 -%}{% for js_href in m.js.values() %} -{% if js_href not in included_js -%} -{{ '' if included_js.append( js_href ) }} +{%- for m in report.modules %} + {% if m.js and m.js|length > 0 -%} + {% for js_href in m.js.values() %} + {% if js_href not in included_js -%}{{ '' if included_js.append( js_href ) }} -{% endif %} -{%- endfor %}{% endif %}{% endfor %} + {% endif %} + {%- endfor %} + {% endif %} +{% endfor %} + diff --git a/multiqc/templates/original/nav.html b/multiqc/templates/original/nav.html new file mode 100644 index 0000000000..b8106044b3 --- /dev/null +++ b/multiqc/templates/original/nav.html @@ -0,0 +1,60 @@ +{# ####################### + nav.html +########################## + +The side navigation for the report. + +#} + +
                +
                +

                + + + + + + +

                + {% if config.title is not none %} +

                {{ config.title }}

                + {% endif %} +

                Loading report..

                + +
                + +
                +
                diff --git a/multiqc/templates/original/toolbox.html b/multiqc/templates/original/toolbox.html new file mode 100644 index 0000000000..d468b125a4 --- /dev/null +++ b/multiqc/templates/original/toolbox.html @@ -0,0 +1,503 @@ +{# ####################### + toolbox.html +########################## + +This block codes the markup used by the toolbar on the right hand side +of the report. + +#} + +
                + + + +
                + + + + +
                +

                + + Highlight Samples +

                + {% if report.num_flat_plots > 0 %} +

                + + This report has flat image plots that won't be highlighted.
                + See the + documentation + for help. +

                + {% endif %} +
                + + + +
                +

                + Regex mode off + + +

                +
                  +
                  + + +
                  +

                  + + Rename Samples +

                  + {% if report.num_flat_plots > 0 %} +

                  + + This report has flat image plots that won't be renamed.
                  + See the + documentation + for help. +

                  + {% endif %} +
                  + + + +
                  +

                  Click here for bulk input.

                  +
                  +

                  Paste two columns of a tab-delimited table here (eg. from Excel).

                  +

                  First column should be the old name, second column the new name.

                  +
                  + + +
                  +
                  +

                  + Regex mode off + + +

                  +
                    +
                    + + +
                    +

                    + + Show / Hide Samples +

                    + {% if report.num_flat_plots > 0 %} +

                    + + This report has flat image plots that won't be hidden.
                    + See the + documentation + for help. +

                    + {% endif %} +
                    +
                    + +
                    +
                    + +
                    +
                    + + +
                    +
                    + {% if report.general_stats_data | length > 10 %}

                    Warning! This can take a few seconds.

                    {% endif %} +

                    + Regex mode off + + +

                    +
                      +
                      + + +
                      +

                      Explain with AI

                      + +

                      Configure AI settings to get explanations of plots and data in this report.

                      + +
                      +
                      + + + +

                      +
                      + + + +
                      + + +

                      +
                      + +
                      + + +

                      +

                      + Keys entered here will be stored in your browser's local storage. See + the docs. +

                      +
                      + + + + +
                      + +
                      + +

                      + Anonymize samples off +

                      +
                      + + +
                      +

                      Export Plots

                      +
                      + +
                      +
                      +
                      +
                      +
                      + + px +
                      +
                      +
                      +
                      + + px +
                      +
                      +
                      +
                      +
                      + +
                      +
                      + +
                      +
                      +
                      +
                      + +
                      +
                      +
                      + + X +
                      +
                      +
                      +
                      + +
                      +

                      Download the raw data used to create the plots in this report below:

                      +
                      +
                      + +
                      +
                      + +
                      +
                      + {% if config.make_data_dir %} +

                      + Note that additional data was saved in {{ config.data_dir_name }} when this report was + generated. +

                      + {% endif %} +
                      +
                      +
                      + +
                      +
                      Choose Plots
                      + + +
                      + +
                      + +

                      If you use plots from MultiQC in a publication or presentation, please cite:

                      +
                      + MultiQC: Summarize analysis results for multiple tools and samples in a single report
                      + Philip Ewels, Måns Magnusson, Sverker Lundin and Max Käller
                      + Bioinformatics (2016)
                      + doi: + 10.1093/bioinformatics/btw354
                      + PMID: 27312411 +
                      +
                      +
                      + + +
                      +
                      + + Settings are automatically saved. You can also save named configurations below. +
                      +

                      Save Settings

                      +

                      You can save the toolbox settings for this report to the browser or as a file.

                      +
                      + + + +
                      +
                      + +

                      Load Settings

                      +

                      Choose a saved report profile from the browser or load from a file:

                      +
                      +
                      + +
                      +
                      + + + + +
                      +
                      +   Load from File + +
                      +
                      +
                      + + +
                      +

                      Tool Citations

                      +

                      Please remember to cite the tools that you use in your analysis.

                      +

                      To help with this, you can download publication details of the tools mentioned in this report:

                      +

                      + +

                      +

                      + +

                      +
                      + + +
                      +

                      About MultiQC

                      +

                      This report was generated using MultiQC, version {{ config.version }}

                      +

                      + You can see a YouTube video describing how to use MultiQC reports here: + https://youtu.be/qPbIlO_KWN0 +

                      +

                      + For more information about MultiQC, including other videos and extensive documentation, please visit + http://multiqc.info +

                      +

                      + You can report bugs, suggest improvements and find the source code for MultiQC on GitHub: + https://github.com/MultiQC/MultiQC +

                      +

                      MultiQC is published in Bioinformatics:

                      +
                      + MultiQC: Summarize analysis results for multiple tools and samples in a single report
                      + Philip Ewels, Måns Magnusson, Sverker Lundin and Max Käller
                      + Bioinformatics (2016)
                      + doi: 10.1093/bioinformatics/btw354
                      + PMID: 27312411 +
                      +
                      + +
                      +
                      + +
                      diff --git a/multiqc/templates/sections/__init__.py b/multiqc/templates/sections/__init__.py index e511005663..2663e46faf 100644 --- a/multiqc/templates/sections/__init__.py +++ b/multiqc/templates/sections/__init__.py @@ -20,7 +20,7 @@ from multiqc import config -template_parent = "default" +template_parent = "original" template_dir = os.path.dirname(__file__) base_fn = "base.html" diff --git a/multiqc/templates/sections/content.html b/multiqc/templates/sections/content.html index 2277eeea7e..a717c9b770 100644 --- a/multiqc/templates/sections/content.html +++ b/multiqc/templates/sections/content.html @@ -10,47 +10,51 @@ #} {% for m in report.modules %} - + {% endfor %} -
                      +
                      Click links in the side-navigation to view other sections of the report.
                      diff --git a/multiqc/templates/simple/__init__.py b/multiqc/templates/simple/__init__.py index 75c0283acb..27b031fa09 100644 --- a/multiqc/templates/simple/__init__.py +++ b/multiqc/templates/simple/__init__.py @@ -14,7 +14,7 @@ from multiqc import config -template_parent = "default" +template_parent = "original" config.plots_force_flat = True config.simple_output = True diff --git a/multiqc/templates/simple/foot.html b/multiqc/templates/simple/foot.html index 5bb600c810..51b4f9edae 100644 --- a/multiqc/templates/simple/foot.html +++ b/multiqc/templates/simple/foot.html @@ -4,4 +4,4 @@ Leaving this file blank to omit from the report. -#} \ No newline at end of file +#} diff --git a/multiqc/templates/simple/nav.html b/multiqc/templates/simple/nav.html index bb02929a4a..364d8e3b9b 100644 --- a/multiqc/templates/simple/nav.html +++ b/multiqc/templates/simple/nav.html @@ -4,4 +4,4 @@ Leaving this file blank to omit the side navigation. -#} \ No newline at end of file +#} diff --git a/multiqc/templates/simple/toolbox.html b/multiqc/templates/simple/toolbox.html index a7df9ab751..ee7bd750ec 100644 --- a/multiqc/templates/simple/toolbox.html +++ b/multiqc/templates/simple/toolbox.html @@ -4,4 +4,4 @@ Leaving this blank so that we don't have a toolbox in this template. -#} \ No newline at end of file +#} diff --git a/multiqc/types.py b/multiqc/types.py index c9e2603a0b..0a024e93aa 100644 --- a/multiqc/types.py +++ b/multiqc/types.py @@ -114,3 +114,4 @@ class Section(BaseModel): print_section: bool = True plot_anchor: Optional[Anchor] = None ai_summary: str = "" + status_bar_html: str = "" diff --git a/multiqc/utils/__init__.py b/multiqc/utils/__init__.py new file mode 100644 index 0000000000..f9abd8effc --- /dev/null +++ b/multiqc/utils/__init__.py @@ -0,0 +1 @@ +"""MultiQC utilities.""" diff --git a/multiqc/utils/config_schema.json b/multiqc/utils/config_schema.json index 0d46acdd99..bba8841605 100644 --- a/multiqc/utils/config_schema.json +++ b/multiqc/utils/config_schema.json @@ -516,6 +516,19 @@ "description": "Path to custom logo image", "title": "Custom Logo" }, + "custom_logo_dark": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Path to custom logo image for dark mode", + "title": "Custom Logo Dark" + }, "custom_logo_url": { "anyOf": [ { @@ -542,6 +555,19 @@ "description": "Title for custom logo", "title": "Custom Logo Title" }, + "custom_logo_width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Width in pixels for the custom logo", + "title": "Custom Logo Width" + }, "custom_css_files": { "anyOf": [ { @@ -584,6 +610,32 @@ "description": "Report template to use", "title": "Template" }, + "template_dark_mode": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Enable dark mode toggle and JavaScript for the report template", + "title": "Template Dark Mode" + }, + "plot_font_family": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Custom font family for plots (defaults to system font stack)", + "title": "Plot Font Family" + }, "profile_runtime": { "anyOf": [ { @@ -1036,7 +1088,7 @@ } ], "default": null, - "description": "Export plots timeout", + "description": "Timeout in seconds for exporting each plot to a static image (default: 60)", "title": "Export Plots Timeout" }, "make_report": { @@ -1378,19 +1430,6 @@ "description": "Number of series to defer loading - user will need to press button to render plot", "title": "Plots Defer Loading Numseries" }, - "plot_theme": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Plotly theme template - any registered Plotly theme name (e.g. 'plotly', 'plotly_white', 'plotly_dark', 'ggplot2', 'seaborn', 'simple_white', 'none')", - "title": "Plot Theme" - }, "lineplot_number_of_points_to_hide_markers": { "anyOf": [ { @@ -1545,6 +1584,19 @@ "title": "General Stats Columns", "type": "object" }, + "general_stats_helptext": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Help text for general statistics table.", + "title": "General Stats Helptext" + }, "table_columns_visible": { "anyOf": [ { @@ -1770,6 +1822,7 @@ "custom_plot_config": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -1783,6 +1836,7 @@ "custom_table_header_config": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -1796,6 +1850,7 @@ "software_versions": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -2038,7 +2093,17 @@ "anyOf": [ { "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] }, "type": "array" }, @@ -2054,7 +2119,14 @@ "anyOf": [ { "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] }, "type": "array" }, @@ -2282,6 +2354,7 @@ "custom_content": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { diff --git a/multiqc/utils/config_schema.py b/multiqc/utils/config_schema.py index ba806695ce..b13d5fff66 100644 --- a/multiqc/utils/config_schema.py +++ b/multiqc/utils/config_schema.py @@ -77,11 +77,19 @@ class MultiQCConfig(BaseModel): show_analysis_paths: Optional[bool] = Field(None, description="Show analysis paths in the report") show_analysis_time: Optional[bool] = Field(None, description="Show analysis time in the report") custom_logo: Optional[str] = Field(None, description="Path to custom logo image") + custom_logo_dark: Optional[str] = Field(None, description="Path to custom logo image for dark mode") custom_logo_url: Optional[str] = Field(None, description="URL for custom logo") custom_logo_title: Optional[str] = Field(None, description="Title for custom logo") + custom_logo_width: Optional[int] = Field(None, description="Width in pixels for the custom logo") custom_css_files: Optional[List[str]] = Field(None, description="Custom CSS files to include") simple_output: Optional[bool] = Field(None, description="Simple output") template: Optional[str] = Field(None, description="Report template to use") + template_dark_mode: Optional[bool] = Field( + None, description="Enable dark mode toggle and JavaScript for the report template" + ) + plot_font_family: Optional[str] = Field( + None, description="Custom font family for plots (defaults to system font stack)" + ) profile_runtime: Optional[bool] = Field(None, description="Profile runtime") profile_memory: Optional[bool] = Field(None, description="Profile memory") pandoc_template: Optional[str] = Field(None, description="Pandoc template") @@ -117,7 +125,7 @@ class MultiQCConfig(BaseModel): megaqc_access_token: Optional[str] = Field(None, description="MegaQC access token") megaqc_timeout: Optional[int] = Field(None, description="MegaQC timeout") export_plots: Optional[bool] = Field(None, description="Export plots") - export_plots_timeout: Optional[int] = Field(None, description="Export plots timeout") + export_plots_timeout: Optional[int] = Field(None, description="Timeout for exporting each plot, in seconds") make_report: Optional[bool] = Field(None, description="Make report") make_pdf: Optional[bool] = Field(None, description="Make PDF") @@ -159,11 +167,6 @@ class MultiQCConfig(BaseModel): plots_defer_loading_numseries: Optional[int] = Field( None, description="Number of series to defer loading - user will need to press button to render plot" ) - plot_theme: Optional[str] = Field( - None, - description="Plotly theme template - any registered Plotly theme name " - "(e.g. 'plotly', 'plotly_white', 'plotly_dark', 'ggplot2', 'seaborn', 'simple_white', 'none')", - ) lineplot_number_of_points_to_hide_markers: Optional[int] = Field( None, description="Number of points to hide markers - sum of data points in all samples" ) @@ -195,6 +198,7 @@ class MultiQCConfig(BaseModel): general_stats_columns: Dict[str, GeneralStatsModuleConfig] = Field( default_factory=dict, description="Configuration for general stats columns per module. Keys are module IDs." ) + general_stats_helptext: Optional[str] = Field(None, description="Help text for general statistics table.") table_columns_visible: Optional[Dict[str, Union[bool, Dict[str, bool]]]] = Field( None, description="Which columns to show in tables" ) @@ -235,8 +239,8 @@ class MultiQCConfig(BaseModel): sample_names_replace_complete: Optional[bool] = Field(None, description="Sample names to replace (complete)") sample_names_rename: Optional[List[List[str]]] = Field(None, description="Sample names to rename") show_hide_buttons: Optional[List[str]] = Field(None, description="Show/hide buttons") - show_hide_patterns: Optional[List[str]] = Field(None, description="Show/hide patterns") - show_hide_regex: Optional[List[str]] = Field(None, description="Show/hide regex") + show_hide_patterns: Optional[List[Union[str, List[str]]]] = Field(None, description="Show/hide patterns") + show_hide_regex: Optional[List[Union[str, bool]]] = Field(None, description="Show/hide regex") show_hide_mode: Optional[List[str]] = Field(None, description="Show/hide mode") highlight_patterns: Optional[List[str]] = Field(None, description="Patterns for highlighting samples") highlight_colors: Optional[List[str]] = Field(None, description="Colors to use for highlighting patterns") diff --git a/multiqc/utils/material_icons.py b/multiqc/utils/material_icons.py new file mode 100644 index 0000000000..523ccffe3c --- /dev/null +++ b/multiqc/utils/material_icons.py @@ -0,0 +1,112 @@ +""" +Material Design Icons utility module for MultiQC. + +This module provides functionality to load and use Material Design Icons +across all MultiQC templates and Python code using the Iconify naming scheme. +""" + +import logging +from typing import Optional, Dict +from pathlib import Path +import re + +logger = logging.getLogger(__name__) + +# Cache for loaded SVG content +_svg_cache: Dict[str, str] = {} + + +def get_material_icon_path() -> Path: + """Get the path to the Material Design Icons directory.""" + return Path(__file__).parent / "material_icons" + + +def load_svg_content(icon_name: str) -> Optional[str]: + """ + Load SVG content for a given Material Design Icon. + + Args: + icon_name: Name of the icon in mdi:name format (e.g., 'mdi:information', 'mdi:alert') + + Returns: + SVG content as string, or None if not found + """ + if icon_name in _svg_cache: + return _svg_cache[icon_name] + + # Convert mdi:name to mdi-name for file path + file_name = icon_name.replace(":", "-") + icon_path = get_material_icon_path() / f"{file_name}.svg" + + if not icon_path.exists(): + logger.warning(f"Material Design Icon not found: {icon_name} (file: {file_name}.svg)") + return None + + try: + with open(icon_path, "r", encoding="utf-8") as f: + svg_content = f.read() + _svg_cache[icon_name] = svg_content + return svg_content + except Exception as e: + logger.error(f"Failed to load Material Design Icon '{icon_name}': {e}") + return None + + +def get_material_icon( + icon_name: str, size: int = 24, color: Optional[str] = None, class_name: Optional[str] = None +) -> str: + """ + Get a Material Design Icon as HTML SVG. + + Args: + icon_name: Name of the icon in mdi:name format (e.g., 'mdi:information', 'mdi:alert') + size: Size in pixels (default: 24) + color: CSS color value (default: currentColor) + class_name: Additional CSS class names + + Returns: + HTML SVG string + """ + svg_content = load_svg_content(icon_name) + + if svg_content is None: + # Fallback to a basic icon if the requested icon is not found + return f'[{icon_name}]' + + # Parse and modify the SVG + # Replace width/height attributes + svg_content = re.sub(r'width="[^"]*"', f'width="{size}"', svg_content) + svg_content = re.sub(r'height="[^"]*"', f'height="{size}"', svg_content) + + # Add color and class attributes + style_attrs = [] + if color: + style_attrs.append(f'fill="{color}"') + if class_name: + # Add class attribute after the opening str: + """ + Get a Material Design Icon as JavaScript string for embedding in JS files. + + Args: + icon_name: Name of the icon in mdi:name format + size: Size in pixels (default: 24) + color: CSS color value (default: currentColor) + + Returns: + JavaScript string literal with SVG content + """ + svg_content = get_material_icon(icon_name, size, color) + # Escape for JavaScript + js_content = svg_content.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + return f'"{js_content}"' diff --git a/multiqc/utils/material_icons/LICENSE b/multiqc/utils/material_icons/LICENSE new file mode 100644 index 0000000000..1c3016c337 --- /dev/null +++ b/multiqc/utils/material_icons/LICENSE @@ -0,0 +1,6 @@ +Material Design Icons + +These icons are from the Material Design Icons collection. +Source: https://materialdesignicons.com/ +License: Apache License 2.0 or SIL Open Font License 1.1 +Downloaded via Iconify API: https://api.iconify.design/ diff --git a/multiqc/utils/material_icons/manifest.json b/multiqc/utils/material_icons/manifest.json new file mode 100644 index 0000000000..ee3814213a --- /dev/null +++ b/multiqc/utils/material_icons/manifest.json @@ -0,0 +1,6 @@ +{ + "source": "@material-design-icons/svg", + "copied_icons": 54, + "missing_icons": ["copy"], + "total_required": 55 +} diff --git a/multiqc/utils/material_icons/mdi-alert-circle-outline.svg b/multiqc/utils/material_icons/mdi-alert-circle-outline.svg new file mode 100644 index 0000000000..f578323df4 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-alert-circle-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-alert-circle.svg b/multiqc/utils/material_icons/mdi-alert-circle.svg new file mode 100644 index 0000000000..12d5a81edf --- /dev/null +++ b/multiqc/utils/material_icons/mdi-alert-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-alert.svg b/multiqc/utils/material_icons/mdi-alert.svg new file mode 100644 index 0000000000..d2ed08b25d --- /dev/null +++ b/multiqc/utils/material_icons/mdi-alert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-arrow-left.svg b/multiqc/utils/material_icons/mdi-arrow-left.svg new file mode 100644 index 0000000000..2f4e7aba5a --- /dev/null +++ b/multiqc/utils/material_icons/mdi-arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-arrow-right.svg b/multiqc/utils/material_icons/mdi-arrow-right.svg new file mode 100644 index 0000000000..309a69e656 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-book-open-page-variant.svg b/multiqc/utils/material_icons/mdi-book-open-page-variant.svg new file mode 100644 index 0000000000..8727f34b1a --- /dev/null +++ b/multiqc/utils/material_icons/mdi-book-open-page-variant.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-bookmark.svg b/multiqc/utils/material_icons/mdi-bookmark.svg new file mode 100644 index 0000000000..d2dd2d73ee --- /dev/null +++ b/multiqc/utils/material_icons/mdi-bookmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-cancel.svg b/multiqc/utils/material_icons/mdi-cancel.svg new file mode 100644 index 0000000000..cbf250f5a0 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-cancel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-chart-scatter-plot.svg b/multiqc/utils/material_icons/mdi-chart-scatter-plot.svg new file mode 100644 index 0000000000..f6ed61d16c --- /dev/null +++ b/multiqc/utils/material_icons/mdi-chart-scatter-plot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-check-circle.svg b/multiqc/utils/material_icons/mdi-check-circle.svg new file mode 100644 index 0000000000..bb0c2dd1fc --- /dev/null +++ b/multiqc/utils/material_icons/mdi-check-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-check.svg b/multiqc/utils/material_icons/mdi-check.svg new file mode 100644 index 0000000000..57983fc8e0 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-checkbox-blank-outline.svg b/multiqc/utils/material_icons/mdi-checkbox-blank-outline.svg new file mode 100644 index 0000000000..5e1e01a2c8 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-checkbox-blank-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-checkbox-marked.svg b/multiqc/utils/material_icons/mdi-checkbox-marked.svg new file mode 100644 index 0000000000..5cccefbee3 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-checkbox-marked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-chevron-down.svg b/multiqc/utils/material_icons/mdi-chevron-down.svg new file mode 100644 index 0000000000..b96e4cdb87 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-chevron-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-chevron-left.svg b/multiqc/utils/material_icons/mdi-chevron-left.svg new file mode 100644 index 0000000000..1a75a7de08 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-chevron-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-chevron-right.svg b/multiqc/utils/material_icons/mdi-chevron-right.svg new file mode 100644 index 0000000000..2500b97381 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-chevron-up.svg b/multiqc/utils/material_icons/mdi-chevron-up.svg new file mode 100644 index 0000000000..23404d934d --- /dev/null +++ b/multiqc/utils/material_icons/mdi-chevron-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-circle-half-full.svg b/multiqc/utils/material_icons/mdi-circle-half-full.svg new file mode 100644 index 0000000000..6f5c390997 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-circle-half-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-clock.svg b/multiqc/utils/material_icons/mdi-clock.svg new file mode 100644 index 0000000000..6d14374fe6 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-close.svg b/multiqc/utils/material_icons/mdi-close.svg new file mode 100644 index 0000000000..0e17498594 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-cog.svg b/multiqc/utils/material_icons/mdi-cog.svg new file mode 100644 index 0000000000..5202f4aad2 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-cog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-content-copy.svg b/multiqc/utils/material_icons/mdi-content-copy.svg new file mode 100644 index 0000000000..7b1adcf88a --- /dev/null +++ b/multiqc/utils/material_icons/mdi-content-copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-content-save-outline.svg b/multiqc/utils/material_icons/mdi-content-save-outline.svg new file mode 100644 index 0000000000..da849d9ac1 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-content-save-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-content-save.svg b/multiqc/utils/material_icons/mdi-content-save.svg new file mode 100644 index 0000000000..6a1f4d1159 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-content-save.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-delete.svg b/multiqc/utils/material_icons/mdi-delete.svg new file mode 100644 index 0000000000..6f8dc27903 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-download.svg b/multiqc/utils/material_icons/mdi-download.svg new file mode 100644 index 0000000000..57b7cc7875 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-equalizer.svg b/multiqc/utils/material_icons/mdi-equalizer.svg new file mode 100644 index 0000000000..cd35213c2b --- /dev/null +++ b/multiqc/utils/material_icons/mdi-equalizer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-eye-off.svg b/multiqc/utils/material_icons/mdi-eye-off.svg new file mode 100644 index 0000000000..6b03e5dfa0 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-eye-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-eye.svg b/multiqc/utils/material_icons/mdi-eye.svg new file mode 100644 index 0000000000..5b2471225b --- /dev/null +++ b/multiqc/utils/material_icons/mdi-eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-file-document.svg b/multiqc/utils/material_icons/mdi-file-document.svg new file mode 100644 index 0000000000..7044c98874 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-file-document.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-file.svg b/multiqc/utils/material_icons/mdi-file.svg new file mode 100644 index 0000000000..3f54175c59 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-filter.svg b/multiqc/utils/material_icons/mdi-filter.svg new file mode 100644 index 0000000000..794ad438e2 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-folder-open.svg b/multiqc/utils/material_icons/mdi-folder-open.svg new file mode 100644 index 0000000000..60ecc36bf5 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-folder-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-folder.svg b/multiqc/utils/material_icons/mdi-folder.svg new file mode 100644 index 0000000000..c0c988b198 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-format-align-left.svg b/multiqc/utils/material_icons/mdi-format-align-left.svg new file mode 100644 index 0000000000..a2030423b6 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-format-align-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-format-bold.svg b/multiqc/utils/material_icons/mdi-format-bold.svg new file mode 100644 index 0000000000..a22e3d1b95 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-format-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-format-text.svg b/multiqc/utils/material_icons/mdi-format-text.svg new file mode 100644 index 0000000000..4b0a5bcc6a --- /dev/null +++ b/multiqc/utils/material_icons/mdi-format-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-github.svg b/multiqc/utils/material_icons/mdi-github.svg new file mode 100644 index 0000000000..fa5e17a59a --- /dev/null +++ b/multiqc/utils/material_icons/mdi-github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-hand-pointing-up.svg b/multiqc/utils/material_icons/mdi-hand-pointing-up.svg new file mode 100644 index 0000000000..29408c7a00 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-hand-pointing-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-heart.svg b/multiqc/utils/material_icons/mdi-heart.svg new file mode 100644 index 0000000000..9484c53af7 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-help-circle.svg b/multiqc/utils/material_icons/mdi-help-circle.svg new file mode 100644 index 0000000000..058ef5e14a --- /dev/null +++ b/multiqc/utils/material_icons/mdi-help-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-home.svg b/multiqc/utils/material_icons/mdi-home.svg new file mode 100644 index 0000000000..f56b9ca750 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-image.svg b/multiqc/utils/material_icons/mdi-image.svg new file mode 100644 index 0000000000..1200c74a8e --- /dev/null +++ b/multiqc/utils/material_icons/mdi-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-information.svg b/multiqc/utils/material_icons/mdi-information.svg new file mode 100644 index 0000000000..12e67ccf2d --- /dev/null +++ b/multiqc/utils/material_icons/mdi-information.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-link.svg b/multiqc/utils/material_icons/mdi-link.svg new file mode 100644 index 0000000000..b853ff6104 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-magnify-minus.svg b/multiqc/utils/material_icons/mdi-magnify-minus.svg new file mode 100644 index 0000000000..6ac280ee93 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-magnify-minus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-magnify-plus.svg b/multiqc/utils/material_icons/mdi-magnify-plus.svg new file mode 100644 index 0000000000..10a7b33356 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-magnify-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-magnify.svg b/multiqc/utils/material_icons/mdi-magnify.svg new file mode 100644 index 0000000000..516e7fc813 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-magnify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-menu.svg b/multiqc/utils/material_icons/mdi-menu.svg new file mode 100644 index 0000000000..cb4954fb8a --- /dev/null +++ b/multiqc/utils/material_icons/mdi-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-minus.svg b/multiqc/utils/material_icons/mdi-minus.svg new file mode 100644 index 0000000000..6d6b910f8c --- /dev/null +++ b/multiqc/utils/material_icons/mdi-minus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-pencil.svg b/multiqc/utils/material_icons/mdi-pencil.svg new file mode 100644 index 0000000000..c91db91730 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-pencil.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-pin.svg b/multiqc/utils/material_icons/mdi-pin.svg new file mode 100644 index 0000000000..3321a11cd0 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-plus.svg b/multiqc/utils/material_icons/mdi-plus.svg new file mode 100644 index 0000000000..5e9305fda7 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-printer.svg b/multiqc/utils/material_icons/mdi-printer.svg new file mode 100644 index 0000000000..b46553d25d --- /dev/null +++ b/multiqc/utils/material_icons/mdi-printer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-qrcode.svg b/multiqc/utils/material_icons/mdi-qrcode.svg new file mode 100644 index 0000000000..126cf6b667 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-qrcode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-radiobox-blank.svg b/multiqc/utils/material_icons/mdi-radiobox-blank.svg new file mode 100644 index 0000000000..8e40be5b30 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-radiobox-blank.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-radiobox-marked.svg b/multiqc/utils/material_icons/mdi-radiobox-marked.svg new file mode 100644 index 0000000000..76cb699acd --- /dev/null +++ b/multiqc/utils/material_icons/mdi-radiobox-marked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-refresh.svg b/multiqc/utils/material_icons/mdi-refresh.svg new file mode 100644 index 0000000000..939a1591ae --- /dev/null +++ b/multiqc/utils/material_icons/mdi-refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-school.svg b/multiqc/utils/material_icons/mdi-school.svg new file mode 100644 index 0000000000..4afd8acc03 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-school.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-share.svg b/multiqc/utils/material_icons/mdi-share.svg new file mode 100644 index 0000000000..5aeee2a8ce --- /dev/null +++ b/multiqc/utils/material_icons/mdi-share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-sort.svg b/multiqc/utils/material_icons/mdi-sort.svg new file mode 100644 index 0000000000..8ae602ffa0 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-sort.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-star.svg b/multiqc/utils/material_icons/mdi-star.svg new file mode 100644 index 0000000000..07370e9a58 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-table.svg b/multiqc/utils/material_icons/mdi-table.svg new file mode 100644 index 0000000000..85ac62fdc9 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-thumb-down.svg b/multiqc/utils/material_icons/mdi-thumb-down.svg new file mode 100644 index 0000000000..c9ea8a5da7 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-thumb-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-thumb-up.svg b/multiqc/utils/material_icons/mdi-thumb-up.svg new file mode 100644 index 0000000000..0045913742 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-thumb-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-view-column.svg b/multiqc/utils/material_icons/mdi-view-column.svg new file mode 100644 index 0000000000..d1cca52dd5 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-view-column.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-view-dashboard.svg b/multiqc/utils/material_icons/mdi-view-dashboard.svg new file mode 100644 index 0000000000..5069a8caab --- /dev/null +++ b/multiqc/utils/material_icons/mdi-view-dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-violin.svg b/multiqc/utils/material_icons/mdi-violin.svg new file mode 100644 index 0000000000..85afcba674 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-violin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-warning.svg b/multiqc/utils/material_icons/mdi-warning.svg new file mode 100644 index 0000000000..d2ed08b25d --- /dev/null +++ b/multiqc/utils/material_icons/mdi-warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-weather-night.svg b/multiqc/utils/material_icons/mdi-weather-night.svg new file mode 100644 index 0000000000..de3d034b45 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-weather-night.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-weather-sunny.svg b/multiqc/utils/material_icons/mdi-weather-sunny.svg new file mode 100644 index 0000000000..20a42337b0 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-weather-sunny.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/multiqc/utils/material_icons/mdi-youtube.svg b/multiqc/utils/material_icons/mdi-youtube.svg new file mode 100644 index 0000000000..12861c1623 --- /dev/null +++ b/multiqc/utils/material_icons/mdi-youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ee3a0cfe98..2463dae6ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "multiqc" -version = "1.31" +version = "1.34dev" dependencies = [ "boto3", # for aws bedrock ai support "click", @@ -32,13 +32,12 @@ dependencies = [ "jsonschema", "polars-lts-cpu", # for parquet support. Using LTS version for compatibility with older architectures "pyarrow", # for parquet support - "scanpy", # to parse h5 files for Xenium module - "scipy", # for Xenium module. Though Scanpy depends on scipy anyway. ] -requires-python = ">=3.8" +# Avoid cPython bug in 3.14.1 - https://github.com/python/cpython/issues/142214 +requires-python = ">=3.8, !=3.14.1" authors = [ { name = "Phil Ewels", email = "phil.ewels@seqera.io" }, - { name = "Vlad Savelyev", email = "vladislav.savelyev@seqera.io" }, + { name = "Vlad Savelyev", email = "vladislav.sav@gmail.com" }, ] description = "Create aggregate bioinformatics analysis reports across many samples and tools" readme = "README.md" @@ -227,6 +226,7 @@ qorts = "multiqc.modules.qorts:MultiqcModule" qualimap = "multiqc.modules.qualimap:MultiqcModule" quast = "multiqc.modules.quast:MultiqcModule" rna_seqc = "multiqc.modules.rna_seqc:MultiqcModule" +ribotish = "multiqc.modules.ribotish:MultiqcModule" rockhopper = "multiqc.modules.rockhopper:MultiqcModule" rsem = "multiqc.modules.rsem:MultiqcModule" rseqc = "multiqc.modules.rseqc:MultiqcModule" @@ -237,6 +237,7 @@ samtools = "multiqc.modules.samtools:MultiqcModule" sargasso = "multiqc.modules.sargasso:MultiqcModule" seqera_cli = "multiqc.modules.seqera_cli:MultiqcModule" seqfu = "multiqc.modules.seqfu:MultiqcModule" +seqkit = "multiqc.modules.seqkit:MultiqcModule" sequali = "multiqc.modules.sequali:MultiqcModule" seqwho = "multiqc.modules.seqwho:MultiqcModule" seqyclean = "multiqc.modules.seqyclean:MultiqcModule" @@ -255,6 +256,7 @@ spaceranger = "multiqc.modules.spaceranger:MultiqcModule" stacks = "multiqc.modules.stacks:MultiqcModule" star = "multiqc.modules.star:MultiqcModule" supernova = "multiqc.modules.supernova:MultiqcModule" +sylphtax = "multiqc.modules.sylphtax:MultiqcModule" telseq = "multiqc.modules.telseq:MultiqcModule" theta2 = "multiqc.modules.theta2:MultiqcModule" tophat = "multiqc.modules.tophat:MultiqcModule" @@ -274,10 +276,12 @@ xenome = "multiqc.modules.xenome:MultiqcModule" [project.entry-points."multiqc.templates.v1"] default = "multiqc.templates.default" +original = "multiqc.templates.original" sections = "multiqc.templates.sections" simple = "multiqc.templates.simple" gathered = "multiqc.templates.gathered" geo = "multiqc.templates.geo" +disco = "multiqc.templates.disco" ### See https://docs.seqera.io/multiqc/development/plugins for documentation #[project.entry-points."multiqc.cli_options.v1"] diff --git a/scripts/build-assets.sh b/scripts/build-assets.sh new file mode 100755 index 0000000000..d6302cfa52 --- /dev/null +++ b/scripts/build-assets.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Pre-commit hook to build assets with Vite when source files are modified + +# Check if any source files were passed as arguments (pre-commit filters by the files pattern) +if [ $# -gt 0 ]; then + echo "Source files modified: $*" + echo "Building assets with Vite..." + + # Change to the default template directory + cd multiqc/templates/default + + # Check if node_modules exists + if [ ! -d "node_modules" ]; then + echo "Installing npm dependencies..." + npm install + fi + + # Build with Vite + npm run build + + echo "Build complete." +else + echo "No relevant source files modified." +fi diff --git a/scripts/copy_material_icons.py b/scripts/copy_material_icons.py new file mode 100644 index 0000000000..9510d48ae8 --- /dev/null +++ b/scripts/copy_material_icons.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Fetch Material Design Icons SVG files from Iconify API. + +This script fetches required SVG files from the Iconify API using the mdi (Material Design Icons) +icon set and saves them to the MultiQC source directory so they are bundled with the PyPI installation. +""" + +import requests +from pathlib import Path +import logging +from typing import List + +# Set up logging +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + +# Project root directory +ROOT_DIR = Path(__file__).parent.parent +MULTIQC_DIR = ROOT_DIR / "multiqc" +UTILS_DIR = MULTIQC_DIR / "utils" +ICONS_DIR = UTILS_DIR / "material_icons" + +# Iconify API base URL +ICONIFY_API_BASE = "https://api.iconify.design" + +# List of icons that MultiQC uses in mdi:iconname format +REQUIRED_ICONS: List[str] = [ + # Core functionality + "mdi:information", + "mdi:alert", + "mdi:alert-circle", + "mdi:help-circle", + "mdi:clock", + "mdi:magnify", + # Navigation + "mdi:chevron-down", + "mdi:chevron-up", + "mdi:chevron-left", + "mdi:chevron-right", + "mdi:arrow-left", + "mdi:arrow-right", + "mdi:menu", + "mdi:close", + # Toolbox actions + "mdi:pin", + "mdi:format-text", + "mdi:eye", + "mdi:eye-off", + "mdi:download", + "mdi:content-save", + "mdi:school", + "mdi:content-copy", + # Table actions + "mdi:table", + "mdi:sort", + "mdi:filter", + "mdi:chart-scatter-plot", + "mdi:equalizer", + "mdi:format-align-left", + "mdi:view-column", + # File operations + "mdi:folder", + "mdi:file", + "mdi:file-document", + # Status indicators + "mdi:check-circle", + "mdi:cancel", + "mdi:warning", + "mdi:radiobox-blank", + "mdi:radiobox-marked", + "mdi:thumb-up", + "mdi:thumb-down", + # Color mode toggle icons + "mdi:circle-half-full", + "mdi:weather-sunny", + "mdi:weather-night", + "mdi:check", + # Additional common icons + "mdi:home", + "mdi:view-dashboard", + "mdi:cog", + "mdi:refresh", + "mdi:printer", + "mdi:share", + "mdi:link", + "mdi:star", + "mdi:bookmark", + "mdi:heart", + "mdi:pencil", + "mdi:delete", + "mdi:plus", + "mdi:minus", + "mdi:magnify-plus", + "mdi:magnify-minus", + "mdi:hand-pointing-up", + # Additional icons found in current usage + "mdi:image", + "mdi:checkbox-marked", + "mdi:checkbox-blank-outline", + "mdi:folder-open", + "mdi:content-save-outline", + "mdi:qrcode", + "mdi:format-bold", + "mdi:violin", + # About section icons + "mdi:youtube", + "mdi:book-open-page-variant", + "mdi:github", + "mdi:alert-circle-outline", +] + + +def fetch_icon_from_iconify(icon_name: str) -> bool: + """ + Fetch an icon from Iconify API and save it as SVG. + + Args: + icon_name: The iconify icon name (e.g., 'mdi:home') + + Returns: + True if successful, False otherwise + """ + # Extract icon set and name from iconify format (e.g., 'mdi:home' -> 'mdi', 'home') + icon_set, icon_part = icon_name.split(":") + url = f"{ICONIFY_API_BASE}/{icon_set}/{icon_part}.svg" + + # Convert to filename format (mdi:home -> mdi-home.svg) + file_name = icon_name.replace(":", "-") + dest_path = ICONS_DIR / f"{file_name}.svg" + + # Check if the file already exists + if dest_path.exists(): + return True + + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + + # Save the SVG content + with open(dest_path, "w", encoding="utf-8") as f: + f.write(response.text) + + logger.info(f"Downloaded: {icon_name} -> {file_name}.svg") + return True + + except requests.RequestException as e: + logger.error(f"Failed to download {icon_name}: {e}") + return False + except Exception as e: + logger.error(f"Error saving {icon_name}: {e}") + return False + + +def fetch_icons(): + """Fetch Material Design Icons from Iconify API.""" + missing_icons = False + + # Create destination directory + ICONS_DIR.mkdir(parents=True, exist_ok=True) + + # Fetch SVG files + for icon_name in REQUIRED_ICONS: + success = fetch_icon_from_iconify(icon_name) + if not success: + missing_icons = True + + # Create a simple LICENSE file for attribution + license_content = """Material Design Icons + +These icons are from the Material Design Icons collection. +Source: https://materialdesignicons.com/ +License: Apache License 2.0 or SIL Open Font License 1.1 +Downloaded via Iconify API: https://api.iconify.design/ +""" + + license_path = ICONS_DIR / "LICENSE" + with open(license_path, "w", encoding="utf-8") as f: + f.write(license_content) + + return not missing_icons + + +if __name__ == "__main__": + success = fetch_icons() + if not success: + exit(1) + logger.info("Material Design Icons download completed successfully!") diff --git a/tests/test_custom_content.py b/tests/test_custom_content.py index 7dd5b4fb95..9942d25f66 100644 --- a/tests/test_custom_content.py +++ b/tests/test_custom_content.py @@ -272,7 +272,7 @@ def test_full_run_with_config(data_dir, capsys): ) out = capsys.readouterr().out - assert '

                      Concordance Rates

                      ' in out + assert '

                      Concordance Rates

                      ' in out assert '
                      \n\n""") + assert captured.out.startswith("""\n\n """) files_after = set(os.listdir(tmp_path)) assert files_before == files_after + + +def test_zip_data_dir_with_rerun(stub_modules, tmp_path): + """ + Verify that zip_data_dir works correctly when re-running with --force. + This tests the fix for the bug where a file named 'multiqc_data' was created, + causing NotADirectoryError on subsequent runs. + See https://github.com/MultiQC/MultiQC/issues/3358 + """ + # First run with zip_data_dir + write_report(output_dir=tmp_path, zip_data_dir=True) + + # Check that the zip file was created and the data directory was removed + assert (tmp_path / "multiqc_data.zip").exists() + assert not (tmp_path / "multiqc_data").exists() + + # Second run with --force should work without errors + write_report(output_dir=tmp_path, zip_data_dir=True, force=True) + + # Check again + assert (tmp_path / "multiqc_data.zip").exists() + assert not (tmp_path / "multiqc_data").exists() diff --git a/tests/test_plots.py b/tests/test_plots.py index 246a8251f3..9bc1deb504 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -332,6 +332,249 @@ def test_bar_plot_no_cats(): assert len(report.plot_data[plot.anchor]["datasets"][0]["cats"]) == 3 +def test_bar_plot_sample_groups(): + """ + Test sample_groups configuration for visual grouping + """ + plot = _verify_rendered( + bargraph.plot( + { + "Sample1": {"Cat1": 10, "Cat2": 20}, + "Sample2": {"Cat1": 15, "Cat2": 25}, + "Sample3": {"Cat1": 12, "Cat2": 22}, + "Sample4": {"Cat1": 18, "Cat2": 28}, + }, + ["Cat1", "Cat2"], + { + "id": "test_bar_plot_sample_groups", + "title": "Test: Bar Graph with Sample Groups", + "sample_groups": { + "Group 1": [["Sample1", "Sample1"], ["Sample2", "Sample2"]], + "Group 2": [["Sample3", "Sample3"], ["Sample4", "Sample4"]], + }, + }, + ) + ) + + ds = report.plot_data[plot.anchor]["datasets"][0] + # Samples should be reordered according to groups + assert ds["samples"] == ["Sample4", "Sample3", "Sample2", "Sample1"] # reversed for display + # Group labels should be present + assert ds["group_labels"] == ["Group 2", "Group 2", "Group 1", "Group 1"] # reversed + + +def test_bar_plot_sample_groups_with_names(): + """ + Test sample_groups with custom group names (now directly in dict keys) + """ + plot = _verify_rendered( + bargraph.plot( + { + "Sample1": {"Cat1": 10}, + "Sample2": {"Cat1": 15}, + }, + ["Cat1"], + { + "id": "test_bar_plot_sample_groups_with_names", + "title": "Test: Bar Graph with Named Groups", + "sample_groups": { + "Condition A": [["Sample1", "Sample1"]], + "Condition B": [["Sample2", "Sample2"]], + }, + }, + ) + ) + + ds = report.plot_data[plot.anchor]["datasets"][0] + # Custom group names should be used + assert ds["group_labels"] == ["Condition B", "Condition A"] # reversed + + +def test_bar_plot_sample_groups_ungrouped(): + """ + Test that samples not in any group get added to 'Other' + """ + plot = _verify_rendered( + bargraph.plot( + { + "Sample1": {"Cat1": 10}, + "Sample2": {"Cat1": 15}, + "Sample3": {"Cat1": 20}, + }, + ["Cat1"], + { + "id": "test_bar_plot_sample_groups_ungrouped", + "title": "Test: Bar Graph with Ungrouped Samples", + "sample_groups": {"Group 1": [["Sample1", "Sample1"]]}, # Sample2 and Sample3 not in any group + }, + ) + ) + + ds = report.plot_data[plot.anchor]["datasets"][0] + # Sample1 should be in Group 1, others in Other + # Order: grouped samples first (Sample1), then ungrouped (Sample2, Sample3) + assert "Other" in ds["group_labels"] + assert "Group 1" in ds["group_labels"] + + +def test_bar_plot_sample_groups_disables_sort(): + """ + Test that sample_groups disables sort_samples + """ + inputs = bargraph.BarPlotInputData.create( + {"Sample1": {"Cat1": 10}, "Sample2": {"Cat1": 15}}, + ["Cat1"], + { + "id": "test_bar_plot_sample_groups_disables_sort", + "title": "Test", + "sample_groups": {"Group 1": [["Sample1", "Sample1"]], "Group 2": [["Sample2", "Sample2"]]}, + "sort_samples": True, # Should be overridden + }, + ) + + assert inputs.pconfig.sort_samples is False + + +def test_bar_plot_sample_groups_disables_clustering(): + """ + Test that sample_groups disables cluster_samples + """ + inputs = bargraph.BarPlotInputData.create( + {"Sample1": {"Cat1": 10}, "Sample2": {"Cat1": 15}}, + ["Cat1"], + { + "id": "test_bar_plot_sample_groups_disables_clustering", + "title": "Test", + "sample_groups": {"Group 1": [["Sample1", "Sample1"]], "Group 2": [["Sample2", "Sample2"]]}, + "cluster_samples": True, # Should be overridden + }, + ) + + assert inputs.pconfig.cluster_samples is False + + +def test_bar_plot_sample_groups_empty_group(): + """ + Test that empty groups (groups with no matching samples) are handled gracefully + """ + plot = _verify_rendered( + bargraph.plot( + { + "Sample1": {"Cat1": 10}, + "Sample2": {"Cat1": 15}, + }, + ["Cat1"], + { + "id": "test_bar_plot_sample_groups_empty_group", + "title": "Test: Bar Graph with Empty Group", + "sample_groups": { + "Group A": [["Sample1", "Sample1"]], + "Empty Group": [["NonExistentSample", "NonExistent"]], # This group has no matching samples + "Group B": [["Sample2", "Sample2"]], + }, + }, + ) + ) + + ds = report.plot_data[plot.anchor]["datasets"][0] + # Only samples that exist should be in the output + # Empty group should not contribute any samples or labels + assert len(ds["samples"]) == 2 + assert len(ds["group_labels"]) == 2 + # Group labels should be "Group A" and "Group B" (no "Empty Group") + assert "Empty Group" not in ds["group_labels"] + + +def test_bar_plot_sample_groups_multiple_entries(): + """ + Test same sample appearing in multiple groups with lists for offset alignment + """ + plot = _verify_rendered( + bargraph.plot( + { + "Sample1_25nt": {"Frame0": 50, "Frame1": 30, "Frame2": 20}, + "Sample1_26nt": {"Frame0": 60, "Frame1": 25, "Frame2": 15}, + "Sample2_25nt": {"Frame0": 55, "Frame1": 28, "Frame2": 17}, + "Sample2_26nt": {"Frame0": 65, "Frame1": 22, "Frame2": 13}, + }, + ["Frame0", "Frame1", "Frame2"], + { + "id": "test_bar_plot_sample_groups_multiple_entries", + "title": "Test: Bar Graph with Multiple Entries Per Sample", + "sample_groups": { + "25nt": [["Sample1_25nt", "Sample1"], ["Sample2_25nt", "Sample2"]], + "26nt": [["Sample1_26nt", "Sample1"], ["Sample2_26nt", "Sample2"]], + }, + }, + ) + ) + + ds = report.plot_data[plot.anchor]["datasets"][0] + # All 4 samples should be present + assert len(ds["samples"]) == 4 + # Group labels should have 2 of each type + assert ds["group_labels"].count("25nt") == 2 + assert ds["group_labels"].count("26nt") == 2 + # Offset groups should map sample keys to their base sample names + assert ds["offset_groups"]["Sample1_25nt"] == "Sample1" + assert ds["offset_groups"]["Sample1_26nt"] == "Sample1" + assert ds["offset_groups"]["Sample2_25nt"] == "Sample2" + assert ds["offset_groups"]["Sample2_26nt"] == "Sample2" + + +def test_linegraph_axis_controlled_by_switches_valid(): + """Test that valid axis_controlled_by_switches values are accepted.""" + # Test with yaxis only (default behavior) + config1 = LinePlotConfig(id="test1", title="Test", axis_controlled_by_switches=["yaxis"]) + assert config1.axis_controlled_by_switches == ["yaxis"] + + # Test with xaxis only + config2 = LinePlotConfig(id="test2", title="Test", axis_controlled_by_switches=["xaxis"]) + assert config2.axis_controlled_by_switches == ["xaxis"] + + # Test with both axes + config3 = LinePlotConfig(id="test3", title="Test", axis_controlled_by_switches=["xaxis", "yaxis"]) + assert config3.axis_controlled_by_switches == ["xaxis", "yaxis"] + + # Test with None (default) + config4 = LinePlotConfig(id="test4", title="Test") + assert config4.axis_controlled_by_switches is None + + +def test_linegraph_axis_controlled_by_switches_invalid(): + """Test that invalid axis_controlled_by_switches values are rejected with a useful error.""" + with patch("logging.Logger.error") as err: + config = LinePlotConfig(id="test", title="Test", axis_controlled_by_switches=["invalid"]) + assert config.axis_controlled_by_switches is None + errs = "\n".join(call.args[0] for call in err.mock_calls if call.args) + assert "'axis_controlled_by_switches'" in errs + assert "Literal['xaxis', 'yaxis']" in errs + + +def test_linegraph_axis_controlled_by_switches_string_instead_of_list(): + """Test that a flat string instead of a list is rejected with a useful error.""" + with patch("logging.Logger.error") as err: + config = LinePlotConfig(id="test", title="Test", axis_controlled_by_switches="yaxis") # type: ignore + assert config.axis_controlled_by_switches is None + errs = "\n".join(call.args[0] for call in err.mock_calls if call.args) + assert "'axis_controlled_by_switches'" in errs + assert "List" in errs + + +def test_linegraph_axis_controlled_by_switches_in_plot(): + """Test that axis_controlled_by_switches works in actual plot creation.""" + dataset = {"Sample1": {0: 1, 1: 2}} + + # Test with xaxis + plot = _verify_rendered( + linegraph.plot( + dataset, + LinePlotConfig(id="test_axis_xaxis", title="Test", axis_controlled_by_switches=["xaxis"]), + ) + ) + assert isinstance(plot, linegraph.LinePlot) + + def test_linegraph_smooth(): SMOOTH_TO = 2 dataset = {"Smoothed": {0: 1, 1: 1, 2: 1}, "Unsmoothed": {0: 1, 1: 1}} @@ -644,3 +887,158 @@ def test_table_default_sort(): assert isinstance(p, Plot) sort_string = _get_sortlist_js(p.datasets[0].dt) assert sort_string == "[[2, 1], [1, 0]]" + + +def test_table_custom_plot_config_hidden(reset): + """ + Test that custom_plot_config can set column properties at the table level. + When 'hidden: true' is set at the table level, all columns should be hidden. + """ + table_id = "test_table_hidden" + + # Set custom_plot_config for this table + config.custom_plot_config = { + table_id: { + "hidden": True, # Should apply to all columns + } + } + + headers: Dict[str, ColumnDict] = { + "x": {"title": "Metric X"}, + "y": {"title": "Metric Y"}, + "z": {"title": "Metric Z"}, + } + + p = table.plot( + data={ + "sample1": {"x": 1, "y": 2, "z": 3}, + "sample2": {"x": 4, "y": 5, "z": 6}, + }, + headers=headers, + pconfig=table.TableConfig(id=table_id, title="Test Table"), + ) + + assert isinstance(p, Plot) + + # Check that all columns are hidden + dt = p.datasets[0].dt + for section in dt.section_by_id.values(): + for col_key, col_meta in section.column_by_key.items(): + assert col_meta.hidden is True, f"Column {col_key} should be hidden" + + +def test_table_custom_plot_config_scale(reset): + """ + Test that custom_plot_config can set the color scale at the table level. + When 'scale: RdYlGn' is set at the table level, all columns should use that scale. + """ + table_id = "test_table_scale" + + # Set custom_plot_config for this table + config.custom_plot_config = { + table_id: { + "scale": "RdYlGn", # Should apply to all columns + } + } + + headers: Dict[str, ColumnDict] = { + "x": {"title": "Metric X", "scale": "Blues"}, # This should be overridden + "y": {"title": "Metric Y", "scale": "Reds"}, # This should be overridden + "z": {"title": "Metric Z"}, # This should get RdYlGn + } + + p = table.plot( + data={ + "sample1": {"x": 1, "y": 2, "z": 3}, + "sample2": {"x": 4, "y": 5, "z": 6}, + }, + headers=headers, + pconfig=table.TableConfig(id=table_id, title="Test Table"), + ) + + assert isinstance(p, Plot) + + # Check that all columns have the RdYlGn scale + dt = p.datasets[0].dt + for section in dt.section_by_id.values(): + for col_key, col_meta in section.column_by_key.items(): + assert col_meta.scale == "RdYlGn", f"Column {col_key} should have scale 'RdYlGn', got '{col_meta.scale}'" + + +def test_table_custom_plot_config_multiple_properties(reset): + """ + Test that custom_plot_config can set multiple column properties at once. + """ + table_id = "test_table_multi" + + # Set multiple properties at the table level + config.custom_plot_config = { + table_id: { + "hidden": False, + "scale": "Purples", + "suffix": " units", + } + } + + headers: Dict[str, ColumnDict] = { + "x": {"title": "Metric X", "hidden": True}, # Should be overridden to False + "y": {"title": "Metric Y"}, + } + + p = table.plot( + data={ + "sample1": {"x": 1, "y": 2}, + "sample2": {"x": 3, "y": 4}, + }, + headers=headers, + pconfig=table.TableConfig(id=table_id, title="Test Table"), + ) + + assert isinstance(p, Plot) + + # Check that all properties are applied + dt = p.datasets[0].dt + for section in dt.section_by_id.values(): + for col_key, col_meta in section.column_by_key.items(): + assert col_meta.hidden is False, f"Column {col_key} should not be hidden" + assert col_meta.scale == "Purples", f"Column {col_key} should have scale 'Purples'" + assert col_meta.suffix == " units", f"Column {col_key} should have suffix ' units'" + + +def test_table_custom_plot_config_invalid_field(reset): + """ + Test that invalid fields in custom_plot_config are silently ignored. + This should not crash when a table-level property doesn't exist on TableConfig. + """ + table_id = "test_table_invalid" + + # Set invalid properties - 'hidden' is not a TableConfig field, only a ColumnMeta field + config.custom_plot_config = { + table_id: { + "hidden": True, # Valid ColumnMeta field, should apply to columns + "invalid_field": "value", # Invalid field, should be ignored + } + } + + headers: Dict[str, ColumnDict] = { + "x": {"title": "Metric X"}, + "y": {"title": "Metric Y"}, + } + + # This should not raise an error + p = table.plot( + data={ + "sample1": {"x": 1, "y": 2}, + "sample2": {"x": 3, "y": 4}, + }, + headers=headers, + pconfig=table.TableConfig(id=table_id, title="Test Table"), + ) + + assert isinstance(p, Plot) + + # Check that the valid field (hidden) was applied + dt = p.datasets[0].dt + for section in dt.section_by_id.values(): + for col_key, col_meta in section.column_by_key.items(): + assert col_meta.hidden is True, f"Column {col_key} should be hidden"