diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..e3cc94f772 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,81 @@ +# Bizard – GitHub Copilot Code Review Instructions + +## Project Overview +Bizard is a **Biomedical Visualization Atlas** — a community-driven Quarto website that +hosts bilingual (English + Chinese) R-based visualization tutorials for biomedical +researchers. Every tutorial is a `.qmd` (Quarto Markdown) file rendered by Quarto and +published to GitHub Pages. + +--- + +## Tutorial QMD Format Requirements + +When reviewing a new or modified `.qmd` tutorial, check for the following. + +### 1. YAML Frontmatter (required) +Every tutorial must start with a YAML block containing: +```yaml +--- +title: "Plot Name" +author: + - "**[Editor]** [Name](https://github.com/username);" + - "**[Contributors]** [Name](https://github.com/username)." +--- +``` +- `title` must be present and descriptive. +- `author` must list at least one Editor and optionally Contributors. + +### 2. Required Sections (in order) +Each tutorial should contain, in this order: +1. **A description paragraph** (immediately after the frontmatter, before any heading). +2. `## Example` – contains a demo image `![](../images//_demo.png){...}`. +3. `## Setup` – lists: System Requirements, Programming Language, Dependencies, and an `{r packages setup}` code block. +4. `## Data Preparation` – loads data, preferably from public biomedical sources. +5. `## Visualization` – contains one or more `{r fig...}` code blocks with `fig-cap` and `label`. + +### 3. Code Block Conventions +- The packages setup block must be named: `` ```{r packages setup, message=FALSE, warning=FALSE, output=FALSE} `` +- Figure blocks must have `label`, `fig-cap`, and `out.width` options. +- Include a `sessioninfo::session_info("attached")` block after setup. +- All packages must check for installation before loading (`if (!requireNamespace(...)) install.packages(...)`). +- Code must be self-contained and reproducible using public datasets. + +### 4. Bilingual Pairing +- Every English `.qmd` should have a corresponding `.zh.qmd` Chinese translation. +- The auto-translate workflow generates `.zh.qmd` automatically; do not manually edit `.zh.qmd` files unless correcting mistranslations. + +### 5. Images +- Demo images go in `images//` with the naming convention `_demo.png`. +- Reference them as `![](../images//_demo.png){fig-alt="..." fig-align="center" width="60%"}`. + +### 6. Data Sources +- Prefer public biomedical datasets: TCGA, GEO (GSExxx), built-in R datasets. +- Large data files must be hosted on Bizard Tencent COS and referenced by URL, not committed to the repo. +- Dataset size should be < 1 MB. + +### 7. Style +- Use `-` for unordered lists, not `*`. +- Use backtick inline code for package names, function names, and file paths. +- Avoid line lengths > 120 characters in prose. +- Keep the tutorial self-contained: a reader should be able to copy-paste the code and reproduce every figure. + +--- + +## Workflow Files +When reviewing `.github/workflows/*.yml` files: +- Prefer reusable `actions/github-script@v7` for GitHub API calls. +- Always pin third-party actions to a major version tag (e.g., `@v4`, `@v5`). +- Avoid storing secrets in `run:` steps; use `env:` to pass secrets to steps. +- Translation workflow should not overwrite existing `.zh.qmd` files unless the user explicitly requests it. + +--- + +## What to Flag +- Missing `title` or `author` in YAML. +- Absence of `## Example`, `## Setup`, `## Data Preparation`, or `## Visualization`. +- Hardcoded local file paths (e.g., `read.csv("/home/user/data.csv")`). +- Direct commit of large binary files or data files > 1 MB. +- Code blocks without proper `label` for figures. +- Missing `sessioninfo::session_info("attached")` block. +- Missing bilingual pair (`.zh.qmd`) when adding a new tutorial. +- Packages loaded without the standard `if (!requireNamespace(...))` guard. diff --git a/.github/scripts/generate_skills.py b/.github/scripts/generate_skills.py new file mode 100644 index 0000000000..dfa7b9798f --- /dev/null +++ b/.github/scripts/generate_skills.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +""" +generate_skills.py — Convert Bizard QMD tutorials into AI skill documents. + +A skill document is a compact Markdown file that: + - Names the chart and its category + - Describes when to use the chart + - Lists required R packages + - Provides a minimal reproducible code example extracted from the QMD + - Links back to the full tutorial + +Usage: + # Generate skills for all QMD files in the repo + python .github/scripts/generate_skills.py + + # Generate for a specific directory + python .github/scripts/generate_skills.py --input Omics/ --output skills/Omics/ + + # Generate a single file skill + python .github/scripts/generate_skills.py --input Omics/VolcanoPlot.qmd + +Options: + --input PATH Input QMD file or directory (default: repo root) + --output DIR Output directory for skill files (default: skills/) + --base-url URL Base URL for tutorial links + (default: https://openbiox.github.io/Bizard/) + --format FORMAT Output format: markdown or json (default: markdown) + --verbose Print progress +""" + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +# ── Constants ───────────────────────────────────────────────────────────────── +DEFAULT_BASE_URL = "https://openbiox.github.io/Bizard/" +YAML_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL) +TITLE_RE = re.compile(r'^title:\s*["\']?(.+?)["\']?\s*$', re.MULTILINE) +AUTHOR_RE = re.compile(r'^\s*-\s*["\']?\*\*\[Editor\]\*\*\s*(.+?)["\']?\s*$', re.MULTILINE) + +# Detect package names from `library(pkg)` or `require(pkg)` calls +LIB_RE = re.compile(r'\blibrary\(\s*([a-zA-Z0-9_.]+)\s*\)') +REQ_RE = re.compile(r'\brequireNamespace\(\s*["\']([a-zA-Z0-9_.]+)["\']') + +# Detect sections +SECTION_RE = re.compile(r'^(#{1,3})\s+(.+)$', re.MULTILINE) + +# Code block extractor +CODE_BLOCK_RE = re.compile(r'```\{r([^}]*)\}(.*?)```', re.DOTALL) + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def parse_yaml_field(yaml_text: str, field: str) -> str: + """Extract a simple scalar YAML field value.""" + m = re.search(rf'^{field}\s*:\s*["\']?(.+?)["\']?\s*$', yaml_text, re.MULTILINE) + return m.group(1).strip() if m else "" + + +def extract_packages(content: str) -> list[str]: + """Extract unique package names from library() / requireNamespace() calls.""" + pkgs = set() + pkgs.update(LIB_RE.findall(content)) + pkgs.update(REQ_RE.findall(content)) + # Remove very common base packages that are assumed + skip = {"base", "datasets", "utils", "stats", "methods", "grDevices", "graphics"} + return sorted(pkgs - skip) + + +def extract_description(content: str) -> str: + """Extract the first substantive prose paragraph (after YAML, before first ##).""" + # Remove YAML frontmatter + body = YAML_RE.sub("", content).strip() + # Take text before the first ## heading + parts = re.split(r'^##', body, maxsplit=1, flags=re.MULTILINE) + prose = parts[0].strip() + # Take first non-empty paragraph + for para in prose.split("\n\n"): + para = para.strip() + if para and not para.startswith("#") and len(para) > 30: + # Limit to ~280 chars + return para[:280].rstrip() + ("…" if len(para) > 280 else "") + return "" + + +def extract_first_code_block(content: str, prefer_label: str = "fig") -> str: + """Return the first R code block (preferring figure blocks).""" + blocks = CODE_BLOCK_RE.findall(content) + if not blocks: + return "" + + # Prefer blocks whose label starts with 'fig' + for opts, code in blocks: + if prefer_label in opts.lower(): + return code.strip() + + # Fall back to the first non-setup block + for opts, code in blocks: + if "packages setup" not in opts and "session" not in opts: + return code.strip() + + return blocks[0][1].strip() + + +def detect_category(filepath: Path) -> str: + """Infer chart category from the file path.""" + parts = filepath.parts + known = { + "Distribution", "Correlation", "Ranking", "Composition", + "Proportion", "DataOverTime", "Animation", "Omics", + "Clinics", "Hiplot" + } + for p in parts: + if p in known: + return p + return "Misc" + + +def qmd_to_skill(filepath: Path, base_url: str = DEFAULT_BASE_URL) -> dict: + """Convert a single QMD file into a skill dictionary.""" + content = filepath.read_text(encoding="utf-8") + + # YAML + yaml_match = YAML_RE.match(content) + yaml_text = yaml_match.group(1) if yaml_match else "" + + title = parse_yaml_field(yaml_text, "title") or filepath.stem + # Clean up quotes + title = title.strip('"\'') + + category = detect_category(filepath) + packages = extract_packages(content) + description = extract_description(content) + code = extract_first_code_block(content) + + # Build tutorial URL: base_url + Category/FileName.html + rel_html = str(filepath.with_suffix(".html")).lstrip("./") + tutorial_url = base_url.rstrip("/") + "/" + rel_html + + # Use-when: first 2 sentences of description, or a fallback + use_when = description or f"Visualize {title.lower()} data in a biomedical context." + + # Build skill document text + pkg_list = "\n".join(f"- {p}" for p in packages[:10]) or "- (see tutorial)" + code_block = f"```r\n{code}\n```" if code else "(See full tutorial for code)" + + skill_text = f"""# Skill: {title} (R) + +## Category +{category} + +## When to use +{use_when} + +## Required R packages +{pkg_list} + +## Minimal reproducible code +{code_block} + +## Full tutorial +{tutorial_url} +""" + + return { + "name": title, + "category": category, + "packages": packages, + "use_when": use_when, + "tutorial_url": tutorial_url, + "skill": skill_text, + "source_file": str(filepath), + } + + +def iter_qmd_files(path: Path) -> list[Path]: + """Yield .qmd files, skipping .zh.qmd translations and Template dir.""" + if path.is_file(): + return [path] if (path.suffix == ".qmd" and not path.name.endswith(".zh.qmd")) else [] + + files = [] + for f in sorted(path.rglob("*.qmd")): + if f.name.endswith(".zh.qmd"): + continue + if "Template" in f.parts: + continue + if f.name.startswith("_"): + continue + files.append(f) + return files + + +# ── Main ───────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--input", default=".", help="Input QMD file or directory") + parser.add_argument("--output", default="skills", help="Output directory") + parser.add_argument("--base-url",default=DEFAULT_BASE_URL, help="Base URL for tutorial links") + parser.add_argument("--format", choices=["markdown", "json", "both"], default="markdown") + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + + input_path = Path(args.input) + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + qmd_files = iter_qmd_files(input_path) + if not qmd_files: + print(f"No QMD files found in: {input_path}", file=sys.stderr) + sys.exit(1) + + skills = [] + errors = [] + + for f in qmd_files: + try: + skill = qmd_to_skill(f, base_url=args.base_url) + skills.append(skill) + if args.verbose: + print(f"✓ {f.name} → {skill['name']} [{skill['category']}]") + except Exception as exc: + errors.append((f, exc)) + print(f"✗ {f}: {exc}", file=sys.stderr) + + # Write output + write_json = args.format in ("json", "both") + write_markdown = args.format in ("markdown", "both") + + if write_markdown: + for skill in skills: + cat_dir = output_dir / skill["category"] + cat_dir.mkdir(parents=True, exist_ok=True) + stem = Path(skill["source_file"]).stem + out_file = cat_dir / f"{stem}_skill.md" + out_file.write_text(skill["skill"], encoding="utf-8") + print(f"\nWrote {len(skills)} skill files to {output_dir}/") + + # Always write the index JSON (lightweight summary without full skill text) + index = [ + {k: v for k, v in s.items() if k != "skill"} + for s in skills + ] + index_file = output_dir / "index.json" + with open(index_file, "w", encoding="utf-8") as fh: + json.dump(index, fh, ensure_ascii=False, indent=2) + print(f"Index written to {index_file}") + + if write_json: + full_file = output_dir / "bizard_skills.json" + with open(full_file, "w", encoding="utf-8") as fh: + json.dump(skills, fh, ensure_ascii=False, indent=2) + print(f"Full skills JSON written to {full_file}") + + if errors: + print(f"\n{len(errors)} file(s) failed:", file=sys.stderr) + for f, e in errors: + print(f" {f}: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/auto-translate.yml b/.github/workflows/auto-translate.yml index 515890e852..9b822d9926 100644 --- a/.github/workflows/auto-translate.yml +++ b/.github/workflows/auto-translate.yml @@ -1,24 +1,27 @@ name: Auto-Translate QMD Files +# ────────────────────────────────────────────────────────────────────────────── +# Triggers: +# - pull_request: auto-translate new .qmd files added in a PR +# - push: translate on direct push to main/master (creates a new PR) +# - workflow_dispatch: manual trigger (optionally targeting a specific PR) +# +# Note: The issue_comment (/translate command) trigger has been removed for +# security reasons. Use workflow_dispatch to manually trigger translations. +# ────────────────────────────────────────────────────────────────────────────── on: pull_request: types: [opened, synchronize] - paths: - - '**.qmd' - - '**.Qmd' + paths: ['**.qmd', '**.Qmd'] push: branches: [main, master] - paths: - - '**.qmd' - - '**.Qmd' + paths: ['**.qmd', '**.Qmd'] workflow_dispatch: inputs: pr_number: description: 'PR number to translate (leave empty for current branch)' required: false type: number - issue_comment: - types: [created] permissions: contents: write @@ -27,508 +30,226 @@ permissions: jobs: auto-translate: runs-on: ubuntu-latest - if: | - github.event_name == 'pull_request' || - github.event_name == 'workflow_dispatch' || - github.event_name == 'issue_comment' || - github.event_name == 'push' - + steps: - - name: Check if triggered by valid comment command - id: check-comment - if: github.event_name == 'issue_comment' - run: | - comment="${{ github.event.comment.body }}" - if [[ "$comment" =~ ^/translate ]]; then - echo "valid_command=true" >> $GITHUB_OUTPUT - if [[ ! "${{ github.event.issue.pull_request }}" ]]; then - echo "valid_command=false" >> $GITHUB_OUTPUT - echo "Comment was not on a PR, skipping" - fi - else - echo "valid_command=false" >> $GITHUB_OUTPUT - echo "Comment does not contain /translate command, skipping" - fi - - - name: Exit if invalid comment trigger - if: github.event_name == 'issue_comment' && steps.check-comment.outputs.valid_command != 'true' - run: | - echo "Workflow triggered by comment but command is invalid or not on a PR. Exiting." - exit 0 - - - name: Get PR information - id: pr-info + # ── 1. Resolve branch / PR context ───────────────────────────────────── + - name: Resolve PR / branch context + id: ctx uses: actions/github-script@v7 with: script: | - let prNumber, prRef, prRepo, isPush = false; - - if (context.eventName === 'pull_request') { - prNumber = context.payload.pull_request.number; - prRef = context.payload.pull_request.head.ref; - prRepo = context.payload.pull_request.head.repo.full_name; - } else if (context.eventName === 'issue_comment') { - const issue = context.payload.issue; - prNumber = issue.number; - - // Get PR details - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - prRef = pr.head.ref; - prRepo = pr.head.repo.full_name; - } else if (context.eventName === 'push') { - // For push events, create a new branch for translation + const ev = context.eventName; + let prNumber = '', prRef = '', prRepo = '', isPush = false; + + if (ev === 'pull_request') { + prNumber = String(context.payload.pull_request.number); + prRef = context.payload.pull_request.head.ref; + prRepo = context.payload.pull_request.head.repo.full_name; + + } else if (ev === 'push') { isPush = true; - const baseBranch = context.ref.replace('refs/heads/', ''); - // Generate timestamp: remove milliseconds and 'Z' suffix, replace : and . with - - const timestamp = new Date().toISOString().split('.')[0].replace(/[:.]/g, '-'); - prRef = `auto-translate-${baseBranch}-${timestamp}`; - prRepo = context.repo.owner + '/' + context.repo.repo; - - console.log(`Push event detected on ${baseBranch}`); - console.log(`Will create new branch: ${prRef}`); - } else if (context.eventName === 'workflow_dispatch') { - const inputPrNumber = context.payload.inputs.pr_number; - prNumber = inputPrNumber ? parseInt(inputPrNumber, 10) : null; - if (prNumber) { + const base = context.ref.replace('refs/heads/', ''); + const ts = new Date().toISOString().replace(/[:.]/g, '-').split('Z')[0]; + prRef = `auto-translate-${base}-${ts}`; + prRepo = `${context.repo.owner}/${context.repo.repo}`; + + } else if (ev === 'workflow_dispatch') { + const inputPr = context.payload.inputs?.pr_number; + if (inputPr) { + prNumber = String(inputPr); const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber + owner: context.repo.owner, repo: context.repo.repo, + pull_number: Number(inputPr) }); - prRef = pr.head.ref; + prRef = pr.head.ref; prRepo = pr.head.repo.full_name; } else { - prRef = context.ref.replace('refs/heads/', ''); - prRepo = context.repo.owner + '/' + context.repo.repo; + prRef = context.ref.replace('refs/heads/', ''); + prRepo = `${context.repo.owner}/${context.repo.repo}`; } } - - core.setOutput('pr_number', prNumber || ''); - core.setOutput('pr_ref', prRef || context.ref.replace('refs/heads/', '')); - core.setOutput('pr_repo', prRepo || ''); - core.setOutput('is_push', isPush ? 'true' : 'false'); + + core.setOutput('pr_number', prNumber); + core.setOutput('pr_ref', prRef || context.ref.replace('refs/heads/', '')); + core.setOutput('pr_repo', prRepo); + core.setOutput('is_push', isPush ? 'true' : 'false'); core.setOutput('base_branch', context.ref.replace('refs/heads/', '')); - - console.log(`PR Number: ${prNumber}`); - console.log(`PR Ref: ${prRef}`); - console.log(`PR Repo: ${prRepo}`); - console.log(`Is Push: ${isPush}`); - - - name: Checkout PR branch - if: github.event_name != 'push' + + # ── 2. Checkout ───────────────────────────────────────────────────────── + - name: Checkout (PR / dispatch) + if: steps.ctx.outputs.is_push != 'true' uses: actions/checkout@v4 with: - repository: ${{ steps.pr-info.outputs.pr_repo }} - ref: ${{ steps.pr-info.outputs.pr_ref }} + repository: ${{ steps.ctx.outputs.pr_repo }} + ref: ${{ steps.ctx.outputs.pr_ref }} fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - - name: Checkout and create new branch for push events - if: github.event_name == 'push' + + - name: Checkout (push – create new translation branch) + if: steps.ctx.outputs.is_push == 'true' uses: actions/checkout@v4 with: - ref: ${{ steps.pr-info.outputs.base_branch }} + ref: ${{ steps.ctx.outputs.base_branch }} fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - - name: Create new translation branch - if: github.event_name == 'push' - run: | - git checkout -b ${{ steps.pr-info.outputs.pr_ref }} - echo "Created new branch: ${{ steps.pr-info.outputs.pr_ref }}" - + + - name: Create translation branch (push only) + if: steps.ctx.outputs.is_push == 'true' + run: git checkout -b ${{ steps.ctx.outputs.pr_ref }} + + # ── 3. Python setup ───────────────────────────────────────────────────── - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - - - name: Install dependencies - run: | - pip install openai - - - name: Get changed QMD files + + - name: Install Python dependencies + run: pip install openai + + # ── 4. Detect changed QMD files ───────────────────────────────────────── + - name: Detect changed QMD files id: changed-files uses: tj-actions/changed-files@v46.0.5 with: files: | **.qmd **.Qmd - - - name: Load translation blacklist - id: blacklist - run: | - if [ -f .github/translation-blacklist.txt ]; then - echo "Blacklist file found" - cat .github/translation-blacklist.txt - else - echo "No blacklist file found" - fi - - - name: Process translations + + # ── 5. Translate / validate ────────────────────────────────────────────── + - name: Translate and validate QMD pairs if: steps.changed-files.outputs.any_changed == 'true' env: - # Support multiple AI providers via configurable environment variables - # Priority: AI_Model_* variables, fallback to OPENAI_API_KEY for backward compatibility AI_Model_API_KEY: ${{ secrets.AI_Model_API_KEY || secrets.OPENAI_API_KEY }} AI_Model_BASE_URL: ${{ secrets.AI_Model_BASE_URL }} AI_Model_Name: ${{ secrets.AI_Model_Name }} CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} - IS_PUSH_EVENT: ${{ steps.pr-info.outputs.is_push }} + IS_PUSH_EVENT: ${{ steps.ctx.outputs.is_push }} run: | - # Function to check if file is in blacklist using glob patterns + set -euo pipefail + + # ── helpers ── is_blacklisted() { local file="$1" - if [ -f .github/translation-blacklist.txt ]; then - while IFS= read -r pattern || [ -n "$pattern" ]; do - # Skip comments and empty lines - [[ "$pattern" =~ ^#.*$ ]] && continue - [[ -z "$pattern" ]] && continue - [[ "$pattern" =~ ^[[:space:]]*$ ]] && continue - - # Use case statement for glob pattern matching - case "$file" in - $pattern) - return 0 - ;; - esac - done < .github/translation-blacklist.txt - fi + [ -f .github/translation-blacklist.txt ] || return 1 + while IFS= read -r pattern || [ -n "$pattern" ]; do + [[ "$pattern" =~ ^[[:space:]]*$ || "$pattern" =~ ^# ]] && continue + case "$file" in $pattern) return 0 ;; esac + done < .github/translation-blacklist.txt return 1 } - - # Arrays to store files + files_to_translate=() files_with_pairs=() - - # Process each changed file - for file in ${CHANGED_FILES}; do - echo "Processing: $file" - - # Check blacklist - if is_blacklisted "$file"; then - echo " [SKIPPED] Blacklisted: $file" - continue - fi - - # Determine the translation pair + + for file in $CHANGED_FILES; do + echo "▶ $file" + if is_blacklisted "$file"; then echo " ⊘ blacklisted"; continue; fi + if [[ "$file" == *.zh.qmd ]]; then - # Chinese file - English pair pair="${file%.zh.qmd}.qmd" else - # English file - Chinese pair pair="${file%.qmd}.zh.qmd" fi - - echo " Translation pair: $pair" - - # Check if pair exists in the PR changes + if echo "$CHANGED_FILES" | grep -qw "$pair"; then - echo " [OK] Both language versions present in PR" + echo " ✓ bilingual pair both in PR" files_with_pairs+=("$file|$pair") + elif [ -f "$pair" ]; then + echo " ⚠ pair exists in repo – skipping auto-translate to avoid overwrite" else - # Check if pair exists in repo - if [ -f "$pair" ]; then - # Pair exists in repo - behavior depends on event type - if [ "$IS_PUSH_EVENT" = "true" ]; then - echo " [SKIPPED] Translation pair exists in repo (push event - avoiding overwrite)" - echo " To update translation, modify both files or create a PR" - else - echo " [WARNING] Translation pair exists but not in PR, skipping auto-translation" - fi - else - echo " [TRANSLATE] Will auto-translate to: $pair" - files_to_translate+=("$file") - fi + echo " → will translate to: $pair" + files_to_translate+=("$file") fi done - - # Perform translations + + # Translations if [ ${#files_to_translate[@]} -gt 0 ]; then - echo "" - echo "=========================================" - echo "Auto-translating ${#files_to_translate[@]} file(s)" - echo "=========================================" - - # Check translation provider availability - if [ -z "$AI_Model_API_KEY" ]; then - echo "✗ No translation provider configured" - echo " Need AI_Model_API_KEY or OPENAI_API_KEY" - exit 1 + if [ -z "${AI_Model_API_KEY:-}" ]; then + echo "✗ No AI_Model_API_KEY / OPENAI_API_KEY secret configured – skipping translation" + else + echo "=== Translating ${#files_to_translate[@]} file(s) ===" + for file in "${files_to_translate[@]}"; do + echo " Translating $file …" + python .github/scripts/translate_qmd.py "$file" && echo " ✓" || echo " ✗ failed" + done fi - - echo "✓ External AI provider configured" - - for file in "${files_to_translate[@]}"; do - echo "" - echo "Translating: $file" - - echo " Using external AI provider for translation..." - if python .github/scripts/translate_qmd.py "$file"; then - echo " ✓ Translation successful" - else - echo " ✗ Translation failed for $file" - fi - done - else - echo "" - echo "No files need automatic translation" fi - - # Validate file pairs (both languages in PR) + + # Spell-check bilingual pairs if [ ${#files_with_pairs[@]} -gt 0 ]; then - echo "" - echo "=========================================" - echo "Validating ${#files_with_pairs[@]} bilingual pair(s)" - echo "=========================================" - - for pair_entry in "${files_with_pairs[@]}"; do - IFS='|' read -r file1 file2 <<< "$pair_entry" - echo "" - echo "Checking pair: $file1 <-> $file2" - - # Run spell check on both files - echo " Spell-checking $file1..." - python .github/scripts/translate_qmd.py "$file1" --check-spelling || true - - echo " Spell-checking $file2..." - python .github/scripts/translate_qmd.py "$file2" --check-spelling || true + echo "=== Spell-checking ${#files_with_pairs[@]} pair(s) ===" + for entry in "${files_with_pairs[@]}"; do + IFS='|' read -r f1 f2 <<< "$entry" + python .github/scripts/translate_qmd.py "$f1" --check-spelling || true + python .github/scripts/translate_qmd.py "$f2" --check-spelling || true done fi - - - name: Check for changes - id: git-check + + # ── 6. Commit & push ──────────────────────────────────────────────────── + - name: Commit translations + id: git-commit run: | - # Check for both modified and untracked files - STATUS_OUTPUT="$(git status --porcelain)" - if [ -n "$STATUS_OUTPUT" ]; then - echo "changes=true" >> $GITHUB_OUTPUT - echo "✓ Changes detected:" - echo "$STATUS_OUTPUT" - - # Show newly created translation files - echo "" - echo "📄 New translation files:" - echo "$STATUS_OUTPUT" | grep '^??' | awk '{print $2}' | grep '\.zh\.qmd$\|\.qmd$' || echo " (none)" - - # Show first 20 lines of each new translation - for file in $(echo "$STATUS_OUTPUT" | grep '^??' | awk '{print $2}' | grep '\.zh\.qmd$\|\.qmd$'); do - if [ -f "$file" ]; then - echo "" - echo "📝 Preview of $file (first 20 lines):" - head -20 "$file" - echo "..." - fi - done + if git diff --quiet && git diff --cached --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then + echo "committed=false" >> "$GITHUB_OUTPUT" else - echo "No changes detected" + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git add -A + git commit -m "chore: auto-translate QMD files" \ + -m "Automatically generated translations for modified QMD files." + echo "committed=true" >> "$GITHUB_OUTPUT" fi - - - name: Commit translations - if: steps.git-check.outputs.changes == 'true' - run: | - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - git add -A - git commit -m "chore: auto-translate QMD files - - Automatically generated translations for modified QMD files. - - Co-authored-by: github-actions[bot] " - - - name: Push changes - if: steps.git-check.outputs.changes == 'true' + + - name: Push translations + if: steps.git-commit.outputs.committed == 'true' continue-on-error: true run: | - # Pull latest changes to avoid conflicts - git pull --rebase origin ${{ steps.pr-info.outputs.pr_ref }} || true - git push origin ${{ steps.pr-info.outputs.pr_ref }} || { - echo "⚠️ Push failed (this is expected for PRs from forks without write permissions)" - echo "Translation files are still available in the workflow artifacts" - exit 0 - } - - - name: Post translation preview to PR - if: steps.git-check.outputs.changes == 'true' && (github.event_name == 'pull_request' || github.event_name == 'issue_comment') + git pull --rebase origin ${{ steps.ctx.outputs.pr_ref }} || true + git push origin ${{ steps.ctx.outputs.pr_ref }} || \ + echo "⚠️ Push failed (fork PR without write permission) – translations available as artifacts" + + # ── 7. PR comments ────────────────────────────────────────────────────── + - name: Post translation summary to PR + if: steps.git-commit.outputs.committed == 'true' && github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | - const fs = require('fs'); const { execSync } = require('child_process'); - - // Get PR number - let prNumber; - if (context.eventName === 'pull_request') { - prNumber = context.payload.pull_request.number; - } else if (context.eventName === 'issue_comment') { - prNumber = context.payload.issue.number; - } else { - console.log('Not a PR event, skipping comment'); - return; - } - - // Get list of translated files from the last commit - const translatedFiles = execSync('git show --name-only --format= HEAD') - .toString() - .split('\n') - .filter(f => f.trim() && (f.endsWith('.qmd') || f.endsWith('.zh.qmd'))); - - if (translatedFiles.length === 0) { - console.log('No translated files found in last commit'); - return; - } - - let comment = '## 🤖 Translation Preview\n\n'; - comment += `Successfully translated ${translatedFiles.length} file(s):\n\n`; - - for (const file of translatedFiles) { - if (!file) continue; - - try { - const content = fs.readFileSync(file, 'utf-8'); - const lines = content.split('\n'); - const preview = lines.slice(0, 20).join('\n'); - - comment += `### 📄 \`${file}\`\n\n`; - comment += '
\n'; - comment += 'Preview (first 20 lines)\n\n'; - comment += '```\n'; - comment += preview; - comment += '\n```\n'; - comment += '
\n\n'; - } catch (err) { - console.log(`Could not read ${file}: ${err.message}`); - } + const fs = require('fs'); + + const prNum = context.payload.pull_request.number; + const files = execSync('git show --name-only --format= HEAD') + .toString().trim().split('\n') + .filter(f => f.endsWith('.qmd') || f.endsWith('.zh.qmd')); + + if (!files.length) return; + + let body = `## 🤖 Translation Preview\n\nTranslated **${files.length}** file(s):\n\n`; + for (const f of files) { + const preview = fs.existsSync(f) + ? fs.readFileSync(f, 'utf8').split('\n').slice(0, 20).join('\n') + : '(file not readable)'; + body += `
📄 \`${f}\`\n\n\`\`\`\n${preview}\n\`\`\`\n
\n\n`; } - + await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: comment + owner: context.repo.owner, repo: context.repo.repo, + issue_number: prNum, body }); - - - name: Create Pull Request for push events - if: github.event_name == 'push' && steps.git-check.outputs.changes == 'true' - id: create-pr + + - name: Create PR for push-triggered translations + if: steps.ctx.outputs.is_push == 'true' && steps.git-commit.outputs.committed == 'true' uses: actions/github-script@v7 env: CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} with: script: | - const baseBranch = '${{ steps.pr-info.outputs.base_branch }}'; - const headBranch = '${{ steps.pr-info.outputs.pr_ref }}'; - const changedFiles = process.env.CHANGED_FILES; - + const base = '${{ steps.ctx.outputs.base_branch }}'; + const head = '${{ steps.ctx.outputs.pr_ref }}'; const { data: pr } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `🤖 Auto-translate QMD files from ${baseBranch}`, - head: headBranch, - base: baseBranch, - body: `## 🤖 Automatic Translation - - This PR was automatically created by the auto-translate workflow after detecting changes to QMD files in the \`${baseBranch}\` branch. - - **Changed files** - \`\`\` - ${changedFiles} - \`\`\` - - ### What was done - - Detected new or modified .qmd files - - Generated translations for files without translation pairs - - Created this PR for review - - **Please review the translations for accuracy**, especially: - - Technical terminology - - Biomedical terms - - Code examples and their descriptions - - You can make manual corrections by editing the translated files directly in this PR. - - --- - *This PR was created automatically by the [Auto-Translate workflow](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).*` + owner: context.repo.owner, repo: context.repo.repo, + title: `🤖 Auto-translate QMD files from ${base}`, + head, base, + body: `## 🤖 Automatic Translation\n\nThis PR was created automatically after a push to \`${base}\`.\n\n**Changed files:**\n\`\`\`\n${process.env.CHANGED_FILES}\n\`\`\`\n\n> Please review translations for accuracy, especially biomedical terminology.\n\n---\n*Created by [Auto-Translate workflow](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*` }); - - core.setOutput('pr_number', pr.number); - core.setOutput('pr_url', pr.html_url); - console.log(`Created PR #${pr.number}: ${pr.html_url}`); - - - name: React to comment (if triggered by comment) - if: github.event_name == 'issue_comment' && steps.git-check.outputs.changes == 'true' - uses: actions/github-script@v7 - with: - script: | - await github.rest.reactions.createForIssueComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: context.payload.comment.id, - content: 'rocket' - }); - - - name: Comment on PR - if: github.event_name != 'push' && steps.git-check.outputs.changes == 'true' - uses: actions/github-script@v7 - env: - PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }} - with: - script: | - const fs = require('fs'); - let prNumber = process.env.PR_NUMBER; - - // Fallback to get PR number based on event type if not set - if (!prNumber) { - if (context.eventName === 'pull_request') { - prNumber = context.payload.pull_request.number; - } else if (context.eventName === 'issue_comment') { - prNumber = context.payload.issue.number; - } else { - prNumber = context.issue.number; - } - } - - const triggerMethod = context.eventName === 'issue_comment' ? 'manual command `/translate`' : - context.eventName === 'workflow_dispatch' ? 'manual workflow dispatch' : - 'automatic trigger'; - - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: '🤖 **Auto-Translation Complete**\n\n' + - `Triggered by: ${triggerMethod}\n\n` + - 'I have automatically translated the QMD files in this PR using external AI models.\n\n' + - '**Please review the translations for accuracy**, especially:\n' + - '- Technical terminology\n' + - '- Biomedical terms\n' + - '- Code examples and their descriptions\n\n' + - 'You can make manual corrections by editing the translated files directly.' - }); - - - name: Comment on created PR (for push events) - if: github.event_name == 'push' && steps.git-check.outputs.changes == 'true' && steps.create-pr.outputs.pr_number - uses: actions/github-script@v7 - env: - NEW_PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }} - with: - script: | - const prNumber = process.env.NEW_PR_NUMBER; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: '🤖 **Auto-Translation Complete**\n\n' + - 'Triggered by: push to main/master branch\n\n' + - 'I have automatically translated the QMD files and created this PR using external AI models.\n\n' + - '**Please review the translations for accuracy**, especially:\n' + - '- Technical terminology\n' + - '- Biomedical terms\n' + - '- Code examples and their descriptions\n\n' + - 'You can make manual corrections by editing the translated files directly in this PR.' - }); diff --git a/.github/workflows/generate-skills.yml b/.github/workflows/generate-skills.yml new file mode 100644 index 0000000000..28a00ef0f6 --- /dev/null +++ b/.github/workflows/generate-skills.yml @@ -0,0 +1,63 @@ +name: Generate Skills + +# Run whenever QMD tutorials are updated on the main branch, +# or manually via workflow_dispatch. +on: + push: + branches: [main, master] + paths: + - '**.qmd' + - '.github/scripts/generate_skills.py' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + generate-skills: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + + - name: Generate skill documents and JSON index + run: | + python .github/scripts/generate_skills.py \ + --input . \ + --output skills/ \ + --base-url "https://openbiox.github.io/Bizard/" \ + --format both \ + --verbose + + - name: Check for changes + id: git-check + run: | + if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard skills/)" ]; then + echo "committed=false" >> "$GITHUB_OUTPUT" + echo "No skill file changes detected." + else + echo "committed=true" >> "$GITHUB_OUTPUT" + echo "Skill files updated." + git status --short + fi + + - name: Commit and push skill documents + if: steps.git-check.outputs.committed == 'true' + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git add skills/ + git commit -m "chore: regenerate AI skill documents" \ + -m "Auto-generated skill documents from all QMD tutorials." + git push origin ${{ github.ref_name }} diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 62490aa087..412bb43cf6 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -12,8 +12,161 @@ env: RUST_BACKTRACE: 1 jobs: + # ── Job 1: Format check ──────────────────────────────────────────────────── + # Security note: Uses only the GitHub API to read changed file contents; + # NO checkout of untrusted PR code occurs in this job. + format-check: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Detect changed QMD files and run format check + id: format-check + uses: actions/github-script@v7 + with: + script: | + // ── Get list of changed files in the PR ─────────────────────── + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + }); + + const qmdFiles = files.filter(f => + (f.filename.endsWith('.qmd') || f.filename.endsWith('.Qmd')) && + !f.filename.endsWith('.zh.qmd') && + f.status !== 'removed' + ); + + if (qmdFiles.length === 0) { + core.setOutput('summary', 'No new QMD tutorial files to check.'); + core.setOutput('has_errors', 'false'); + return; + } + + // ── Validation helpers ──────────────────────────────────────── + const REQUIRED_YAML_FIELDS = ['title', 'author']; + const REQUIRED_SECTIONS = ['## Example', '## Setup', '## Data Preparation', '## Visualization']; + + const errors = {}; + const warnings = {}; + + for (const f of qmdFiles) { + let content; + try { + const { data: blob } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: f.filename, + ref: context.payload.pull_request.head.sha, + }); + content = Buffer.from(blob.content, 'base64').toString('utf8'); + } catch (e) { + console.log(`Could not fetch ${f.filename}: ${e.message}`); + continue; + } + + const fp = f.filename; + const fileErrors = []; + const fileWarnings = []; + + // 1. YAML frontmatter + const yamlMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!yamlMatch) { + fileErrors.push('Missing YAML frontmatter (expected `---` block at top)'); + } else { + const yaml = yamlMatch[1]; + for (const field of REQUIRED_YAML_FIELDS) { + if (!new RegExp(`^${field}\\s*:`, 'm').test(yaml)) { + fileErrors.push(`Missing required YAML field: \`${field}\``); + } + } + } + + // 2. Required sections + for (const section of REQUIRED_SECTIONS) { + if (!content.includes(section)) { + fileWarnings.push(`Missing recommended section: \`${section}\``); + } + } + + // 3. Figure code blocks should have labels + const codeBlockRe = /```\{r([^}]*)\}([\s\S]*?)```/g; + let m; + while ((m = codeBlockRe.exec(content)) !== null) { + const opts = m[1], body = m[2]; + if (body.includes('fig-cap') && !opts.includes('label')) { + const lineNo = content.slice(0, m.index).split('\n').length; + fileWarnings.push(`Code block near line ${lineNo} has \`fig-cap\` but no \`label\``); + } + } + + // 4. Demo image + if (!content.includes('![')) { + fileWarnings.push('No demo image found (recommended: `![](../images/...)`)'); + } + + if (fileErrors.length) errors[fp] = fileErrors; + if (fileWarnings.length) warnings[fp] = fileWarnings; + } + + // ── Build summary ────────────────────────────────────────────── + const hasErrors = Object.keys(errors).length > 0; + const lines = []; + + if (hasErrors) { + lines.push('### ❌ Format Errors (must fix)'); + for (const [fp, errs] of Object.entries(errors)) { + lines.push(`\n**\`${fp}\`**`); + errs.forEach(e => lines.push(`- ${e}`)); + } + } + if (Object.keys(warnings).length > 0) { + lines.push('\n### ⚠️ Format Warnings (recommended)'); + for (const [fp, warns] of Object.entries(warnings)) { + lines.push(`\n**\`${fp}\`**`); + warns.forEach(w => lines.push(`- ${w}`)); + } + } + if (!hasErrors && Object.keys(warnings).length === 0) { + lines.push(`### ✅ All ${qmdFiles.length} checked QMD file(s) pass format validation!`); + } + + const summary = lines.join('\n'); + core.setOutput('summary', summary); + core.setOutput('has_errors', String(hasErrors)); + + if (hasErrors) core.setFailed('QMD format errors found'); + + - name: Post format check comment on PR + if: always() + uses: actions/github-script@v7 + env: + FORMAT_SUMMARY: ${{ steps.format-check.outputs.summary }} + HAS_ERRORS: ${{ steps.format-check.outputs.has_errors }} + with: + script: | + const summary = process.env.FORMAT_SUMMARY || '(no output)'; + const hasErrors = process.env.HAS_ERRORS === 'true'; + const icon = hasErrors ? '❌' : '✅'; + + const body = `## ${icon} QMD Format Check\n\n${summary}\n\n` + + `> Automated check by the [PR Review workflow]` + + `(${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).\n` + + `> See [contribution guidance](../blob/main/Template/visualization_guidance_EN.qmd) for the expected tutorial format.`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: context.issue.number, body + }); + + # ── Job 2: Render preview ────────────────────────────────────────────────── build-deploy: runs-on: ubuntu-latest + needs: format-check + if: "!failure()" env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} permissions: diff --git a/Omics/CellChatCirclePlot.qmd b/Omics/CellChatCirclePlot.qmd new file mode 100644 index 0000000000..57dd14e336 --- /dev/null +++ b/Omics/CellChatCirclePlot.qmd @@ -0,0 +1,250 @@ +--- +title: "Cell-Cell Communication Circle Plot" +author: + - "**[Editor]** [Bizard Team](https://github.com/openbiox/Bizard);" + - "**[Contributors]** [ShiXiang Wang](https://github.com/ShixiangWang)." +--- + +The Cell-Cell Communication Circle Plot (细胞-细胞通讯网络圈图) is a specialized visualization for depicting intercellular signaling interactions inferred from single-cell RNA sequencing (scRNA-seq) data. Using the **CellChat** R package, this plot presents a circular network where nodes represent cell populations (cell types or clusters) and directed edges indicate the strength and direction of ligand-receptor communication signals between them. + +The arc width of each edge encodes communication probability, and the self-loops on each node represent autocrine signaling. Color coding maps each cell type to a distinct hue, and arrow directionality shows the sender-receiver relationship. This visualization is particularly powerful for discovering cross-talk between immune cells and tumor cells, identifying key signaling hubs in tissue microenvironments, and comparing communication networks across biological conditions. + +## Example + +![](../images/Omics/CellChatCirclePlot_demo.png){fig-alt="Cell-Cell Communication Circle Plot DEMO" fig-align="center" width="60%"} + +## Setup + +- System Requirements: Cross-platform (Linux/MacOS/Windows) + +- Programming Language: R + +- Dependencies: `CellChat`, `ggplot2`, `circlize` + +```{r packages setup, message=FALSE, warning=FALSE, output=FALSE} +# Install Bioconductor manager if needed +if (!requireNamespace("BiocManager", quietly = TRUE)) { + install.packages("BiocManager") +} + +# Install CRAN dependencies +for (pkg in c("ggplot2", "circlize", "igraph")) { + if (!requireNamespace(pkg, quietly = TRUE)) install.packages(pkg) +} + +# Install CellChat from GitHub (jinworks/CellChat is the current maintained fork) +if (!requireNamespace("CellChat", quietly = TRUE)) { + if (!requireNamespace("devtools", quietly = TRUE)) install.packages("devtools") + devtools::install_github("jinworks/CellChat") +} + +# Load packages +library(CellChat) +library(ggplot2) +library(circlize) +``` + +```{r session info} +sessioninfo::session_info("attached") +``` + +## Data Preparation + +For this tutorial, we build a simulated CellChat-compatible communication matrix so the +example is fully self-contained and runs without requiring an external dataset or model +inference. The same visualizations work identically with a real `CellChat` object produced +from your own scRNA-seq data. + +```{r simulate data, message=FALSE, warning=FALSE} +# Simulate a communication count/weight matrix +# In real use, these come from cellchat@net$count and cellchat@net$weight + +set.seed(42) +cell_types <- c("CD4 T", "CD8 T", "NK", "B cell", "Monocyte", + "DC", "Fibroblast", "Epithelial", "Endothelial") +n <- length(cell_types) + +net_count <- matrix(sample(0:50, n * n, replace = TRUE), n, n, + dimnames = list(cell_types, cell_types)) +net_weight <- matrix(runif(n * n, 0, 1), n, n, + dimnames = list(cell_types, cell_types)) +diag(net_count) <- 0 # remove self-communication +diag(net_weight) <- 0 + +# Group size (proportional to number of cells per type, simulated) +group_size <- sample(50:500, n, replace = TRUE) +names(group_size) <- cell_types + +cat("Simulated", n, "cell types with", sum(net_count), "total interactions\n") +print(net_count) +``` + +### Build a CellChat Object from Your Own Data (Reference) + +The following code is for reference only (`eval: false`). It shows how to build a CellChat +object from a Seurat scRNA-seq object before running the visualizations below. + +```{r upstream workflow, eval=FALSE, echo=TRUE} +library(Seurat) +library(CellChat) + +# 1. Extract normalised count matrix and metadata from a Seurat object +seurat_obj <- readRDS("your_seurat_object.rds") +data_input <- GetAssayData(seurat_obj, assay = "RNA", slot = "data") +meta <- seurat_obj@meta.data + +# 2. Create CellChat object +cellchat <- createCellChat(object = data_input, meta = meta, group.by = "cell_type") + +# 3. Set ligand-receptor database +cellchat@DB <- CellChatDB.human # or CellChatDB.mouse + +# 4. Pre-process & infer communication +cellchat <- subsetData(cellchat) +cellchat <- identifyOverExpressedGenes(cellchat) +cellchat <- identifyOverExpressedInteractions(cellchat) +cellchat <- computeCommunProb(cellchat, type = "triMean") +cellchat <- filterCommunication(cellchat, min.cells = 10) +cellchat <- computeCommunProbPathway(cellchat) +cellchat <- aggregateNet(cellchat) +# Now cellchat@net$count and cellchat@net$weight are available for plotting +``` + +## Visualization + +### 1. Circle Plot of Overall Cell-Cell Communication Network + +The overall circle plot summarizes all inferred communication interactions. Each node +represents a cell type; node size is proportional to the number of interactions it +participates in. Arc width encodes communication strength. + +```{r fig1-overall-circle, fig.width=9, fig.height=5, warning=FALSE} +#| fig-cap: "Overall Cell-Cell Communication Circle Plot" +#| out.width: "95%" + +par(mfrow = c(1, 2), xpd = TRUE) + +# Panel A: Number of interactions +netVisual_circle( + net_count, + vertex.weight = group_size, + weight.scale = TRUE, + label.edge = FALSE, + title.name = "Number of interactions" +) + +# Panel B: Interaction strength (weights) +netVisual_circle( + net_weight, + vertex.weight = group_size, + weight.scale = TRUE, + label.edge = FALSE, + title.name = "Interaction weights/strength" +) + +par(mfrow = c(1, 1)) +``` + +### 2. Circle Plot for Individual Cell Groups + +To highlight the outgoing signals from each cell type separately, we subset the weight +matrix row by row and produce one circle per cell type. + +```{r fig2-individual-circle, fig.width=10, fig.height=10, warning=FALSE} +#| fig-cap: "Per-Cell-Type Outgoing Communication Circle Plot" +#| out.width: "95%" + +n_types <- nrow(net_weight) +n_cols <- ceiling(sqrt(n_types)) +n_rows <- ceiling(n_types / n_cols) + +par(mfrow = c(n_rows, n_cols), xpd = TRUE, mar = c(1, 1, 2, 1)) + +for (i in seq_len(n_types)) { + mat_i <- matrix(0, nrow = n_types, ncol = n_types, + dimnames = dimnames(net_weight)) + mat_i[i, ] <- net_weight[i, ] # outgoing signals from cell type i + + netVisual_circle( + mat_i, + vertex.weight = group_size, + weight.scale = TRUE, + edge.weight.max = max(net_weight), + title.name = rownames(net_weight)[i] + ) +} + +par(mfrow = c(1, 1)) +``` + +### 3. Chord Diagram + +The chord diagram provides an alternative circular view using `circlize`, clearly showing +bidirectional communication volumes between cell type pairs. + +```{r fig3-chord, fig.width=7, fig.height=7, warning=FALSE} +#| fig-cap: "Cell-Cell Communication Chord Diagram" +#| out.width: "80%" + +# Use circlize for a chord diagram of communication weights +chordDiagram( + net_weight, + transparency = 0.4, + annotationTrack = "grid", + preAllocateTracks = 1 +) + +circos.trackPlotRegion( + track.index = 1, + panel.fun = function(x, y) { + circos.text( + CELL_META$xcenter, + CELL_META$ylim[1] + 0.1, + CELL_META$sector.index, + facing = "clockwise", + niceFacing = TRUE, + adj = c(0, 0.5), + cex = 0.7 + ) + }, + bg.border = NA +) + +title("Cell-Cell Communication (Chord Diagram)") +circos.clear() +``` + +### 4. Communication Heatmap + +A heatmap view allows rapid identification of dominant sender-receiver pairs and +asymmetric communication patterns. + +```{r fig4-heatmap, fig.width=7, fig.height=6, warning=FALSE} +#| fig-cap: "Cell-Cell Communication Heatmap" +#| out.width: "80%" + +df_heat <- as.data.frame(as.table(net_weight)) +colnames(df_heat) <- c("Sender", "Receiver", "Weight") + +ggplot(df_heat, aes(x = Receiver, y = Sender, fill = Weight)) + + geom_tile(color = "white", linewidth = 0.4) + + scale_fill_gradient(low = "white", high = "#b74147", name = "Weight") + + theme_minimal(base_size = 11) + + theme( + axis.text.x = element_text(angle = 45, hjust = 1), + panel.grid = element_blank(), + plot.title = element_text(size = 13, face = "bold", hjust = 0.5) + ) + + labs( + title = "Cell-Cell Communication Heatmap", + x = "Receiver", y = "Sender" + ) +``` + +## References + +\[1\] Jin S, Guerrero-Juarez CF, Zhang L, et al. Inference and analysis of cell-cell communication using CellChat. *Nature Communications*. 2021;12(1):1088. + +\[2\] Jin S, et al. CellChat for systematic analysis of cell-cell communication from single-cell transcriptomics. *Nature Protocols*. 2024. + +\[3\] CellChat GitHub: diff --git a/Omics/CellChatCirclePlot.zh.qmd b/Omics/CellChatCirclePlot.zh.qmd new file mode 100644 index 0000000000..c21e22c3dc --- /dev/null +++ b/Omics/CellChatCirclePlot.zh.qmd @@ -0,0 +1,240 @@ +--- +title: "细胞-细胞通讯网络圈图" +author: + - "**[编辑]** [Bizard 团队](https://github.com/openbiox/Bizard);" + - "**[贡献]** [王诗翔](https://github.com/ShixiangWang)." +--- + +细胞-细胞通讯网络圈图是一种专用于描绘来自单细胞 RNA 测序(scRNA-seq)数据推断的细胞间信号交互的可视化方法。使用 **CellChat** R 包,该图以圆形网络的形式展示,其中节点代表细胞群(细胞类型或细胞簇),有向边表示细胞类型之间配体-受体通讯信号的强度和方向。 + +每条边的弧宽编码通讯概率,每个节点上的自环代表自分泌信号。颜色编码将每种细胞类型映射到不同颜色,箭头方向显示发送者-接收者关系。该可视化方法特别适用于发现免疫细胞与肿瘤细胞之间的交叉信号,识别组织微环境中的关键信号中枢,以及比较不同生物学条件下的通讯网络。 + +## 示例 + +![](../images/Omics/CellChatCirclePlot_demo.png){fig-alt="Cell-Cell Communication Circle Plot DEMO" fig-align="center" width="60%"} + +## 环境配置 + +- 系统要求:跨平台(Linux/MacOS/Windows) + +- 编程语言:R + +- 依赖包:`CellChat`、`ggplot2`、`circlize` + +```{r packages setup, message=FALSE, warning=FALSE, output=FALSE} +# 如有需要,安装 Bioconductor 管理器 +if (!requireNamespace("BiocManager", quietly = TRUE)) { + install.packages("BiocManager") +} + +# 安装 CRAN 依赖 +for (pkg in c("ggplot2", "circlize", "igraph")) { + if (!requireNamespace(pkg, quietly = TRUE)) install.packages(pkg) +} + +# 从 GitHub 安装 CellChat(jinworks/CellChat 为当前维护版本) +if (!requireNamespace("CellChat", quietly = TRUE)) { + if (!requireNamespace("devtools", quietly = TRUE)) install.packages("devtools") + devtools::install_github("jinworks/CellChat") +} + +# 加载包 +library(CellChat) +library(ggplot2) +library(circlize) +``` + +```{r session info} +sessioninfo::session_info("attached") +``` + +## 数据准备 + +本教程使用模拟的 CellChat 兼容通讯矩阵,使示例完全自包含,无需外部数据集或模型推断即可运行。对于使用真实 `CellChat` 对象(由您自己的 scRNA-seq 数据生成)的用户,相同的可视化代码同样适用。 + +```{r simulate data, message=FALSE, warning=FALSE} +# 模拟通讯计数/权重矩阵 +# 在实际使用中,这些数据来自 cellchat@net$count 和 cellchat@net$weight + +set.seed(42) +cell_types <- c("CD4 T", "CD8 T", "NK", "B cell", "Monocyte", + "DC", "Fibroblast", "Epithelial", "Endothelial") +n <- length(cell_types) + +net_count <- matrix(sample(0:50, n * n, replace = TRUE), n, n, + dimnames = list(cell_types, cell_types)) +net_weight <- matrix(runif(n * n, 0, 1), n, n, + dimnames = list(cell_types, cell_types)) +diag(net_count) <- 0 # 移除自分泌通讯 +diag(net_weight) <- 0 + +# 每种细胞类型的细胞数量(模拟) +group_size <- sample(50:500, n, replace = TRUE) +names(group_size) <- cell_types + +cat("模拟了", n, "种细胞类型,共", sum(net_count), "次交互\n") +print(net_count) +``` + +### 使用自有数据构建 CellChat 对象(参考代码) + +以下代码仅供参考(`eval: false`),展示如何从 Seurat scRNA-seq 对象构建 CellChat 对象,然后进行下方的可视化。 + +```{r upstream workflow, eval=FALSE, echo=TRUE} +library(Seurat) +library(CellChat) + +# 1. 从 Seurat 对象提取标准化计数矩阵和元数据 +seurat_obj <- readRDS("your_seurat_object.rds") +data_input <- GetAssayData(seurat_obj, assay = "RNA", slot = "data") +meta <- seurat_obj@meta.data + +# 2. 创建 CellChat 对象 +cellchat <- createCellChat(object = data_input, meta = meta, group.by = "cell_type") + +# 3. 设置配体-受体数据库 +cellchat@DB <- CellChatDB.human # 小鼠数据使用 CellChatDB.mouse + +# 4. 预处理与推断通讯 +cellchat <- subsetData(cellchat) +cellchat <- identifyOverExpressedGenes(cellchat) +cellchat <- identifyOverExpressedInteractions(cellchat) +cellchat <- computeCommunProb(cellchat, type = "triMean") +cellchat <- filterCommunication(cellchat, min.cells = 10) +cellchat <- computeCommunProbPathway(cellchat) +cellchat <- aggregateNet(cellchat) +# 此后 cellchat@net$count 和 cellchat@net$weight 即可用于绘图 +``` + +## 可视化 + +### 1. 整体细胞-细胞通讯圈图 + +整体圈图汇总所有推断的通讯交互。每个节点代表一种细胞类型;节点大小与该细胞类型参与的交互数量成正比。弧宽编码通讯强度。 + +```{r fig1-overall-circle, fig.width=9, fig.height=5, warning=FALSE} +#| fig-cap: "整体细胞-细胞通讯圈图" +#| out.width: "95%" + +par(mfrow = c(1, 2), xpd = TRUE) + +# 面板 A:交互数量 +netVisual_circle( + net_count, + vertex.weight = group_size, + weight.scale = TRUE, + label.edge = FALSE, + title.name = "交互数量" +) + +# 面板 B:交互强度(权重) +netVisual_circle( + net_weight, + vertex.weight = group_size, + weight.scale = TRUE, + label.edge = FALSE, + title.name = "交互权重/强度" +) + +par(mfrow = c(1, 1)) +``` + +### 2. 单细胞类型通讯圈图 + +逐行对权重矩阵进行子集化,分别展示每种细胞类型的输出信号。 + +```{r fig2-individual-circle, fig.width=10, fig.height=10, warning=FALSE} +#| fig-cap: "各细胞类型输出通讯圈图" +#| out.width: "95%" + +n_types <- nrow(net_weight) +n_cols <- ceiling(sqrt(n_types)) +n_rows <- ceiling(n_types / n_cols) + +par(mfrow = c(n_rows, n_cols), xpd = TRUE, mar = c(1, 1, 2, 1)) + +for (i in seq_len(n_types)) { + mat_i <- matrix(0, nrow = n_types, ncol = n_types, + dimnames = dimnames(net_weight)) + mat_i[i, ] <- net_weight[i, ] # 第 i 种细胞类型的输出信号 + + netVisual_circle( + mat_i, + vertex.weight = group_size, + weight.scale = TRUE, + edge.weight.max = max(net_weight), + title.name = rownames(net_weight)[i] + ) +} + +par(mfrow = c(1, 1)) +``` + +### 3. 弦图 + +弦图使用 `circlize` 提供了另一种圆形视图,清晰展示细胞类型对之间的双向通讯量。 + +```{r fig3-chord, fig.width=7, fig.height=7, warning=FALSE} +#| fig-cap: "细胞-细胞通讯弦图" +#| out.width: "80%" + +chordDiagram( + net_weight, + transparency = 0.4, + annotationTrack = "grid", + preAllocateTracks = 1 +) + +circos.trackPlotRegion( + track.index = 1, + panel.fun = function(x, y) { + circos.text( + CELL_META$xcenter, + CELL_META$ylim[1] + 0.1, + CELL_META$sector.index, + facing = "clockwise", + niceFacing = TRUE, + adj = c(0, 0.5), + cex = 0.7 + ) + }, + bg.border = NA +) + +title("细胞-细胞通讯(弦图)") +circos.clear() +``` + +### 4. 通讯热图 + +热图视图可快速识别主要的发送者-接收者细胞对及不对称通讯模式。 + +```{r fig4-heatmap, fig.width=7, fig.height=6, warning=FALSE} +#| fig-cap: "细胞-细胞通讯热图" +#| out.width: "80%" + +df_heat <- as.data.frame(as.table(net_weight)) +colnames(df_heat) <- c("发送者", "接收者", "权重") + +ggplot(df_heat, aes(x = 接收者, y = 发送者, fill = 权重)) + + geom_tile(color = "white", linewidth = 0.4) + + scale_fill_gradient(low = "white", high = "#b74147", name = "权重") + + theme_minimal(base_size = 11) + + theme( + axis.text.x = element_text(angle = 45, hjust = 1), + panel.grid = element_blank(), + plot.title = element_text(size = 13, face = "bold", hjust = 0.5) + ) + + labs( + title = "细胞-细胞通讯热图", + x = "接收者", y = "发送者" + ) +``` + +## 参考文献 + +\[1\] Jin S, Guerrero-Juarez CF, Zhang L 等. 使用 CellChat 推断和分析细胞-细胞通讯. *Nature Communications*. 2021;12(1):1088. + +\[2\] Jin S 等. 基于单细胞转录组系统分析细胞-细胞通讯的 CellChat. *Nature Protocols*. 2024. + +\[3\] CellChat GitHub: diff --git a/Omics/MebocostFlowPlot.qmd b/Omics/MebocostFlowPlot.qmd new file mode 100644 index 0000000000..e8cf0ce774 --- /dev/null +++ b/Omics/MebocostFlowPlot.qmd @@ -0,0 +1,405 @@ +--- +title: "MEBOCOST Metabolic Flow Plot" +author: + - "**[Editor]** [Bizard Team](https://github.com/openbiox/Bizard);" + - "**[Contributors]** [ShiXiang Wang](https://github.com/ShixiangWang)." +--- + +The MEBOCOST Metabolic Flow Plot (流图) is an innovative visualization developed within the **MEBOCOST** (Metabolite-mediated Cell Communication Prediction) framework. It depicts metabolite-mediated intercellular communication by rendering directed flow arrows between cell types, where the thickness and color of each flow encode the magnitude and direction of predicted metabolic signaling. + +Unlike conventional cell-cell communication tools that focus solely on protein ligand-receptor pairs, MEBOCOST explicitly models small metabolites (e.g., lipids, amino acids, sugars) as signaling molecules. The flow plot integrates: + +- **Sender cells** (metabolite producers) and **receiver cells** (metabolite sensor-expressing cells) +- **Communication score** mapped to arrow width and opacity +- **Metabolite identity** optionally color-coded on edges + +This chart is particularly suited for studying metabolic crosstalk in tumor microenvironments, tissue development, and metabolic disease contexts where small-molecule signaling complements protein-based communication. + +## Example + +![](../images/Omics/MebocostFlowPlot_demo.png){fig-alt="MEBOCOST Flow Plot DEMO" fig-align="center" width="60%"} + +## Setup + +- System Requirements: Cross-platform (Linux/MacOS/Windows) + +- Programming Language: R (primary) / Python (MEBOCOST backend) + +- Dependencies: `MEBOCOST`, `ggplot2`, `dplyr`, `ggraph`, `igraph`, `tidygraph` + +::: callout-note +MEBOCOST has both Python and R interfaces. The R wrapper (`MEBOCOST`) calls the Python back-end internally. If you prefer the pure Python workflow, see the [MEBOCOST Python documentation](https://wwylab.github.io/mebocost/). +::: + +```{r packages setup, message=FALSE, warning=FALSE, output=FALSE} +# Install CRAN dependencies +for (pkg in c("ggplot2", "dplyr", "ggraph", "igraph", "tidygraph", + "patchwork", "scales", "RColorBrewer")) { + if (!requireNamespace(pkg, quietly = TRUE)) install.packages(pkg) +} + +# Install MEBOCOST R package from GitHub +if (!requireNamespace("MEBOCOST", quietly = TRUE)) { + if (!requireNamespace("devtools", quietly = TRUE)) install.packages("devtools") + devtools::install_github("kaifuchenlab/MEBOCOST", subdir = "R") +} + +# Load packages +library(ggplot2) +library(dplyr) +library(ggraph) +library(igraph) +library(tidygraph) +library(patchwork) +library(scales) +``` + +```{r session info} +sessioninfo::session_info("attached") +``` + +## Data Preparation + +### 1. Overview of the MEBOCOST Workflow (Reference) + +A typical MEBOCOST analysis proceeds as follows: + +```{r upstream workflow} +#| eval: false +#| echo: true + +library(MEBOCOST) + +# Step 1: Load scRNA-seq expression data and metadata +data_input <- readRDS("your_scRNA_normalized.rds") # genes × cells matrix +cell_meta <- read.csv("your_cell_metadata.csv") # cell type annotation + +# Step 2: Run MEBOCOST inference +# MEBOCOST predicts metabolite production (enzymes) and sensing (transporters/GPCRs) +mebo_result <- mebocost_flow( + data = data_input, + meta = cell_meta, + group.by = "cell_type", + species = "human" # or "mouse" +) + +# Step 3: The result contains a communication table: +# sender | receiver | metabolite | score | p.value +head(mebo_result@communication) +``` + +### 2. Load Example Data + +For this tutorial we prepare a synthetic communication table that mirrors the MEBOCOST output format, keeping the example fully self-contained. + +```{r load data, message=FALSE, warning=FALSE} +# Attempt to download a pre-built MEBOCOST result from Bizard data repository +mebo_url <- "https://bizard-1301043367.cos.ap-guangzhou.myqcloud.com/MEBOCOST_example.rds" +local_rds <- tempfile(fileext = ".rds") + +download_ok <- tryCatch({ + download.file(mebo_url, local_rds, quiet = TRUE) + file.exists(local_rds) && file.size(local_rds) > 1000 +}, error = function(e) FALSE) + +if (download_ok) { + mebo_comm <- readRDS(local_rds) + message("Loaded pre-built MEBOCOST example.") +} else { + # Fallback: simulate a realistic communication table + message("Using simulated MEBOCOST communication data for demonstration.") + + set.seed(2024) + cell_types <- c("Cancer", "T cell", "NK", "Macrophage", + "Fibroblast", "Endothelial", "B cell") + metabolites <- c("Glucose", "Lactate", "Glutamine", "Glutamate", + "Succinate", "Arginine", "Tryptophan", "Sphingosine", + "Palmitate", "Citrate") + + n_pairs <- 60 + mebo_comm <- data.frame( + sender = sample(cell_types, n_pairs, replace = TRUE), + receiver = sample(cell_types, n_pairs, replace = TRUE), + metabolite = sample(metabolites, n_pairs, replace = TRUE), + score = runif(n_pairs, 0.1, 1.0), + p_value = runif(n_pairs, 0.001, 0.05) + ) %>% + filter(sender != receiver) %>% # remove self-loops + group_by(sender, receiver, metabolite) %>% + summarise(score = mean(score), + p_value = min(p_value), + .groups = "drop") + + message(sprintf("Simulated %d sender-receiver-metabolite triplets.", nrow(mebo_comm))) +} + +head(mebo_comm) +``` + +### 3. Aggregate Communication Scores + +```{r aggregate scores, message=FALSE} +# Aggregate across metabolites: total communication score between each cell pair +agg_comm <- mebo_comm %>% + group_by(sender, receiver) %>% + summarise( + total_score = sum(score), + n_metabolites = n_distinct(metabolite), + .groups = "drop" + ) %>% + arrange(desc(total_score)) + +head(agg_comm) +``` + +## Visualization + +### 1. Basic Flow Plot (Arrow Network) + +The basic flow plot uses `ggraph` to draw directed arrows between sender and receiver cell types. Arrow width encodes total communication score. + +```{r fig1-flow-basic, fig.width=8, fig.height=7, warning=FALSE} +#| fig-cap: "Basic MEBOCOST Metabolic Flow Plot" +#| out.width: "85%" + +# Build graph object +g_basic <- graph_from_data_frame( + d = agg_comm %>% rename(weight = total_score), + directed = TRUE, + vertices = data.frame(name = unique(c(agg_comm$sender, agg_comm$receiver))) +) + +set.seed(42) +p1 <- ggraph(g_basic, layout = "circle") + + geom_edge_arc( + aes(width = weight, alpha = weight), + arrow = arrow(length = unit(3, "mm"), type = "closed"), + end_cap = circle(6, "mm"), + start_cap = circle(6, "mm"), + color = "#3a86ff", + curvature = 0.2 + ) + + geom_node_point(size = 12, color = "#ffbe0b", shape = 21, + fill = "#fb5607", stroke = 1.5) + + geom_node_label(aes(label = name), repel = FALSE, size = 3.2, + fontface = "bold", color = "white", + fill = NA, label.size = 0) + + scale_edge_width_continuous(range = c(0.5, 4), name = "Total Score") + + scale_edge_alpha_continuous(range = c(0.3, 1), guide = "none") + + labs( + title = "Metabolic Cell-Cell Communication Flow", + subtitle = "Arrow width = aggregated communication score" + ) + + theme_graph(base_family = "sans") + + theme( + plot.title = element_text(size = 14, face = "bold", hjust = 0.5), + plot.subtitle = element_text(size = 10, hjust = 0.5), + legend.position = "bottom" + ) + +p1 +``` + +### 2. Flow Plot Colored by Top Metabolite + +Color each arrow by the identity of the metabolite with the highest communication score for that cell pair. + +```{r fig2-flow-colored, fig.width=9, fig.height=8, warning=FALSE} +#| fig-cap: "Flow Plot Colored by Dominant Metabolite" +#| out.width: "85%" + +# Find dominant metabolite for each sender-receiver pair +dominant_meta <- mebo_comm %>% + group_by(sender, receiver) %>% + slice_max(score, n = 1, with_ties = FALSE) %>% + ungroup() %>% + select(sender, receiver, metabolite, score) + +# Select top metabolites for legible legend +top_metabolites <- dominant_meta %>% + count(metabolite, sort = TRUE) %>% + slice_head(n = 8) %>% + pull(metabolite) + +dominant_meta <- dominant_meta %>% + mutate( + meta_label = if_else(metabolite %in% top_metabolites, + metabolite, "Other") + ) + +# Build graph +g_meta <- graph_from_data_frame( + d = dominant_meta %>% rename(weight = score), + directed = TRUE, + vertices = data.frame(name = unique(c(dominant_meta$sender, dominant_meta$receiver))) +) + +# Add metabolite edge attribute +E(g_meta)$meta_label <- dominant_meta$meta_label + +# Color palette +n_meta <- length(unique(dominant_meta$meta_label)) +pal_meta <- c(RColorBrewer::brewer.pal(min(n_meta, 9), "Set1"), + "grey70")[seq_len(n_meta)] + +set.seed(42) +p2 <- ggraph(g_meta, layout = "circle") + + geom_edge_arc( + aes(width = weight, color = meta_label, alpha = weight), + arrow = arrow(length = unit(3, "mm"), type = "closed"), + end_cap = circle(6, "mm"), + start_cap = circle(6, "mm"), + curvature = 0.2 + ) + + geom_node_point(size = 14, shape = 21, + fill = "#4361ee", color = "white", stroke = 2) + + geom_node_label(aes(label = name), size = 3, + fontface = "bold", color = "white", + fill = NA, label.size = 0) + + scale_edge_width_continuous(range = c(0.5, 4), name = "Score") + + scale_edge_alpha_continuous(range = c(0.4, 1), guide = "none") + + scale_edge_color_manual(values = pal_meta, name = "Metabolite") + + labs( + title = "Metabolic Flow Colored by Dominant Metabolite", + subtitle = "Arrow color = metabolite with highest communication score" + ) + + theme_graph(base_family = "sans") + + theme( + plot.title = element_text(size = 14, face = "bold", hjust = 0.5), + plot.subtitle = element_text(size = 10, hjust = 0.5), + legend.position = "right" + ) + +p2 +``` + +### 3. Flow Bubble Summary Plot + +An alternative representation uses a bubble matrix where rows are senders, columns are receivers, and bubble size encodes communication strength. + +```{r fig3-bubble-summary, fig.width=8, fig.height=6, warning=FALSE} +#| fig-cap: "Communication Bubble Summary Matrix" +#| out.width: "80%" + +p3 <- ggplot(agg_comm, + aes(x = receiver, y = sender, + size = total_score, color = n_metabolites)) + + geom_point(alpha = 0.8) + + scale_size_continuous(name = "Total Score", range = c(2, 14)) + + scale_color_gradient( + name = "# Metabolites", + low = "#90e0ef", + high = "#03045e" + ) + + theme_bw(base_size = 11) + + theme( + axis.text.x = element_text(angle = 45, hjust = 1), + panel.grid.major = element_line(color = "grey90"), + plot.title = element_text(size = 13, face = "bold", hjust = 0.5) + ) + + labs( + title = "Metabolic Communication Bubble Matrix", + x = "Receiver Cell Type", + y = "Sender Cell Type" + ) + +p3 +``` + +### 4. Top Sender/Receiver Bar Charts + +Identify which cell types are the most active senders and receivers. + +```{r fig4-bar-charts, fig.width=10, fig.height=5, warning=FALSE} +#| fig-cap: "Top Senders and Receivers by Metabolic Communication" +#| out.width: "95%" + +# Top senders +p_sender <- agg_comm %>% + group_by(sender) %>% + summarise(outgoing = sum(total_score), .groups = "drop") %>% + arrange(desc(outgoing)) %>% + ggplot(aes(x = reorder(sender, outgoing), y = outgoing, fill = outgoing)) + + geom_col(width = 0.7, color = "white") + + scale_fill_gradient(low = "#caf0f8", high = "#0077b6", guide = "none") + + coord_flip() + + theme_classic(base_size = 11) + + labs(title = "Top Senders", x = NULL, y = "Total Outgoing Score") + +# Top receivers +p_receiver <- agg_comm %>% + group_by(receiver) %>% + summarise(incoming = sum(total_score), .groups = "drop") %>% + arrange(desc(incoming)) %>% + ggplot(aes(x = reorder(receiver, incoming), y = incoming, fill = incoming)) + + geom_col(width = 0.7, color = "white") + + scale_fill_gradient(low = "#ffd6a5", high = "#f77f00", guide = "none") + + coord_flip() + + theme_classic(base_size = 11) + + labs(title = "Top Receivers", x = NULL, y = "Total Incoming Score") + +p_sender + p_receiver +``` + +### 5. Heatmap of Metabolite-Level Communication + +Visualize communication at metabolite resolution to identify the most prominent metabolic signals. + +```{r fig5-metabolite-heatmap, fig.width=10, fig.height=7, warning=FALSE} +#| fig-cap: "Metabolite-Level Communication Heatmap" +#| out.width: "90%" + +# Create a pair label for each sender-receiver combination +heat_data <- mebo_comm %>% + mutate(pair = paste(sender, "→", receiver)) %>% + group_by(pair, metabolite) %>% + summarise(score = sum(score), .groups = "drop") + +# Keep top pairs and metabolites for readability +top_pairs <- heat_data %>% + group_by(pair) %>% + summarise(total = sum(score), .groups = "drop") %>% + slice_max(total, n = 12) %>% + pull(pair) + +top_metas <- heat_data %>% + group_by(metabolite) %>% + summarise(total = sum(score), .groups = "drop") %>% + slice_max(total, n = 10) %>% + pull(metabolite) + +heat_sub <- heat_data %>% + filter(pair %in% top_pairs, metabolite %in% top_metas) + +ggplot(heat_sub, + aes(x = metabolite, y = pair, fill = score)) + + geom_tile(color = "white", linewidth = 0.4) + + scale_fill_gradient2( + name = "Score", + low = "white", + mid = "#ffb3c6", + high = "#d00000", + midpoint = median(heat_sub$score) + ) + + theme_minimal(base_size = 11) + + theme( + axis.text.x = element_text(angle = 45, hjust = 1), + panel.grid = element_blank(), + plot.title = element_text(size = 13, face = "bold", hjust = 0.5) + ) + + labs( + title = "Metabolite-Level Communication Heatmap", + x = "Metabolite", + y = "Sender → Receiver" + ) +``` + +## References + +\[1\] Chen K, et al. MEBOCOST: metabolite-mediated cell communication modeling by single cell transcriptome. *bioRxiv*. 2022. + +\[2\] MEBOCOST GitHub: + +\[3\] MEBOCOST Documentation: + +\[4\] Demo Notebook: diff --git a/Omics/MebocostFlowPlot.zh.qmd b/Omics/MebocostFlowPlot.zh.qmd new file mode 100644 index 0000000000..2755f02932 --- /dev/null +++ b/Omics/MebocostFlowPlot.zh.qmd @@ -0,0 +1,396 @@ +--- +title: "MEBOCOST 代谢流图" +author: + - "**[编辑]** [Bizard 团队](https://github.com/openbiox/Bizard);" + - "**[贡献]** [王诗翔](https://github.com/ShixiangWang)." +--- + +MEBOCOST 代谢流图(流图)是在 **MEBOCOST**(代谢物介导的细胞通讯预测)框架内开发的创新可视化方法。它通过在细胞类型之间绘制有向流箭头来描绘代谢物介导的细胞间通讯,其中每条流的粗细和颜色编码预测代谢信号的大小和方向。 + +与仅关注蛋白质配体-受体对的传统细胞-细胞通讯工具不同,MEBOCOST 明确地将小分子代谢物(如脂质、氨基酸、糖类)建模为信号分子。流图整合了: + +- **发送细胞**(代谢物生产者)和**接收细胞**(表达代谢物感应器的细胞) +- 映射到箭头宽度和不透明度的**通讯评分** +- 边上可选的代谢物身份**颜色编码** + +该图表特别适用于研究肿瘤微环境、组织发育和代谢疾病背景下的代谢串扰,在这些场景中小分子信号传导对蛋白质通讯起到补充作用。 + +## 示例 + +![](../images/Omics/MebocostFlowPlot_demo.png){fig-alt="MEBOCOST Flow Plot DEMO" fig-align="center" width="60%"} + +## 环境配置 + +- 系统要求:跨平台(Linux/MacOS/Windows) + +- 编程语言:R(主要)/ Python(MEBOCOST 后端) + +- 依赖包:`ggplot2`、`dplyr`、`ggraph`、`igraph`、`tidygraph` + +::: callout-note +MEBOCOST 同时提供 Python 和 R 接口。R 封装包(`MEBOCOST`)在内部调用 Python 后端。如果您希望使用纯 Python 工作流,请参阅 [MEBOCOST Python 文档](https://wwylab.github.io/mebocost/)。 +::: + +```{r packages setup, message=FALSE, warning=FALSE, output=FALSE} +# 安装 CRAN 依赖 +for (pkg in c("ggplot2", "dplyr", "ggraph", "igraph", "tidygraph", + "patchwork", "scales", "RColorBrewer")) { + if (!requireNamespace(pkg, quietly = TRUE)) install.packages(pkg) +} + +# 从 GitHub 安装 MEBOCOST R 包 +if (!requireNamespace("MEBOCOST", quietly = TRUE)) { + if (!requireNamespace("devtools", quietly = TRUE)) install.packages("devtools") + devtools::install_github("kaifuchenlab/MEBOCOST", subdir = "R") +} + +# 加载包 +library(ggplot2) +library(dplyr) +library(ggraph) +library(igraph) +library(tidygraph) +library(patchwork) +library(scales) +``` + +```{r session info} +sessioninfo::session_info("attached") +``` + +## 数据准备 + +### 1. MEBOCOST 工作流概述(参考代码) + +典型的 MEBOCOST 分析流程如下: + +```{r upstream workflow, eval=FALSE, echo=TRUE} +library(MEBOCOST) + +# 第 1 步:加载 scRNA-seq 表达数据和元数据 +data_input <- readRDS("your_scRNA_normalized.rds") # 基因 × 细胞矩阵 +cell_meta <- read.csv("your_cell_metadata.csv") # 细胞类型注释 + +# 第 2 步:运行 MEBOCOST 推断 +# MEBOCOST 预测代谢物产生(酶)和感应(转运蛋白/GPCR) +mebo_result <- mebocost_flow( + data = data_input, + meta = cell_meta, + group.by = "cell_type", + species = "human" # 小鼠数据使用 "mouse" +) + +# 第 3 步:结果包含一个通讯表格: +# sender | receiver | metabolite | score | p.value +head(mebo_result@communication) +``` + +### 2. 加载示例数据 + +本教程使用与 MEBOCOST 输出格式相同的合成通讯表格,使示例完全自包含。 + +```{r load data, message=FALSE, warning=FALSE} +# 尝试从 Bizard 数据仓库下载预构建的 MEBOCOST 结果 +mebo_url <- "https://bizard-1301043367.cos.ap-guangzhou.myqcloud.com/MEBOCOST_example.rds" +local_rds <- tempfile(fileext = ".rds") + +download_ok <- tryCatch({ + download.file(mebo_url, local_rds, quiet = TRUE) + file.exists(local_rds) && file.size(local_rds) > 1000 +}, error = function(e) FALSE) + +if (download_ok) { + mebo_comm <- readRDS(local_rds) + message("已加载预构建的 MEBOCOST 示例。") +} else { + # 回退:模拟真实的通讯表格 + message("使用模拟的 MEBOCOST 通讯数据进行演示。") + + set.seed(2024) + cell_types <- c("Cancer", "T cell", "NK", "Macrophage", + "Fibroblast", "Endothelial", "B cell") + metabolites <- c("Glucose", "Lactate", "Glutamine", "Glutamate", + "Succinate", "Arginine", "Tryptophan", "Sphingosine", + "Palmitate", "Citrate") + + n_pairs <- 60 + mebo_comm <- data.frame( + sender = sample(cell_types, n_pairs, replace = TRUE), + receiver = sample(cell_types, n_pairs, replace = TRUE), + metabolite = sample(metabolites, n_pairs, replace = TRUE), + score = runif(n_pairs, 0.1, 1.0), + p_value = runif(n_pairs, 0.001, 0.05) + ) %>% + filter(sender != receiver) %>% + group_by(sender, receiver, metabolite) %>% + summarise(score = mean(score), + p_value = min(p_value), + .groups = "drop") + + message(sprintf("模拟了 %d 个发送者-接收者-代谢物三元组。", nrow(mebo_comm))) +} + +head(mebo_comm) +``` + +### 3. 聚合通讯评分 + +```{r aggregate scores, message=FALSE} +# 跨代谢物聚合:计算每对细胞之间的总通讯评分 +agg_comm <- mebo_comm %>% + group_by(sender, receiver) %>% + summarise( + total_score = sum(score), + n_metabolites = n_distinct(metabolite), + .groups = "drop" + ) %>% + arrange(desc(total_score)) + +head(agg_comm) +``` + +## 可视化 + +### 1. 基础流图(箭头网络) + +基础流图使用 `ggraph` 在发送者和接收者细胞类型之间绘制有向箭头。箭头宽度编码总通讯评分。 + +```{r fig1-flow-basic, fig.width=8, fig.height=7, warning=FALSE} +#| fig-cap: "MEBOCOST 代谢流图(基础版)" +#| out.width: "85%" + +# 构建图对象 +g_basic <- graph_from_data_frame( + d = agg_comm %>% rename(weight = total_score), + directed = TRUE, + vertices = data.frame(name = unique(c(agg_comm$sender, agg_comm$receiver))) +) + +set.seed(42) +p1 <- ggraph(g_basic, layout = "circle") + + geom_edge_arc( + aes(width = weight, alpha = weight), + arrow = arrow(length = unit(3, "mm"), type = "closed"), + end_cap = circle(6, "mm"), + start_cap = circle(6, "mm"), + color = "#3a86ff", + curvature = 0.2 + ) + + geom_node_point(size = 12, color = "#ffbe0b", shape = 21, + fill = "#fb5607", stroke = 1.5) + + geom_node_label(aes(label = name), repel = FALSE, size = 3.2, + fontface = "bold", color = "white", + fill = NA, label.size = 0) + + scale_edge_width_continuous(range = c(0.5, 4), name = "总评分") + + scale_edge_alpha_continuous(range = c(0.3, 1), guide = "none") + + labs( + title = "代谢细胞-细胞通讯流图", + subtitle = "箭头宽度 = 聚合通讯评分" + ) + + theme_graph(base_family = "sans") + + theme( + plot.title = element_text(size = 14, face = "bold", hjust = 0.5), + plot.subtitle = element_text(size = 10, hjust = 0.5), + legend.position = "bottom" + ) + +p1 +``` + +### 2. 按主要代谢物着色的流图 + +对每对细胞的箭头按通讯评分最高的代谢物身份着色。 + +```{r fig2-flow-colored, fig.width=9, fig.height=8, warning=FALSE} +#| fig-cap: "按主要代谢物着色的流图" +#| out.width: "85%" + +# 为每对发送者-接收者找到主要代谢物 +dominant_meta <- mebo_comm %>% + group_by(sender, receiver) %>% + slice_max(score, n = 1, with_ties = FALSE) %>% + ungroup() %>% + select(sender, receiver, metabolite, score) + +# 选择出现频率最高的代谢物以保持图例清晰 +top_metabolites <- dominant_meta %>% + count(metabolite, sort = TRUE) %>% + slice_head(n = 8) %>% + pull(metabolite) + +dominant_meta <- dominant_meta %>% + mutate( + meta_label = if_else(metabolite %in% top_metabolites, + metabolite, "Other") + ) + +# 构建图 +g_meta <- graph_from_data_frame( + d = dominant_meta %>% rename(weight = score), + directed = TRUE, + vertices = data.frame(name = unique(c(dominant_meta$sender, dominant_meta$receiver))) +) + +E(g_meta)$meta_label <- dominant_meta$meta_label + +n_meta <- length(unique(dominant_meta$meta_label)) +pal_meta <- c(RColorBrewer::brewer.pal(min(n_meta, 9), "Set1"), + "grey70")[seq_len(n_meta)] + +set.seed(42) +p2 <- ggraph(g_meta, layout = "circle") + + geom_edge_arc( + aes(width = weight, color = meta_label, alpha = weight), + arrow = arrow(length = unit(3, "mm"), type = "closed"), + end_cap = circle(6, "mm"), + start_cap = circle(6, "mm"), + curvature = 0.2 + ) + + geom_node_point(size = 14, shape = 21, + fill = "#4361ee", color = "white", stroke = 2) + + geom_node_label(aes(label = name), size = 3, + fontface = "bold", color = "white", + fill = NA, label.size = 0) + + scale_edge_width_continuous(range = c(0.5, 4), name = "评分") + + scale_edge_alpha_continuous(range = c(0.4, 1), guide = "none") + + scale_edge_color_manual(values = pal_meta, name = "代谢物") + + labs( + title = "按主要代谢物着色的代谢流图", + subtitle = "箭头颜色 = 通讯评分最高的代谢物" + ) + + theme_graph(base_family = "sans") + + theme( + plot.title = element_text(size = 14, face = "bold", hjust = 0.5), + plot.subtitle = element_text(size = 10, hjust = 0.5), + legend.position = "right" + ) + +p2 +``` + +### 3. 流气泡汇总图 + +另一种表示方式使用气泡矩阵,行为发送者,列为接收者,气泡大小编码通讯强度。 + +```{r fig3-bubble-summary, fig.width=8, fig.height=6, warning=FALSE} +#| fig-cap: "通讯气泡汇总矩阵" +#| out.width: "80%" + +p3 <- ggplot(agg_comm, + aes(x = receiver, y = sender, + size = total_score, color = n_metabolites)) + + geom_point(alpha = 0.8) + + scale_size_continuous(name = "总评分", range = c(2, 14)) + + scale_color_gradient( + name = "代谢物数量", + low = "#90e0ef", + high = "#03045e" + ) + + theme_bw(base_size = 11) + + theme( + axis.text.x = element_text(angle = 45, hjust = 1), + panel.grid.major = element_line(color = "grey90"), + plot.title = element_text(size = 13, face = "bold", hjust = 0.5) + ) + + labs( + title = "代谢通讯气泡矩阵", + x = "接收细胞类型", + y = "发送细胞类型" + ) + +p3 +``` + +### 4. 主要发送者/接收者柱状图 + +识别通讯活动最活跃的发送者和接收者细胞类型。 + +```{r fig4-bar-charts, fig.width=10, fig.height=5, warning=FALSE} +#| fig-cap: "代谢通讯中的主要发送者和接收者" +#| out.width: "95%" + +# 主要发送者 +p_sender <- agg_comm %>% + group_by(sender) %>% + summarise(outgoing = sum(total_score), .groups = "drop") %>% + arrange(desc(outgoing)) %>% + ggplot(aes(x = reorder(sender, outgoing), y = outgoing, fill = outgoing)) + + geom_col(width = 0.7, color = "white") + + scale_fill_gradient(low = "#caf0f8", high = "#0077b6", guide = "none") + + coord_flip() + + theme_classic(base_size = 11) + + labs(title = "主要发送者", x = NULL, y = "总输出评分") + +# 主要接收者 +p_receiver <- agg_comm %>% + group_by(receiver) %>% + summarise(incoming = sum(total_score), .groups = "drop") %>% + arrange(desc(incoming)) %>% + ggplot(aes(x = reorder(receiver, incoming), y = incoming, fill = incoming)) + + geom_col(width = 0.7, color = "white") + + scale_fill_gradient(low = "#ffd6a5", high = "#f77f00", guide = "none") + + coord_flip() + + theme_classic(base_size = 11) + + labs(title = "主要接收者", x = NULL, y = "总输入评分") + +p_sender + p_receiver +``` + +### 5. 代谢物水平通讯热图 + +在代谢物分辨率下可视化通讯,识别最突出的代谢信号。 + +```{r fig5-metabolite-heatmap, fig.width=10, fig.height=7, warning=FALSE} +#| fig-cap: "代谢物水平通讯热图" +#| out.width: "90%" + +heat_data <- mebo_comm %>% + mutate(pair = paste(sender, "→", receiver)) %>% + group_by(pair, metabolite) %>% + summarise(score = sum(score), .groups = "drop") + +top_pairs <- heat_data %>% + group_by(pair) %>% + summarise(total = sum(score), .groups = "drop") %>% + slice_max(total, n = 12) %>% + pull(pair) + +top_metas <- heat_data %>% + group_by(metabolite) %>% + summarise(total = sum(score), .groups = "drop") %>% + slice_max(total, n = 10) %>% + pull(metabolite) + +heat_sub <- heat_data %>% + filter(pair %in% top_pairs, metabolite %in% top_metas) + +ggplot(heat_sub, + aes(x = metabolite, y = pair, fill = score)) + + geom_tile(color = "white", linewidth = 0.4) + + scale_fill_gradient2( + name = "评分", + low = "white", + mid = "#ffb3c6", + high = "#d00000", + midpoint = median(heat_sub$score) + ) + + theme_minimal(base_size = 11) + + theme( + axis.text.x = element_text(angle = 45, hjust = 1), + panel.grid = element_blank(), + plot.title = element_text(size = 13, face = "bold", hjust = 0.5) + ) + + labs( + title = "代谢物水平通讯热图", + x = "代谢物", + y = "发送者 → 接收者" + ) +``` + +## 参考文献 + +\[1\] Chen K 等. MEBOCOST:基于单细胞转录组的代谢物介导细胞通讯建模. *bioRxiv*. 2022. + +\[2\] MEBOCOST GitHub: + +\[3\] MEBOCOST 文档: diff --git a/Skills.qmd b/Skills.qmd new file mode 100644 index 0000000000..e1019198d3 --- /dev/null +++ b/Skills.qmd @@ -0,0 +1,584 @@ +--- +title: "Bizard Skills" +subtitle: "AI-Ready Visualization Skills for Biomedical Research" +from: markdown+emoji +--- + +## What are Bizard Skills? :brain: + +**Bizard Skills** convert every tutorial in this atlas into a compact, structured **AI skill document** — a plain-text summary of what a chart is, when to use it, what R packages it needs, and the minimal reproducible code to produce it. Skills can be: + +- Copied directly into AI assistant context windows (ChatGPT, Copilot, Claude, etc.) +- Used by local LLM tools like [LM Studio](https://lmstudio.ai/) or [Ollama](https://ollama.ai/) +- Downloaded as a ZIP for offline use in custom AI pipelines +- Referenced by automated bioinformatics workflows + +::: callout-tip +**How to use a skill**: Click any card below to view the skill document, then copy its content into your AI assistant prompt. For example: *"Use the following skill to help me create a violin plot comparing gene expression between tumor and normal samples."* +::: + +--- + +## Browse Skills by Category {#browse} + +Use the search box or click a category to filter skills. + +```{=html} +
+ + +
+ +
+
+ + +
+ + + +
+ + +``` + +--- + +## Download All Skills {#download} + +::: callout-note +Use the script at `.github/scripts/generate_skills.py` to regenerate skill documents from all QMD tutorials automatically. Run: +```bash +python .github/scripts/generate_skills.py --output skills/ +``` +::: + +The auto-generated skill files follow this naming convention: `_skill.md` + +Each skill document contains: +- **Chart name and category** +- **When to use** — a concise decision guide +- **Key R packages** required +- **Minimal reproducible R code** to produce the chart +- **Link** back to the full tutorial diff --git a/Skills.zh.qmd b/Skills.zh.qmd new file mode 100644 index 0000000000..72ac8ea689 --- /dev/null +++ b/Skills.zh.qmd @@ -0,0 +1,584 @@ +--- +title: "Bizard 技能库" +subtitle: "面向生物医学研究的 AI 就绪可视化技能" +from: markdown+emoji +--- + +## 什么是 Bizard 技能? :brain: + +**Bizard 技能**将本图谱中的每个教程转换为简洁、结构化的 **AI 技能文档**——一份纯文本摘要,说明图表是什么、何时使用、需要哪些 R 包以及生成该图表的最小可复现代码。技能文档可用于: + +- 直接复制到 AI 助手的上下文窗口中(ChatGPT、Copilot、Claude 等) +- 与本地 LLM 工具配合使用,如 [LM Studio](https://lmstudio.ai/) 或 [Ollama](https://ollama.ai/) +- 作为 ZIP 包下载,用于自定义 AI 流水线的离线使用 +- 在自动化生物信息学工作流中引用 + +::: callout-tip +**如何使用技能**:点击下方任意卡片查看技能文档,然后将其内容复制到您的 AI 助手提示中。例如:*"请使用以下技能帮我创建一个比较肿瘤和正常样本基因表达的小提琴图。"* +::: + +--- + +## 按分类浏览技能 {#browse} + +使用搜索框或点击分类标签来筛选技能。 + +```{=html} +
+ + +
+ +
+
+ + +
+ + + +
+ + +``` + +--- + +## 下载全部技能 {#download} + +::: callout-note +使用 `.github/scripts/generate_skills.py` 脚本可从所有 QMD 教程自动重新生成技能文档。运行: +```bash +python .github/scripts/generate_skills.py --output skills/ +``` +::: + +自动生成的技能文件命名规则:`<教程名称>_skill.md` + +每份技能文档包含: +- **图表名称和分类** +- **使用时机** — 简洁的决策指南 +- **所需 R 包** +- **最小可复现 R 代码**,用于生成图表 +- **链接**,指向完整教程 diff --git a/_quarto.yml b/_quarto.yml index 7941f56f0c..53c51de4c3 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -45,6 +45,8 @@ website: href: Tutorial.qmd - text: " Graph Gallery" href: GraphGallery.qmd + - text: " Skills" + href: Skills.qmd - section: " 𝐁𝐀𝐒𝐈𝐂𝐒" - section: " Distribution" contents: @@ -194,6 +196,10 @@ website: href: Omics/MotifPlot.qmd - text: "TextEnrichmentBarPlot" href: Omics/TextEnrichmentBarPlot.qmd + - text: "CellChatCirclePlot" + href: Omics/CellChatCirclePlot.qmd + - text: "MebocostFlowPlot" + href: Omics/MebocostFlowPlot.qmd - section: " 𝐂𝐋𝐈𝐍𝐈𝐂𝐒" contents: diff --git a/index.qmd b/index.qmd index 5b1b1c2b86..4d6ed7117a 100644 --- a/index.qmd +++ b/index.qmd @@ -35,3 +35,133 @@ Bizard brings together powerful visualization tools, curated code, and collabora ::: callout-note If you find this useful or have suggestions for improvement, please let us know by leaving your comments in the GitHub Discussions:speech_balloon: at the bottom of any page. ::: + +--- + +## 🔍 Chart Recommender {#recommender} + +Not sure which chart to use? Describe your data or research goal below and get instant recommendations from Bizard's curated visualization library — **no server required, runs entirely in your browser**. + +```{=html} +
+ + +
+ + +
+
+
+ + +```