|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | waterfall-basic: Basic Waterfall Chart |
3 | | -Library: bokeh 3.8.1 | Python 3.13.11 |
4 | | -Quality: 91/100 | Created: 2025-12-24 |
| 3 | +Library: bokeh 3.9.0 | Python 3.13.13 |
| 4 | +Quality: 86/100 | Updated: 2026-05-06 |
5 | 5 | """ |
6 | 6 |
|
7 | | -from bokeh.io import export_png |
8 | | -from bokeh.models import ColumnDataSource, FactorRange, Label |
9 | | -from bokeh.plotting import figure, save |
| 7 | +import os |
| 8 | +import time |
| 9 | +from pathlib import Path |
10 | 10 |
|
| 11 | +from bokeh.io import output_file, save |
| 12 | +from bokeh.models import ColumnDataSource, FactorRange, HoverTool, Label |
| 13 | +from bokeh.plotting import figure |
| 14 | +from selenium import webdriver |
| 15 | +from selenium.webdriver.chrome.options import Options |
11 | 16 |
|
12 | | -# Data - quarterly financial breakdown from revenue to net income |
| 17 | + |
| 18 | +THEME = os.getenv("ANYPLOT_THEME", "light") |
| 19 | +PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
| 20 | +ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" |
| 21 | +INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
| 22 | +INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
| 23 | + |
| 24 | +# Okabe-Ito palette |
| 25 | +POSITIVE = "#009E73" # brand green for positive changes |
| 26 | +NEGATIVE = "#D55E00" # vermillion for negative changes |
| 27 | +TOTAL = "#0072B2" # blue for totals |
| 28 | + |
| 29 | +# Data - quarterly financial breakdown |
13 | 30 | categories = ["Starting Revenue", "Product Sales", "Services", "Refunds", "Operating Costs", "Marketing", "Net Income"] |
14 | | -# First value is the starting total, last value will be calculated as final total |
15 | | -# Middle values are changes (positive = increase, negative = decrease) |
16 | | -changes = [150000, 50000, 35000, -8000, -75000, -22000, 0] # Last is placeholder for total |
| 31 | +changes = [150000, 50000, 35000, -8000, -75000, -22000, 0] |
17 | 32 |
|
18 | 33 | # Calculate waterfall positions |
19 | 34 | running_total = 0 |
20 | 35 | bar_bottoms = [] |
21 | 36 | bar_tops = [] |
22 | 37 | bar_colors = [] |
23 | | -display_values = [] # For labels |
| 38 | +display_values = [] |
24 | 39 |
|
25 | 40 | for i, (_cat, change) in enumerate(zip(categories, changes, strict=True)): |
26 | 41 | if i == 0: |
27 | 42 | # Starting total - full bar from 0 |
28 | 43 | running_total = change |
29 | 44 | bar_bottoms.append(0) |
30 | 45 | bar_tops.append(running_total) |
31 | | - bar_colors.append("#306998") # Python Blue for totals |
| 46 | + bar_colors.append(TOTAL) |
32 | 47 | display_values.append(running_total) |
33 | 48 | elif i == len(categories) - 1: |
34 | 49 | # Final total - full bar from 0 to current running total |
35 | 50 | bar_bottoms.append(0) |
36 | 51 | bar_tops.append(running_total) |
37 | | - bar_colors.append("#306998") # Python Blue for totals |
| 52 | + bar_colors.append(TOTAL) |
38 | 53 | display_values.append(running_total) |
39 | 54 | else: |
40 | 55 | # Intermediate changes |
41 | 56 | if change >= 0: |
42 | 57 | bar_bottoms.append(running_total) |
43 | 58 | bar_tops.append(running_total + change) |
44 | | - bar_colors.append("#2ECC71") # Green for positive |
| 59 | + bar_colors.append(POSITIVE) |
45 | 60 | else: |
46 | 61 | bar_bottoms.append(running_total + change) |
47 | 62 | bar_tops.append(running_total) |
48 | | - bar_colors.append("#E74C3C") # Red for negative |
| 63 | + bar_colors.append(NEGATIVE) |
49 | 64 | running_total += change |
50 | 65 | display_values.append(change) |
51 | 66 |
|
52 | 67 | # Create data source |
53 | | -source = ColumnDataSource(data={"categories": categories, "bottom": bar_bottoms, "top": bar_tops, "color": bar_colors}) |
| 68 | +source = ColumnDataSource( |
| 69 | + data={ |
| 70 | + "categories": categories, |
| 71 | + "bottom": bar_bottoms, |
| 72 | + "top": bar_tops, |
| 73 | + "color": bar_colors, |
| 74 | + "display": [f"${v:,.0f}" for v in display_values], |
| 75 | + } |
| 76 | +) |
54 | 77 |
|
55 | | -# Create figure (4800 x 2700 px) |
| 78 | +# Create figure |
56 | 79 | p = figure( |
57 | 80 | x_range=FactorRange(*categories, range_padding=0.1), |
58 | 81 | width=4800, |
59 | 82 | height=2700, |
60 | | - title="waterfall-basic · bokeh · pyplots.ai", |
| 83 | + title="waterfall-basic · bokeh · anyplot.ai", |
61 | 84 | x_axis_label="Financial Category", |
62 | 85 | y_axis_label="Amount ($)", |
63 | 86 | toolbar_location=None, |
|
71 | 94 | width=0.6, |
72 | 95 | source=source, |
73 | 96 | color="color", |
74 | | - line_color="white", |
| 97 | + line_color=INK_SOFT, |
75 | 98 | line_width=2, |
76 | 99 | alpha=0.9, |
77 | 100 | ) |
78 | 101 |
|
| 102 | +# Add HoverTool for interactivity |
| 103 | +hover = HoverTool(tooltips=[("Category", "@categories"), ("Amount", "@display")]) |
| 104 | +p.add_tools(hover) |
| 105 | + |
79 | 106 | # Calculate running totals for connector lines |
80 | 107 | running_totals = [] |
81 | 108 | rt = 0 |
|
86 | 113 | rt += change |
87 | 114 | running_totals.append(rt) |
88 | 115 |
|
89 | | -# Draw connector lines between bars (showing running total flow) |
90 | | -for i in range(len(categories) - 2): # Don't draw connector to final total bar |
91 | | - # The connector should be at the running total level after bar i |
| 116 | +# Draw connector lines between bars |
| 117 | +for i in range(len(categories) - 2): |
92 | 118 | connector_y = running_totals[i] |
93 | | - |
94 | 119 | p.line( |
95 | 120 | x=[categories[i], categories[i + 1]], |
96 | 121 | y=[connector_y, connector_y], |
97 | | - line_color="#7F8C8D", |
| 122 | + line_color=INK_SOFT, |
98 | 123 | line_width=2, |
99 | 124 | line_dash="dashed", |
100 | | - alpha=0.7, |
| 125 | + alpha=0.5, |
101 | 126 | ) |
102 | 127 |
|
103 | 128 | # Add value labels on bars |
104 | 129 | max_value = max(bar_tops) |
105 | | -label_offset = max_value * 0.03 # 3% of max value for offset |
| 130 | +label_offset = max_value * 0.03 |
106 | 131 |
|
107 | 132 | for i, (_cat, _bottom, top, display_val) in enumerate( |
108 | 133 | zip(categories, bar_bottoms, bar_tops, display_values, strict=True) |
109 | 134 | ): |
110 | | - # Label position - above the bar |
111 | 135 | label_y = top + label_offset |
112 | 136 |
|
113 | 137 | if i == 0 or i == len(categories) - 1: |
114 | | - # Total bars - show absolute value |
115 | 138 | label_text = f"${display_val:,.0f}" |
116 | 139 | else: |
117 | | - # Change bars - show with sign |
118 | 140 | if display_val >= 0: |
119 | 141 | label_text = f"+${display_val:,.0f}" |
120 | 142 | else: |
|
127 | 149 | text_font_size="20pt", |
128 | 150 | text_align="center", |
129 | 151 | text_baseline="bottom", |
130 | | - text_color="#2C3E50", |
| 152 | + text_color=INK, |
131 | 153 | ) |
132 | 154 | p.add_layout(label) |
133 | 155 |
|
134 | 156 | # Style |
135 | | -p.title.text_font_size = "32pt" |
136 | | -p.xaxis.axis_label_text_font_size = "24pt" |
137 | | -p.yaxis.axis_label_text_font_size = "24pt" |
| 157 | +p.title.text_font_size = "28pt" |
| 158 | +p.title.text_color = INK |
| 159 | + |
| 160 | +p.xaxis.axis_label_text_font_size = "22pt" |
| 161 | +p.xaxis.axis_label_text_color = INK |
| 162 | +p.yaxis.axis_label_text_font_size = "22pt" |
| 163 | +p.yaxis.axis_label_text_color = INK |
| 164 | + |
138 | 165 | p.xaxis.major_label_text_font_size = "18pt" |
139 | 166 | p.yaxis.major_label_text_font_size = "18pt" |
140 | | -p.xaxis.major_label_orientation = 0.5 # Slight rotation for readability |
| 167 | +p.xaxis.major_label_text_color = INK_SOFT |
| 168 | +p.yaxis.major_label_text_color = INK_SOFT |
| 169 | +p.xaxis.major_label_orientation = 0.3 |
| 170 | + |
| 171 | +p.xaxis.axis_line_color = INK_SOFT |
| 172 | +p.yaxis.axis_line_color = INK_SOFT |
| 173 | +p.xaxis.major_tick_line_color = INK_SOFT |
| 174 | +p.yaxis.major_tick_line_color = INK_SOFT |
141 | 175 |
|
142 | | -# Grid styling |
143 | 176 | p.xgrid.grid_line_color = None |
144 | | -p.ygrid.grid_line_alpha = 0.3 |
145 | | -p.ygrid.grid_line_dash = "dashed" |
| 177 | +p.ygrid.grid_line_color = INK |
| 178 | +p.ygrid.grid_line_alpha = 0.1 |
146 | 179 |
|
147 | | -# Background |
148 | | -p.background_fill_color = "#FAFAFA" |
149 | | -p.border_fill_color = "#FFFFFF" |
| 180 | +p.background_fill_color = PAGE_BG |
| 181 | +p.border_fill_color = PAGE_BG |
| 182 | +p.outline_line_color = INK_SOFT |
150 | 183 |
|
151 | | -# Axis styling |
152 | | -p.yaxis.formatter.use_scientific = False |
153 | 184 | p.y_range.start = 0 |
154 | | -p.y_range.end = max_value * 1.15 # 15% padding above max |
155 | | -p.min_border_left = 80 # Extra left margin for y-axis labels |
156 | | - |
157 | | -# Save as PNG and HTML |
158 | | -export_png(p, filename="plot.png") |
159 | | -save(p, filename="plot.html", title="waterfall-basic · bokeh · pyplots.ai") |
| 185 | +p.y_range.end = max_value * 1.15 |
| 186 | +p.min_border_left = 80 |
| 187 | + |
| 188 | +# Save HTML |
| 189 | +output_file(f"plot-{THEME}.html") |
| 190 | +save(p) |
| 191 | + |
| 192 | +# Screenshot with Selenium |
| 193 | +W, H = 4800, 2700 |
| 194 | +opts = Options() |
| 195 | +for arg in ( |
| 196 | + "--headless=new", |
| 197 | + "--no-sandbox", |
| 198 | + "--disable-dev-shm-usage", |
| 199 | + "--disable-gpu", |
| 200 | + f"--window-size={W},{H}", |
| 201 | + "--hide-scrollbars", |
| 202 | +): |
| 203 | + opts.add_argument(arg) |
| 204 | + |
| 205 | +driver = webdriver.Chrome(options=opts) |
| 206 | +driver.set_window_size(W, H) |
| 207 | +driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}") |
| 208 | +time.sleep(3) |
| 209 | +driver.save_screenshot(f"plot-{THEME}.png") |
| 210 | +driver.quit() |
0 commit comments