clean-charts 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raghuram Sirigiri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: clean-charts
3
+ Version: 0.1.2
4
+ Summary: A Python library to generate beautiful, clean charts in premium visual styles
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: matplotlib>=3.5.0
9
+ Requires-Dist: pandas>=1.3.0
10
+ Requires-Dist: numpy>=1.20.0
11
+ Requires-Dist: pillow>=8.0.0
12
+ Dynamic: license-file
13
+
14
+ # Clean Charts Library
15
+
16
+ A Python library to generate line charts in clean, premium visual styles, including right-aligned axes, custom year boundaries, distinct line colors, and dynamic scaling for mini canvas resolutions.
17
+
18
+ ![Example Chart](notebook_chart_long_title.png)
19
+
20
+ The library automatically identifies date/time columns and any number of value series to plot them dynamically, with overlap-avoiding label placement, and allows custom color interpolation, date label frequencies, titles, and subtitles.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install .
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ You can run the script without any parameters to recreate the chart from the reference image:
31
+
32
+ ```python
33
+ from clean_charts import plot_time_series
34
+
35
+ # Generates the default landscape image (1000x500)
36
+ plot_time_series(
37
+ output_path="chart_landscape.png",
38
+ aspect_ratio="landscape",
39
+ title="Europe",
40
+ subtitle="Sales of Chinese-made cars, % of total"
41
+ )
42
+
43
+ # Generates a 500x500 square visualization
44
+ plot_time_series(
45
+ output_path="chart_500.png",
46
+ width=500,
47
+ height=500,
48
+ title="Europe",
49
+ subtitle="Sales of Chinese-made cars, % of total"
50
+ )
51
+
52
+ # Generates a chart with a custom color gradient (from Indigo to Coral)
53
+ plot_time_series(
54
+ output_path="chart_gradient.png",
55
+ start_color="#4b0082",
56
+ end_color="#ff7f50",
57
+ title="EV Market Split",
58
+ subtitle="Gradient Theme Demonstration"
59
+ )
60
+ ```
61
+
62
+ ## Custom Data Input and X-Axis Frequencies
63
+
64
+ You can supply your own pandas DataFrame with any date/time column and value columns. The library dynamically identifies them and configures the X-axis label frequency:
65
+
66
+ ```python
67
+ import pandas as pd
68
+ from clean_charts import plot_time_series
69
+
70
+ # Day-frequency dataset example
71
+ daily_data = pd.DataFrame({
72
+ "Day": pd.date_range("2026-05-01", periods=10, freq="D"),
73
+ "Users": [120, 150, 190, 240, 220, 250, 270, 310, 340, 320],
74
+ "Signups": [15, 22, 35, 40, 28, 30, 32, 45, 52, 48]
75
+ })
76
+
77
+ plot_time_series(
78
+ data=daily_data,
79
+ output_path="daily_chart.png",
80
+ title="Server Statistics",
81
+ subtitle="10-Day Signups growth",
82
+ label_frequency="day", # Supported: "year", "quarter", "month", "week", "day", "hour", "minute", "second"
83
+ start_color="#006400",
84
+ end_color="#ffd700"
85
+ )
86
+ ```
@@ -0,0 +1,73 @@
1
+ # Clean Charts Library
2
+
3
+ A Python library to generate line charts in clean, premium visual styles, including right-aligned axes, custom year boundaries, distinct line colors, and dynamic scaling for mini canvas resolutions.
4
+
5
+ ![Example Chart](notebook_chart_long_title.png)
6
+
7
+ The library automatically identifies date/time columns and any number of value series to plot them dynamically, with overlap-avoiding label placement, and allows custom color interpolation, date label frequencies, titles, and subtitles.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install .
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ You can run the script without any parameters to recreate the chart from the reference image:
18
+
19
+ ```python
20
+ from clean_charts import plot_time_series
21
+
22
+ # Generates the default landscape image (1000x500)
23
+ plot_time_series(
24
+ output_path="chart_landscape.png",
25
+ aspect_ratio="landscape",
26
+ title="Europe",
27
+ subtitle="Sales of Chinese-made cars, % of total"
28
+ )
29
+
30
+ # Generates a 500x500 square visualization
31
+ plot_time_series(
32
+ output_path="chart_500.png",
33
+ width=500,
34
+ height=500,
35
+ title="Europe",
36
+ subtitle="Sales of Chinese-made cars, % of total"
37
+ )
38
+
39
+ # Generates a chart with a custom color gradient (from Indigo to Coral)
40
+ plot_time_series(
41
+ output_path="chart_gradient.png",
42
+ start_color="#4b0082",
43
+ end_color="#ff7f50",
44
+ title="EV Market Split",
45
+ subtitle="Gradient Theme Demonstration"
46
+ )
47
+ ```
48
+
49
+ ## Custom Data Input and X-Axis Frequencies
50
+
51
+ You can supply your own pandas DataFrame with any date/time column and value columns. The library dynamically identifies them and configures the X-axis label frequency:
52
+
53
+ ```python
54
+ import pandas as pd
55
+ from clean_charts import plot_time_series
56
+
57
+ # Day-frequency dataset example
58
+ daily_data = pd.DataFrame({
59
+ "Day": pd.date_range("2026-05-01", periods=10, freq="D"),
60
+ "Users": [120, 150, 190, 240, 220, 250, 270, 310, 340, 320],
61
+ "Signups": [15, 22, 35, 40, 28, 30, 32, 45, 52, 48]
62
+ })
63
+
64
+ plot_time_series(
65
+ data=daily_data,
66
+ output_path="daily_chart.png",
67
+ title="Server Statistics",
68
+ subtitle="10-Day Signups growth",
69
+ label_frequency="day", # Supported: "year", "quarter", "month", "week", "day", "hour", "minute", "second"
70
+ start_color="#006400",
71
+ end_color="#ffd700"
72
+ )
73
+ ```
@@ -0,0 +1,4 @@
1
+ from clean_charts.plot import plot_time_series
2
+ from clean_charts.data import get_default_data
3
+
4
+ __all__ = ["plot_time_series", "get_default_data"]
@@ -0,0 +1,64 @@
1
+ import pandas as pd
2
+
3
+ def get_default_data():
4
+ # Reconstructed data representing monthly values from January 2020 to October 2025.
5
+ # Total of 70 months.
6
+ dates = pd.date_range(start="2020-01-01", end="2025-10-01", freq="MS")
7
+
8
+ # Approx curves from the chart
9
+ electric = [
10
+ # 2020
11
+ 1.0, 1.5, 0.8, 1.2, 1.7, 2.3, 2.8, 2.5, 1.8, 2.2, 2.8, 2.0,
12
+ # 2021
13
+ 2.2, 2.8, 2.0, 2.2, 2.4, 2.8, 2.2, 2.8, 3.2, 2.5, 3.0, 3.8,
14
+ # 2022
15
+ 4.8, 4.4, 4.2, 4.0, 4.8, 6.0, 5.8, 5.0, 6.5, 6.2, 5.5, 5.0,
16
+ # 2023
17
+ 5.8, 6.5, 7.0, 6.8, 7.8, 7.5, 7.2, 7.0, 7.8, 7.0, 6.5, 7.0,
18
+ # 2024
19
+ 8.2, 8.8, 7.6, 7.4, 7.0, 7.2, 7.0, 6.8, 7.2, 7.8, 8.2, 8.5,
20
+ # 2025 (up to October)
21
+ 9.2, 9.8, 9.5, 9.2, 10.0, 11.2, 10.8, 10.0, 11.0, 10.2
22
+ ]
23
+
24
+ hybrid = [
25
+ # 2020
26
+ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1,
27
+ # 2021
28
+ 0.1, 0.1, 0.1, 0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.2, 1.5, 1.8,
29
+ # 2022
30
+ 2.2, 2.0, 1.8, 1.5, 1.4, 1.5, 2.3, 2.0, 1.8, 1.6, 1.8, 1.7,
31
+ # 2023
32
+ 1.9, 2.0, 1.8, 1.7, 1.5, 1.2, 1.0, 0.8, 0.8, 0.8, 1.2, 0.8,
33
+ # 2024
34
+ 0.8, 0.8, 0.9, 1.0, 1.2, 1.5, 1.8, 2.2, 2.8, 4.0, 5.5, 7.0,
35
+ # 2025 (up to October)
36
+ 6.0, 7.5, 7.0, 8.0, 8.8, 9.0, 9.2, 9.8, 15.2, 12.8
37
+ ]
38
+
39
+ petrol = [
40
+ # 2020
41
+ 0.3, 0.3, 0.2, 0.2, 0.2, 0.3, 0.4, 0.3, 0.2, 0.2, 0.3, 0.3,
42
+ # 2021
43
+ 0.3, 0.4, 0.3, 0.2, 0.2, 0.3, 0.4, 0.5, 0.4, 0.3, 0.3, 0.5,
44
+ # 2022
45
+ 0.6, 0.5, 0.7, 0.6, 0.5, 0.4, 0.7, 0.8, 0.5, 0.7, 0.8, 0.9,
46
+ # 2023
47
+ 1.0, 1.1, 1.0, 0.8, 0.9, 0.8, 1.1, 1.2, 1.0, 0.9, 1.1, 1.0,
48
+ # 2024
49
+ 1.2, 1.3, 1.0, 1.1, 0.9, 1.0, 1.2, 1.1, 1.2, 1.3, 1.2, 1.4,
50
+ # 2025 (up to October)
51
+ 1.5, 1.6, 1.4, 1.7, 1.8, 1.5, 1.6, 1.8, 1.8, 1.8
52
+ ]
53
+
54
+ # Ensure they are all of same length
55
+ min_len = min(len(dates), len(electric), len(hybrid), len(petrol))
56
+
57
+ df = pd.DataFrame({
58
+ "Date": dates[:min_len],
59
+ "Electric": electric[:min_len],
60
+ "Hybrid": hybrid[:min_len],
61
+ "Petrol": petrol[:min_len]
62
+ })
63
+
64
+ return df
@@ -0,0 +1,548 @@
1
+ import os
2
+ import math
3
+ import matplotlib.pyplot as plt
4
+ import matplotlib.dates as mdates
5
+ import pandas as pd
6
+ from datetime import datetime
7
+ from clean_charts.data import get_default_data
8
+
9
+ def hex_to_rgb(hex_str):
10
+ """Converts hex color string to an RGB tuple (0-255)."""
11
+ hex_str = hex_str.lstrip('#')
12
+ if len(hex_str) != 6:
13
+ raise ValueError(f"Invalid hex color format: {hex_str}")
14
+ return tuple(int(hex_str[i:i+2], 16) for i in (0, 2, 4))
15
+
16
+ def rgb_to_hex(rgb):
17
+ """Converts RGB tuple to hex color string."""
18
+ return '#{:02x}{:02x}{:02x}'.format(int(round(rgb[0])), int(round(rgb[1])), int(round(rgb[2])))
19
+
20
+ def get_gradient_colors(start_hex, end_hex, num_colors):
21
+ """Generates an interpolated gradient between two hex colors."""
22
+ if num_colors <= 1:
23
+ return [start_hex]
24
+ start_rgb = hex_to_rgb(start_hex)
25
+ end_rgb = hex_to_rgb(end_hex)
26
+
27
+ colors = []
28
+ for i in range(num_colors):
29
+ t = i / (num_colors - 1)
30
+ r = start_rgb[0] + t * (end_rgb[0] - start_rgb[0])
31
+ g = start_rgb[1] + t * (end_rgb[1] - start_rgb[1])
32
+ b = start_rgb[2] + t * (end_rgb[2] - start_rgb[2])
33
+ colors.append(rgb_to_hex((r, g, b)))
34
+ return colors
35
+
36
+ def plot_time_series(data=None, output_path="chart.png", width=None, height=None, aspect_ratio=None, title=None, subtitle=None, start_color=None, end_color=None, label_frequency="year", scale_text=True):
37
+ """
38
+ Plots a time-series line chart in the exact styling of the provided Economist chart.
39
+ Automatically identifies the date/time column and value columns from the data.
40
+ Optionally generates a color gradient between start_color and end_color.
41
+ Allows user to select X-axis label frequency and input custom title and subtitle.
42
+
43
+ Parameters:
44
+ data (pd.DataFrame): DataFrame with time series columns.
45
+ If None, the default reconstructed dataset will be used.
46
+ output_path (str): File path to save the generated image.
47
+ width (int): Target width of the image in pixels.
48
+ height (int): Target height of the image in pixels.
49
+ aspect_ratio (str): Aspect ratio: "square" (or "1:1"), "landscape" (or "2:1"), "vertical" (or "1:2").
50
+ If provided, dynamically sets width and height.
51
+ title (str): Custom title for the chart. Defaults to empty string.
52
+ subtitle (str): Custom subtitle for the chart. Defaults to empty string.
53
+ start_color (str): Hex color code for the first line series gradient boundary.
54
+ end_color (str): Hex color code for the last line series gradient boundary.
55
+ label_frequency (str): X-axis label frequency: "year", "quarter", "month", "week", "day", "hour", "minute", "second".
56
+ scale_text (bool): Whether to scale fonts and line weights proportionally.
57
+ """
58
+ if aspect_ratio is not None:
59
+ ar_str = str(aspect_ratio).lower().strip()
60
+ if ar_str in ["square", "1:1"]:
61
+ ar = 1.0
62
+ elif ar_str in ["landscape", "2:1"]:
63
+ ar = 2.0
64
+ elif ar_str in ["vertical", "1:2"]:
65
+ ar = 0.5
66
+ else:
67
+ raise ValueError(f"Unknown aspect ratio: {aspect_ratio}. Choose from 'square', 'landscape', 'vertical', '1:1', '2:1', '1:2'")
68
+
69
+ if width is not None and height is None:
70
+ height = int(width / ar)
71
+ elif height is not None and width is None:
72
+ width = int(height * ar)
73
+ elif width is not None and height is not None:
74
+ height = int(width / ar)
75
+ else:
76
+ if ar == 1.0:
77
+ width, height = 800, 800
78
+ elif ar == 2.0:
79
+ width, height = 1000, 500
80
+ elif ar == 0.5:
81
+ width, height = 500, 1000
82
+ else:
83
+ if width is None:
84
+ width = 1000
85
+ if height is None:
86
+ height = 562
87
+
88
+ if data is None:
89
+ data = get_default_data()
90
+
91
+ data = data.copy()
92
+
93
+ # Identify Date and Value columns dynamically
94
+ date_col = None
95
+ for col in data.columns:
96
+ if "date" in col.lower() or "time" in col.lower() or "timestamp" in col.lower():
97
+ date_col = col
98
+ break
99
+
100
+ if date_col is None:
101
+ for col in data.columns:
102
+ if pd.api.types.is_datetime64_any_dtype(data[col]):
103
+ date_col = col
104
+ break
105
+
106
+ if date_col is None:
107
+ date_col = data.columns[0]
108
+
109
+ value_cols = [col for col in data.columns if col != date_col]
110
+
111
+ # Ensure Date column is datetime
112
+ data[date_col] = pd.to_datetime(data[date_col])
113
+
114
+ # Calculate scale factor relative to a standard 1000x562 plot
115
+ scale = min(width / 1000.0, height / 562.0) if scale_text else 1.0
116
+
117
+ # Base styling tokens (scaled)
118
+ dpi = 100
119
+ fig_w = width / dpi
120
+ fig_h = height / dpi
121
+
122
+ # Font sizes
123
+ title_fs = max(6.0, 18.0 * scale)
124
+ subtitle_fs = max(5.0, 13.0 * scale)
125
+ label_fs = max(5.0, 13.0 * scale)
126
+ annotation_fs = max(5.0, 13.0 * scale)
127
+
128
+ # Line weights & sizes
129
+ line_w = max(1.0, 4.0 * scale)
130
+ grid_w = max(0.4, 0.8 * scale)
131
+ axis_w = max(0.6, 1.8 * scale)
132
+ tick_l = max(1.5, 6.0 * scale)
133
+ tick_w = max(0.5, 1.5 * scale)
134
+
135
+ # Colors
136
+ bg_color = "#f4f3f0" # Light cream-gray background
137
+ grid_color = "#dcdbd7" # Light grey gridlines
138
+ axis_color = "#000000" # Black for axes & ticks
139
+
140
+ # Establish line colors (gradient vs static Economist-like palette)
141
+ if start_color is not None and end_color is not None:
142
+ try:
143
+ colors_cycle = get_gradient_colors(start_color, end_color, len(value_cols))
144
+ except Exception as e:
145
+ print(f"Warning: Failed to interpolate colors ({e}). Using default Economist colors.")
146
+ colors_cycle = ["#f0957d", "#e3120b", "#7f8783", "#076fa1", "#6cb0e0", "#f1a629", "#789048"]
147
+ else:
148
+ colors_cycle = ["#f0957d", "#e3120b", "#7f8783", "#076fa1", "#6cb0e0", "#f1a629", "#789048"]
149
+
150
+ # Set up matplotlib figure
151
+ plt.rcParams['font.family'] = 'sans-serif'
152
+ plt.rcParams['font.sans-serif'] = ['Segoe UI', 'Arial', 'Helvetica', 'DejaVu Sans']
153
+
154
+ fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi, facecolor=bg_color)
155
+ ax.set_facecolor(bg_color)
156
+
157
+ # Plot the lines dynamically
158
+ for idx, col in enumerate(value_cols):
159
+ color = colors_cycle[idx % len(colors_cycle)]
160
+ zorder = 4 if idx == 1 else 3
161
+ ax.plot(data[date_col], data[col], color=color, linewidth=line_w, label=col, zorder=zorder)
162
+
163
+ # Y-axis configuration (Right side)
164
+ ax.yaxis.tick_right()
165
+ ax.yaxis.set_label_position("right")
166
+
167
+ # Calculate Y-axis limits dynamically
168
+ max_val = data[value_cols].max().max()
169
+ min_val = min(0.0, data[value_cols].min().min())
170
+ ax.set_ylim(bottom=min_val)
171
+
172
+ # Automatically locate major Y ticks
173
+ ax.yaxis.set_major_locator(plt.MaxNLocator(nbins=5, steps=[1, 2, 4, 5, 10]))
174
+
175
+ # Force drawing to fetch calculated ticks
176
+ fig.canvas.draw()
177
+ ticks = ax.get_yticks()
178
+ ticks = [t for t in ticks if t >= min_val]
179
+ ax.set_yticks(ticks)
180
+ ax.set_ylim(min_val, ticks[-1])
181
+
182
+ ax.tick_params(axis='y', which='both', length=0, labelsize=label_fs, colors="#333333", pad=5*scale)
183
+
184
+ # Gridlines (horizontal only)
185
+ ax.grid(True, axis='y', color=grid_color, linestyle='-', linewidth=grid_w, zorder=1)
186
+ ax.grid(False, axis='x')
187
+
188
+ # X-axis configuration
189
+ min_date = data[date_col].min()
190
+ max_date = data[date_col].max()
191
+ duration = max_date - min_date
192
+ pad_duration = max(duration * 0.03, pd.Timedelta(seconds=1))
193
+ ax.set_xlim(min_date, max_date + pad_duration)
194
+
195
+ # Configure date interval tick marks and centered label parameters dynamically
196
+ freq = label_frequency.lower().strip()
197
+
198
+ if freq == "year":
199
+ b_freq = "YS"
200
+ delta = pd.Timedelta(days=366)
201
+ elif freq == "quarter":
202
+ b_freq = "QS"
203
+ delta = pd.Timedelta(days=95)
204
+ elif freq == "month":
205
+ b_freq = "MS"
206
+ delta = pd.Timedelta(days=31)
207
+ elif freq == "week":
208
+ b_freq = "W-MON"
209
+ delta = pd.Timedelta(days=7)
210
+ elif freq == "day":
211
+ b_freq = "D"
212
+ delta = pd.Timedelta(days=1)
213
+ elif freq == "hour":
214
+ b_freq = "h"
215
+ delta = pd.Timedelta(hours=1)
216
+ elif freq == "minute":
217
+ b_freq = "min"
218
+ delta = pd.Timedelta(minutes=1)
219
+ elif freq == "second":
220
+ b_freq = "s"
221
+ delta = pd.Timedelta(seconds=1)
222
+ else:
223
+ raise ValueError(f"Unknown label frequency: {label_frequency}. Choose from 'year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second'")
224
+
225
+ # Generate boundary ticks safely containing the dates range
226
+ all_boundaries = pd.date_range(start=min_date - delta, end=max_date + delta, freq=b_freq)
227
+
228
+ # Filter boundaries to those relevant to our dataset
229
+ boundaries = [b for b in all_boundaries if b >= min_date - delta and b <= max_date + delta]
230
+ first_idx = 0
231
+ for idx, b in enumerate(boundaries):
232
+ if b <= min_date:
233
+ first_idx = idx
234
+
235
+ last_idx = len(boundaries) - 1
236
+ for idx, b in enumerate(boundaries):
237
+ if b >= max_date:
238
+ last_idx = idx
239
+ break
240
+
241
+ boundaries = boundaries[first_idx:last_idx+1]
242
+
243
+ # Make sure we have at least 2 boundary ticks to define an interval
244
+ if len(boundaries) < 2:
245
+ boundaries = [min_date, max_date + pad_duration]
246
+
247
+ # Sample ticks if there are too many to prevent label overlap
248
+ max_labels = 12
249
+ num_intervals = len(boundaries) - 1
250
+ if num_intervals > max_labels:
251
+ k = math.ceil(num_intervals / max_labels)
252
+ boundaries = boundaries[::k]
253
+ # Append the very last boundary if not present to close the last interval
254
+ if boundaries[-1] < max_date:
255
+ boundaries.append(max_date + pad_duration)
256
+
257
+ # Calculate boundary dates and centered labels based on frequency
258
+ boundary_dates = list(boundaries)
259
+ midpoint_dates = []
260
+
261
+ prev_date = None
262
+ for i in range(len(boundary_dates) - 1):
263
+ b_start = boundary_dates[i]
264
+ b_end = boundary_dates[i+1]
265
+ mid_date = b_start + (b_end - b_start) / 2
266
+
267
+ # Select label formatting conditionally based on changes relative to the previous date
268
+ if i == 0:
269
+ if freq == "year":
270
+ label_text = b_start.strftime("%Y")
271
+ elif freq == "quarter":
272
+ label_text = f"{b_start.year} Q{(b_start.month-1)//3 + 1}"
273
+ elif freq == "month":
274
+ label_text = b_start.strftime("%b %Y")
275
+ elif freq == "week":
276
+ label_text = b_start.strftime("%b %d, %Y")
277
+ elif freq == "day":
278
+ label_text = b_start.strftime("%b %d, %Y")
279
+ elif freq == "hour":
280
+ label_text = b_start.strftime("%b %d, %H:%M")
281
+ elif freq == "minute":
282
+ label_text = b_start.strftime("%b %d, %H:%M")
283
+ elif freq == "second":
284
+ label_text = b_start.strftime("%H:%M:%S")
285
+ else:
286
+ label_text = b_start.strftime("%Y-%m-%d")
287
+ else:
288
+ if freq == "year":
289
+ label_text = b_start.strftime("%y")
290
+ elif freq == "quarter":
291
+ q_num = (b_start.month-1)//3 + 1
292
+ if b_start.year != prev_date.year:
293
+ label_text = f"{b_start.year} Q{q_num}"
294
+ else:
295
+ label_text = f"Q{q_num}"
296
+ elif freq == "month":
297
+ if b_start.year != prev_date.year:
298
+ label_text = b_start.strftime("%b %Y")
299
+ else:
300
+ label_text = b_start.strftime("%b")
301
+ elif freq == "week":
302
+ if b_start.year != prev_date.year:
303
+ label_text = b_start.strftime("%b %d, %Y")
304
+ elif b_start.month != prev_date.month:
305
+ label_text = b_start.strftime("%b %d")
306
+ else:
307
+ label_text = b_start.strftime("%d")
308
+ elif freq == "day":
309
+ if b_start.year != prev_date.year:
310
+ label_text = b_start.strftime("%b %d, %Y")
311
+ elif b_start.month != prev_date.month:
312
+ label_text = b_start.strftime("%b %d")
313
+ else:
314
+ label_text = b_start.strftime("%d")
315
+ elif freq == "hour":
316
+ if b_start.date() != prev_date.date():
317
+ label_text = b_start.strftime("%b %d, %H:%M")
318
+ else:
319
+ label_text = b_start.strftime("%H:%M")
320
+ elif freq == "minute":
321
+ if b_start.date() != prev_date.date():
322
+ label_text = b_start.strftime("%b %d, %H:%M")
323
+ elif b_start.hour != prev_date.hour:
324
+ label_text = b_start.strftime("%H:%M")
325
+ else:
326
+ label_text = b_start.strftime(":%M")
327
+ elif freq == "second":
328
+ if b_start.hour != prev_date.hour or b_start.minute != prev_date.minute:
329
+ label_text = b_start.strftime("%H:%M:%S")
330
+ else:
331
+ label_text = b_start.strftime(":%S")
332
+ else:
333
+ label_text = b_start.strftime("%Y-%m-%d")
334
+
335
+ midpoint_dates.append((mid_date, label_text))
336
+ prev_date = b_start
337
+
338
+ # Apply X ticks
339
+ ax.set_xticks(boundary_dates)
340
+ ax.set_xticklabels([])
341
+
342
+ # Enable ticks pointing downwards
343
+ ax.tick_params(axis='x', direction='out', length=tick_l, width=tick_w, colors=axis_color)
344
+
345
+ # Draw centered date/time labels
346
+ for date, label in midpoint_dates:
347
+ ax.text(
348
+ date,
349
+ -0.04,
350
+ label,
351
+ transform=ax.get_xaxis_transform(),
352
+ ha="center",
353
+ va="top",
354
+ fontsize=label_fs,
355
+ color="#333333",
356
+ fontweight="normal"
357
+ )
358
+
359
+ # Spine/Borders styling
360
+ ax.spines['top'].set_visible(False)
361
+ ax.spines['left'].set_visible(False)
362
+ ax.spines['right'].set_visible(False)
363
+ ax.spines['bottom'].set_visible(True)
364
+ ax.spines['bottom'].set_color(axis_color)
365
+ ax.spines['bottom'].set_linewidth(axis_w)
366
+
367
+ # Inline labels next to the lines
368
+ if height >= 150:
369
+ is_original_dataset = set(value_cols) == {"Electric", "Hybrid", "Petrol"} and min_date.year == 2020 and freq == "year"
370
+
371
+ if is_original_dataset:
372
+ # Recreate original chart annotations exactly
373
+ ax.text(
374
+ pd.Timestamp("2023-04-01"),
375
+ 8.6,
376
+ "Electric",
377
+ color="#000000",
378
+ fontsize=annotation_fs,
379
+ ha="center",
380
+ va="bottom",
381
+ fontweight="normal"
382
+ )
383
+ ax.text(
384
+ pd.Timestamp("2024-11-01"),
385
+ 13.2,
386
+ "Hybrid",
387
+ color="#000000",
388
+ fontsize=annotation_fs,
389
+ ha="right",
390
+ va="bottom",
391
+ fontweight="normal"
392
+ )
393
+ ax.text(
394
+ pd.Timestamp("2025-01-01"),
395
+ 2.7,
396
+ "Petrol",
397
+ color="#000000",
398
+ fontsize=annotation_fs,
399
+ ha="center",
400
+ va="bottom",
401
+ fontweight="normal"
402
+ )
403
+ else:
404
+ # Overlap-avoiding annotation placement
405
+ last_points = []
406
+ for col in value_cols:
407
+ valid = data[[date_col, col]].dropna()
408
+ if not valid.empty:
409
+ last_points.append({
410
+ "col": col,
411
+ "date": valid[date_col].iloc[-1],
412
+ "val": valid[col].iloc[-1]
413
+ })
414
+
415
+ last_points.sort(key=lambda x: x["val"])
416
+
417
+ y_range = ticks[-1] - min_val
418
+ min_spacing = max(0.05 * y_range, 0.5)
419
+
420
+ for i in range(1, len(last_points)):
421
+ diff = last_points[i]["val"] - last_points[i-1]["val"]
422
+ if diff < min_spacing:
423
+ last_points[i]["val"] = last_points[i-1]["val"] + min_spacing
424
+
425
+ for pt in last_points:
426
+ ax.text(
427
+ pt["date"] - duration * 0.01,
428
+ pt["val"] + 0.01 * y_range,
429
+ pt["col"],
430
+ color="#000000",
431
+ fontsize=annotation_fs,
432
+ ha="right",
433
+ va="bottom",
434
+ fontweight="normal"
435
+ )
436
+
437
+ # Title wrapping & line limiting (max 2 lines)
438
+ if title is None:
439
+ title = ""
440
+
441
+ if title:
442
+ import textwrap
443
+ # Estimate average character width in pixels based on font size (1pt = 1.39px)
444
+ char_width_px = title_fs * (100.0 / 72.0) * 0.55
445
+ # Assume title occupies at most 90% of the figure width
446
+ max_chars = max(15, int((width * 0.90) / char_width_px))
447
+
448
+ lines = textwrap.wrap(title, width=max_chars)
449
+ if len(lines) > 2:
450
+ lines = lines[:2]
451
+ sec_line = lines[1]
452
+ if len(sec_line) > max_chars - 3:
453
+ sec_line = sec_line[:max_chars - 3]
454
+ lines[1] = sec_line.rstrip() + "..."
455
+ title = "\n".join(lines)
456
+
457
+ # Subtitle wrapping & line limiting (max 2 lines)
458
+ if subtitle is None:
459
+ subtitle = ""
460
+
461
+ if subtitle:
462
+ import textwrap
463
+ # Estimate average character width in pixels for subtitle font size
464
+ char_width_sub_px = subtitle_fs * (100.0 / 72.0) * 0.55
465
+ max_chars_sub = max(15, int((width * 0.90) / char_width_sub_px))
466
+
467
+ sub_lines = textwrap.wrap(subtitle, width=max_chars_sub)
468
+ if len(sub_lines) > 2:
469
+ sub_lines = sub_lines[:2]
470
+ sec_line_sub = sub_lines[1]
471
+ if len(sec_line_sub) > max_chars_sub - 3:
472
+ sec_line_sub = sec_line_sub[:max_chars_sub - 3]
473
+ sub_lines[1] = sec_line_sub.rstrip() + "..."
474
+ subtitle = "\n".join(sub_lines)
475
+
476
+ # Combined padding after title/subtitle block (before axis top)
477
+ combined_pad = 25 * scale
478
+
479
+ # Calculate title padding dynamically based on subtitle lines
480
+ num_sub_lines = len(subtitle.split('\n')) if subtitle else 0
481
+ if subtitle:
482
+ title_pad = combined_pad + (num_sub_lines * 15 + 8) * scale
483
+ else:
484
+ title_pad = combined_pad
485
+
486
+ # Set the title with dynamic padding
487
+ ax.set_title(
488
+ title,
489
+ loc='left',
490
+ fontsize=title_fs,
491
+ fontweight='bold',
492
+ color='#111111',
493
+ pad=title_pad
494
+ )
495
+
496
+ # Draw subtitle if provided, positioned at the combined padding offset above axis top
497
+ if subtitle:
498
+ ax.annotate(
499
+ subtitle,
500
+ xy=(0.0, 1.0),
501
+ xycoords='axes fraction',
502
+ xytext=(0.0, combined_pad),
503
+ textcoords='offset points',
504
+ fontsize=subtitle_fs,
505
+ fontweight='normal',
506
+ color='#444444',
507
+ va='bottom',
508
+ ha='left'
509
+ )
510
+
511
+ # Target empty margin on all sides of outermost visual content
512
+ margin_px = 52.5 * scale
513
+
514
+ # Spacing offsets for elements extending outside the axis boundaries
515
+ left_offset = 0.0
516
+ right_offset = 23 * scale # Y-axis labels ("0", "4", ..., "16")
517
+ bottom_offset = 25 * scale # X-axis tick marks and year labels
518
+
519
+ # Calculate title/subtitle block height above the axis top line
520
+ num_title_lines = len(title.split('\n')) if title else 0
521
+ title_subtitle_block_height = title_pad + num_title_lines * 22 * scale
522
+
523
+ left_margin = margin_px + left_offset
524
+ right_margin = margin_px + right_offset
525
+ bottom_margin = margin_px + bottom_offset
526
+ top_margin = margin_px + title_subtitle_block_height + 15 * scale
527
+
528
+ # Convert margins to figure fraction coordinates
529
+ left_pad = left_margin / width
530
+ right_pad = 1.0 - (right_margin / width)
531
+ bottom_pad = bottom_margin / height
532
+ top_pad = 1.0 - (top_margin / height)
533
+
534
+ # Set safety bounds
535
+ left_pad = max(0.01, min(left_pad, 0.35))
536
+ right_pad = max(0.60, min(right_pad, 0.99))
537
+ bottom_pad = max(0.01, min(bottom_pad, 0.45))
538
+ top_pad = max(0.40, min(top_pad, 0.95))
539
+
540
+ fig.subplots_adjust(left=left_pad, right=right_pad, bottom=bottom_pad, top=top_pad)
541
+
542
+ # Save the figure
543
+ dir_name = os.path.dirname(output_path)
544
+ if dir_name:
545
+ os.makedirs(dir_name, exist_ok=True)
546
+
547
+ plt.savefig(output_path, facecolor=bg_color, edgecolor='none', dpi=dpi)
548
+ plt.close(fig)
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: clean-charts
3
+ Version: 0.1.2
4
+ Summary: A Python library to generate beautiful, clean charts in premium visual styles
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: matplotlib>=3.5.0
9
+ Requires-Dist: pandas>=1.3.0
10
+ Requires-Dist: numpy>=1.20.0
11
+ Requires-Dist: pillow>=8.0.0
12
+ Dynamic: license-file
13
+
14
+ # Clean Charts Library
15
+
16
+ A Python library to generate line charts in clean, premium visual styles, including right-aligned axes, custom year boundaries, distinct line colors, and dynamic scaling for mini canvas resolutions.
17
+
18
+ ![Example Chart](notebook_chart_long_title.png)
19
+
20
+ The library automatically identifies date/time columns and any number of value series to plot them dynamically, with overlap-avoiding label placement, and allows custom color interpolation, date label frequencies, titles, and subtitles.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install .
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ You can run the script without any parameters to recreate the chart from the reference image:
31
+
32
+ ```python
33
+ from clean_charts import plot_time_series
34
+
35
+ # Generates the default landscape image (1000x500)
36
+ plot_time_series(
37
+ output_path="chart_landscape.png",
38
+ aspect_ratio="landscape",
39
+ title="Europe",
40
+ subtitle="Sales of Chinese-made cars, % of total"
41
+ )
42
+
43
+ # Generates a 500x500 square visualization
44
+ plot_time_series(
45
+ output_path="chart_500.png",
46
+ width=500,
47
+ height=500,
48
+ title="Europe",
49
+ subtitle="Sales of Chinese-made cars, % of total"
50
+ )
51
+
52
+ # Generates a chart with a custom color gradient (from Indigo to Coral)
53
+ plot_time_series(
54
+ output_path="chart_gradient.png",
55
+ start_color="#4b0082",
56
+ end_color="#ff7f50",
57
+ title="EV Market Split",
58
+ subtitle="Gradient Theme Demonstration"
59
+ )
60
+ ```
61
+
62
+ ## Custom Data Input and X-Axis Frequencies
63
+
64
+ You can supply your own pandas DataFrame with any date/time column and value columns. The library dynamically identifies them and configures the X-axis label frequency:
65
+
66
+ ```python
67
+ import pandas as pd
68
+ from clean_charts import plot_time_series
69
+
70
+ # Day-frequency dataset example
71
+ daily_data = pd.DataFrame({
72
+ "Day": pd.date_range("2026-05-01", periods=10, freq="D"),
73
+ "Users": [120, 150, 190, 240, 220, 250, 270, 310, 340, 320],
74
+ "Signups": [15, 22, 35, 40, 28, 30, 32, 45, 52, 48]
75
+ })
76
+
77
+ plot_time_series(
78
+ data=daily_data,
79
+ output_path="daily_chart.png",
80
+ title="Server Statistics",
81
+ subtitle="10-Day Signups growth",
82
+ label_frequency="day", # Supported: "year", "quarter", "month", "week", "day", "hour", "minute", "second"
83
+ start_color="#006400",
84
+ end_color="#ffd700"
85
+ )
86
+ ```
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ clean_charts/__init__.py
5
+ clean_charts/data.py
6
+ clean_charts/plot.py
7
+ clean_charts.egg-info/PKG-INFO
8
+ clean_charts.egg-info/SOURCES.txt
9
+ clean_charts.egg-info/dependency_links.txt
10
+ clean_charts.egg-info/notebook_chart_long_title.png
11
+ clean_charts.egg-info/requires.txt
12
+ clean_charts.egg-info/top_level.txt
13
+ tests/test_plot.py
@@ -0,0 +1,4 @@
1
+ matplotlib>=3.5.0
2
+ pandas>=1.3.0
3
+ numpy>=1.20.0
4
+ pillow>=8.0.0
@@ -0,0 +1 @@
1
+ clean_charts
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "clean-charts"
7
+ version = "0.1.2"
8
+ description = "A Python library to generate beautiful, clean charts in premium visual styles"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ dependencies = [
12
+ "matplotlib>=3.5.0",
13
+ "pandas>=1.3.0",
14
+ "numpy>=1.20.0",
15
+ "pillow>=8.0.0"
16
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,147 @@
1
+ import os
2
+ import unittest
3
+ import sys
4
+ import pandas as pd
5
+ from PIL import Image
6
+
7
+ # Add root directory to path
8
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
9
+
10
+ from clean_charts import plot_time_series, get_default_data
11
+
12
+ class TestEconomistChart(unittest.TestCase):
13
+ def setUp(self):
14
+ self.temp_full = "tests/test_full.png"
15
+ self.temp_500 = "tests/test_500.png"
16
+ self.temp_square = "tests/test_square.png"
17
+ self.temp_landscape = "tests/test_landscape.png"
18
+ self.temp_vertical = "tests/test_vertical.png"
19
+ self.temp_title = "tests/test_title.png"
20
+ self.temp_dynamic = "tests/test_dynamic.png"
21
+ self.temp_colors = "tests/test_colors.png"
22
+ self.temp_freq = "tests/test_freq.png"
23
+ self.temp_wrap = "tests/test_wrap.png"
24
+ self.temp_sub = "tests/test_sub.png"
25
+
26
+ # Ensure tests dir exists
27
+ os.makedirs("tests", exist_ok=True)
28
+
29
+ def tearDown(self):
30
+ # Clean up temporary test files
31
+ for f in [self.temp_full, self.temp_500, self.temp_square, self.temp_landscape, self.temp_vertical, self.temp_title, self.temp_dynamic, self.temp_colors, self.temp_freq, self.temp_wrap, self.temp_sub]:
32
+ if os.path.exists(f):
33
+ os.remove(f)
34
+
35
+ def test_default_data(self):
36
+ df = get_default_data()
37
+ self.assertIsNotNone(df)
38
+ self.assertIn("Date", df.columns)
39
+ self.assertIn("Electric", df.columns)
40
+ self.assertIn("Hybrid", df.columns)
41
+ self.assertIn("Petrol", df.columns)
42
+ self.assertGreater(len(df), 50)
43
+
44
+ def test_plot_dimensions(self):
45
+ # 1. Test full scale
46
+ plot_time_series(output_path=self.temp_full, width=1000, height=562)
47
+ self.assertTrue(os.path.exists(self.temp_full))
48
+ with Image.open(self.temp_full) as img:
49
+ self.assertEqual(img.size, (1000, 562))
50
+
51
+ # 2. Test 500x500
52
+ plot_time_series(output_path=self.temp_500, width=500, height=500)
53
+ self.assertTrue(os.path.exists(self.temp_500))
54
+ with Image.open(self.temp_500) as img:
55
+ self.assertEqual(img.size, (500, 500))
56
+
57
+ def test_aspect_ratios(self):
58
+ # Square (1:1)
59
+ plot_time_series(output_path=self.temp_square, aspect_ratio="square")
60
+ self.assertTrue(os.path.exists(self.temp_square))
61
+ with Image.open(self.temp_square) as img:
62
+ self.assertEqual(img.size[0], img.size[1]) # width == height
63
+
64
+ # Landscape (2:1)
65
+ plot_time_series(output_path=self.temp_landscape, aspect_ratio="2:1")
66
+ self.assertTrue(os.path.exists(self.temp_landscape))
67
+ with Image.open(self.temp_landscape) as img:
68
+ self.assertEqual(img.size[0] / img.size[1], 2.0) # width == 2 * height
69
+
70
+ # Vertical (1:2)
71
+ plot_time_series(output_path=self.temp_vertical, aspect_ratio="vertical", height=600)
72
+ self.assertTrue(os.path.exists(self.temp_vertical))
73
+ with Image.open(self.temp_vertical) as img:
74
+ self.assertEqual(img.size[0] / img.size[1], 0.5) # width == 0.5 * height
75
+ self.assertEqual(img.size[1], 600)
76
+
77
+ def test_custom_title(self):
78
+ plot_time_series(output_path=self.temp_title, title="Custom Test Title")
79
+ self.assertTrue(os.path.exists(self.temp_title))
80
+
81
+ def test_dynamic_columns(self):
82
+ # Generate custom dataset with columns unrelated to the original
83
+ custom_data = pd.DataFrame({
84
+ "YearMonth": pd.date_range("2010-01-01", periods=24, freq="MS"),
85
+ "Apples": [10.0 + i*0.5 for i in range(24)],
86
+ "Oranges": [15.0 - i*0.2 for i in range(24)]
87
+ })
88
+ plot_time_series(
89
+ data=custom_data,
90
+ output_path=self.temp_dynamic,
91
+ title="Fruit Production Trends",
92
+ aspect_ratio="landscape"
93
+ )
94
+ self.assertTrue(os.path.exists(self.temp_dynamic))
95
+
96
+ def test_color_interpolation(self):
97
+ plot_time_series(
98
+ output_path=self.temp_colors,
99
+ start_color="#ff0000",
100
+ end_color="#0000ff",
101
+ title="Gradient Test Chart"
102
+ )
103
+ self.assertTrue(os.path.exists(self.temp_colors))
104
+
105
+ def test_label_frequencies(self):
106
+ # Test 1: Quarter
107
+ plot_time_series(output_path=self.temp_freq, label_frequency="quarter")
108
+ self.assertTrue(os.path.exists(self.temp_freq))
109
+ os.remove(self.temp_freq)
110
+
111
+ # Test 2: Month
112
+ plot_time_series(output_path=self.temp_freq, label_frequency="month")
113
+ self.assertTrue(os.path.exists(self.temp_freq))
114
+ os.remove(self.temp_freq)
115
+
116
+ # Test 3: Day frequency on daily dataset
117
+ daily_df = pd.DataFrame({
118
+ "Day": pd.date_range("2023-01-01", periods=10, freq="D"),
119
+ "Clicks": [100 + i*10 for i in range(10)]
120
+ })
121
+ plot_time_series(data=daily_df, output_path=self.temp_freq, label_frequency="day", title="Daily Yield")
122
+ self.assertTrue(os.path.exists(self.temp_freq))
123
+ os.remove(self.temp_freq)
124
+
125
+ # Test 4: Minute frequency on minute-level dataset
126
+ minute_df = pd.DataFrame({
127
+ "Time": pd.date_range("2023-01-01 10:00:00", periods=15, freq="min"),
128
+ "Value": [10 + i for i in range(15)]
129
+ })
130
+ plot_time_series(data=minute_df, output_path=self.temp_freq, label_frequency="minute", title="Live Server Load")
131
+ self.assertTrue(os.path.exists(self.temp_freq))
132
+
133
+ def test_title_wrapping(self):
134
+ long_title = "This is an extremely long title designed specifically to exceed the maximum character capacity of a single line, causing the text wrapping function to split it into multiple lines and truncate it to exactly two lines with an ellipsis at the very end."
135
+ plot_time_series(output_path=self.temp_wrap, title=long_title)
136
+ self.assertTrue(os.path.exists(self.temp_wrap))
137
+
138
+ def test_subtitle(self):
139
+ plot_time_series(
140
+ output_path=self.temp_sub,
141
+ title="Sales in Europe",
142
+ subtitle="Chinese-made cars share, % of total"
143
+ )
144
+ self.assertTrue(os.path.exists(self.temp_sub))
145
+
146
+ if __name__ == "__main__":
147
+ unittest.main()