c10_dumbbell 差值排序 (Delta) The Economist Nature Lancet

Dumbbell / Lollipop Chart

哑铃图/棒棒糖图,对比基线与终点 (Baseline vs Endpoint) 的配对变化幅度,按变化量自动降序排列。

🔬 矢量预览 (Vector Preview) 600 DPI Ready
Dumbbell / Lollipop Chart

📊 统计学特性 (Statistical Features)

  • 配对差值计算 (Delta)
  • 前后测变化幅度与方向
  • 分类排序算法
  • 正负增益色彩区隔

🛡️ QC 质检要点 (QC Highlights)

  • 连接线粗细适中连接两端端点
  • 基线与终点端点色彩反差鲜明
  • 类别标签长文本不截断
  • 数值变化百分比标注
🎨 顶刊色彩提取器 (Palette Extractor)Nature

Crisp sans-serif typography, clean borderless spines, high-contrast palette with soft muted secondary accents.

🛡️ 无障碍评分:AAA (Deuteranopia & Protanopia Compliant)

📋 Data Contract 数据契约规范 Strict Schema

输入数据必须完全符合下列列名与数据类型规范,方可通过自动化数据前检 (Pre-flight Validation)。

📥 下载规范示例 CSV 数据
字段名称 (Column) 数据类型 (Type) 约束性 语义说明与值域约束
category categorical 必需 (Required) Entity or category name (e.g. Country, Biomarker)
baseline_val continuous 必需 (Required) Baseline or starting measurement
post_val continuous 必需 (Required) Post-intervention or ending measurement
group categorical 可选 (Optional) High-level classification group
组内最小样本量: n ≥ 3
最大允许缺失率: 5%

🐍 独立可复现 Python 绘图源码 Stand-alone Script

完全可复现的 Python 脚本,支持 CLI 参数 `--data`, `--style`, `--output-dir` 与模块化 `render()` 调用。

#!/usr/bin/env python3
"""FigureCraft Chart Engine: Dumbbell / Lollipop Chart (c10_dumbbell)."""

import os
import sys
import argparse
from pathlib import Path
from typing import List, Dict, Optional
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

try:
    from engines.styles import apply_style, get_palette
except ImportError:
    sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
    from engines.styles import apply_style, get_palette

CHART_ID = "c10_dumbbell"


def render(
    data_path: Optional[str] = None,
    style: str = "nature",
    output_dir: str = "output",
    formats: Optional[List[str]] = None,
    dpi: int = 600,
) -> Dict[str, str]:
    if formats is None:
        formats = ["svg", "pdf", "png", "tiff"]
    if data_path is None:
        data_path = str(Path(__file__).resolve().parents[1] / "data" / f"{CHART_ID}.csv")

    apply_style(style)
    palette = get_palette(style)
    df = pd.read_csv(data_path)
    df = df.dropna(subset=["category", "baseline_val", "post_val"])

    # Compute delta and sort by post_val
    df["delta"] = df["post_val"] - df["baseline_val"]
    df["pct_change"] = (df["delta"] / df["baseline_val"]) * 100.0
    df = df.sort_values("post_val", ascending=True).reset_index(drop=True)

    n = len(df)
    fig, ax = plt.subplots(figsize=(7.0, max(4.5, n * 0.42)))

    post_color = palette[0] if len(palette) > 0 else "#E64B35"
    base_color = "#9CA3AF"  # Neutral slate
    connector_color = "#D1D5DB"

    y_pos = np.arange(n)

    # Draw horizontal connectors
    for idx, row in df.iterrows():
        y = y_pos[idx]
        x0 = row["baseline_val"]
        x1 = row["post_val"]
        ax.plot([x0, x1], [y, y], color=connector_color, linewidth=1.8, zorder=2)

        # Delta badge on right
        delta_str = f"+{row['delta']:.1f} ({row['pct_change']:+.0f}%)" if row["delta"] >= 0 else f"{row['delta']:.1f} ({row['pct_change']:+.0f}%)"
        delta_col = "#059669" if row["delta"] >= 0 else "#DC2626"
        max_x = max(x0, x1)
        ax.text(
            max_x + (df["post_val"].max() - df["baseline_val"].min()) * 0.04,
            y,
            delta_str,
            ha="left",
            va="center",
            fontsize=7.0,
            fontweight="bold",
            color=delta_col,
        )

    # Plot baseline markers
    ax.scatter(
        df["baseline_val"],
        y_pos,
        color=base_color,
        s=48,
        label="Baseline / Pre-treatment",
        edgecolors="#4B5563",
        linewidth=0.8,
        zorder=4,
    )

    # Plot post markers
    ax.scatter(
        df["post_val"],
        y_pos,
        color=post_color,
        s=64,
        label="Post-treatment",
        edgecolors="#FFFFFF",
        linewidth=0.8,
        zorder=5,
    )

    ax.set_yticks(y_pos)
    ax.set_yticklabels([c.replace("_", " ") for c in df["category"]], fontsize=8.0)
    ax.set_xlabel("Quantitative Biomarker / Metric Level", fontsize=8.0)
    ax.set_title("Ranked Pre- vs. Post-Intervention Response Shifts", pad=12)

    # Expand x-limit for annotations
    x_span = df["post_val"].max() - df["baseline_val"].min()
    ax.set_xlim(
        df[["baseline_val", "post_val"]].min().min() - x_span * 0.08,
        df[["baseline_val", "post_val"]].max().max() + x_span * 0.28,
    )
    ax.legend(loc="lower right", frameon=False, fontsize=7.5)

    os.makedirs(output_dir, exist_ok=True)
    generated_files = {}
    for fmt in formats:
        out_path = os.path.join(output_dir, f"{CHART_ID}_{style}.{fmt}")
        fig.savefig(out_path, format=fmt, dpi=dpi, bbox_inches="tight")
        generated_files[fmt] = str(Path(out_path).resolve())

    plt.close(fig)
    return generated_files


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Render Dumbbell / Lollipop Chart")
    parser.add_argument("--data", type=str, default=None, help="Path to input CSV dataset")
    parser.add_argument("--style", type=str, default="nature", choices=["nature", "cell", "lancet", "ieee", "economist"], help="Journal style pack")
    parser.add_argument("--output-dir", type=str, default="output", help="Output directory")
    parser.add_argument("--formats", type=str, default="svg,pdf,png,tiff", help="Comma-separated export formats")
    parser.add_argument("--dpi", type=int, default=600, help="Raster DPI")
    args = parser.parse_args()

    fmt_list = [f.strip() for f in args.formats.split(",") if f.strip()]
    results = render(args.data, style=args.style, output_dir=args.output_dir, formats=fmt_list, dpi=args.dpi)
    for fmt, path in results.items():
        print(f"Generated {fmt.upper()}: {path}")