c10_dumbbell 差值排序 (Delta)
The Economist Nature Lancet
Dumbbell / Lollipop Chart
哑铃图/棒棒糖图,对比基线与终点 (Baseline vs Endpoint) 的配对变化幅度,按变化量自动降序排列。
🔬 矢量预览 (Vector Preview)
600 DPI Ready
格式支持: SVG, PDF, PNG (600 DPI), TIFF
📊 统计学特性 (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)。
| 字段名称 (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}")