|
1 | 1 | """ anyplot.ai |
2 | 2 | dumbbell-basic: Basic Dumbbell Chart |
3 | | -Library: altair 6.1.0 | Python 3.14.4 |
4 | | -Quality: 89/100 | Updated: 2026-04-26 |
| 3 | +Library: altair 6.2.2 | Python 3.13.14 |
| 4 | +Quality: 92/100 | Updated: 2026-06-30 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import os |
8 | 8 |
|
9 | 9 | import altair as alt |
10 | 10 | import pandas as pd |
| 11 | +from PIL import Image |
11 | 12 |
|
12 | 13 |
|
13 | 14 | # Theme-adaptive chrome |
|
17 | 18 | INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
18 | 19 | INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
19 | 20 |
|
20 | | -# Okabe-Ito palette positions 1 and 2 |
| 21 | +# Imprint palette positions 1 and 2 |
21 | 22 | COLOR_BEFORE = "#009E73" |
22 | 23 | COLOR_AFTER = "#C475FD" |
23 | 24 |
|
|
41 | 42 | } |
42 | 43 | ) |
43 | 44 | data["difference"] = data["After"] - data["Before"] |
44 | | -data = data.sort_values("difference", ascending=True) |
| 45 | +data = data.sort_values("difference", ascending=True).reset_index(drop=True) |
45 | 46 |
|
46 | 47 | # Long-form data for the two dot series |
47 | 48 | dots_data = pd.melt( |
48 | 49 | data, id_vars=["category", "difference"], value_vars=["Before", "After"], var_name="period", value_name="score" |
49 | 50 | ) |
50 | 51 |
|
51 | | -x_scale = alt.Scale(domain=[45, 90]) |
| 52 | +title = "Employee Satisfaction · dumbbell-basic · python · altair · anyplot.ai" |
| 53 | +n = len(title) |
| 54 | +title_fontsize = round(16 * (67 / n)) if n > 67 else 16 |
| 55 | + |
| 56 | +x_scale = alt.Scale(domain=[45, 92]) |
52 | 57 | y_sort = alt.EncodingSortField(field="difference", order="ascending") |
53 | 58 |
|
54 | 59 | # Connecting lines (theme-adaptive subtle ink) |
55 | 60 | lines = ( |
56 | 61 | alt.Chart(data) |
57 | | - .mark_rule(strokeWidth=3, color=INK_SOFT, opacity=0.55) |
| 62 | + .mark_rule(strokeWidth=3, color=INK_SOFT, opacity=0.45) |
58 | 63 | .encode(y=alt.Y("category:N", sort=y_sort, title=None), x=alt.X("Before:Q", scale=x_scale), x2=alt.X2("After:Q")) |
59 | 64 | ) |
60 | 65 |
|
61 | 66 | # Dots for Before / After values |
62 | 67 | dots = ( |
63 | 68 | alt.Chart(dots_data) |
64 | | - .mark_circle(size=420, opacity=1.0, stroke=PAGE_BG, strokeWidth=2) |
| 69 | + .mark_circle(size=350, opacity=1.0, stroke=PAGE_BG, strokeWidth=2) |
65 | 70 | .encode( |
66 | 71 | y=alt.Y("category:N", sort=y_sort, title=None), |
67 | 72 | x=alt.X("score:Q", scale=x_scale, title="Employee Satisfaction Score (%)"), |
68 | 73 | color=alt.Color( |
69 | 74 | "period:N", |
70 | 75 | scale=alt.Scale(domain=["Before", "After"], range=[COLOR_BEFORE, COLOR_AFTER]), |
71 | | - legend=alt.Legend(title="Policy Change", labelFontSize=16, titleFontSize=18), |
| 76 | + legend=alt.Legend(title="Policy Change", labelFontSize=10, titleFontSize=12), |
72 | 77 | ), |
73 | 78 | tooltip=["category:N", "period:N", "score:Q"], |
74 | 79 | ) |
75 | 80 | ) |
76 | 81 |
|
| 82 | +# Difference labels via transform_calculate — shows the gain at a glance |
| 83 | +diff_labels = ( |
| 84 | + alt.Chart(data) |
| 85 | + .transform_calculate(label="'+' + toString(datum.difference) + ' pts'") |
| 86 | + .mark_text(align="left", dx=8, fontSize=11, fontWeight="bold", clip=False) |
| 87 | + .encode( |
| 88 | + y=alt.Y("category:N", sort=y_sort, title=None), |
| 89 | + x=alt.X("After:Q", scale=x_scale), |
| 90 | + text=alt.Text("label:N"), |
| 91 | + color=alt.value(INK_SOFT), |
| 92 | + ) |
| 93 | +) |
| 94 | + |
77 | 95 | chart = ( |
78 | | - (lines + dots) |
| 96 | + (lines + dots + diff_labels) |
79 | 97 | .properties( |
80 | | - width=1600, |
81 | | - height=900, |
82 | | - title=alt.Title( |
83 | | - "Employee Satisfaction · dumbbell-basic · altair · anyplot.ai", |
84 | | - fontSize=28, |
85 | | - color=INK, |
86 | | - anchor="start", |
87 | | - offset=20, |
88 | | - ), |
| 98 | + width=576, |
| 99 | + height=374, |
| 100 | + title=alt.Title(title, fontSize=title_fontsize, color=INK, anchor="start", offset=16), |
89 | 101 | background=PAGE_BG, |
90 | 102 | ) |
91 | 103 | .configure_view(fill=PAGE_BG, stroke=None) |
92 | 104 | .configure_axis( |
93 | | - labelFontSize=18, |
94 | | - titleFontSize=22, |
| 105 | + labelFontSize=10, |
| 106 | + titleFontSize=12, |
95 | 107 | domainColor=INK_SOFT, |
| 108 | + domainOpacity=0, |
96 | 109 | tickColor=INK_SOFT, |
97 | 110 | gridColor=INK, |
98 | 111 | gridOpacity=0.10, |
99 | 112 | labelColor=INK_SOFT, |
100 | 113 | titleColor=INK, |
101 | 114 | ) |
102 | 115 | .configure_title(color=INK) |
103 | | - .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK, padding=12) |
| 116 | + .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK, padding=10) |
104 | 117 | ) |
105 | 118 |
|
106 | | -chart.save(f"plot-{THEME}.png", scale_factor=3.0) |
| 119 | +chart.save(f"plot-{THEME}.png", scale_factor=4.0) |
| 120 | + |
| 121 | +# Pad to exact 3200×1800 canvas (vl-convert pads outside width/height) |
| 122 | +TW, TH = 3200, 1800 |
| 123 | +_img = Image.open(f"plot-{THEME}.png").convert("RGB") |
| 124 | +_w, _h = _img.size |
| 125 | +if _w > TW or _h > TH: |
| 126 | + raise SystemExit( |
| 127 | + f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. " |
| 128 | + f"Shrink chart .properties(width=, height=) values and re-render." |
| 129 | + ) |
| 130 | +if _w < TW or _h < TH: |
| 131 | + _canvas = Image.new("RGB", (TW, TH), PAGE_BG) |
| 132 | + _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2)) |
| 133 | + _canvas.save(f"plot-{THEME}.png") |
| 134 | + |
107 | 135 | chart.save(f"plot-{THEME}.html") |
0 commit comments