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.
- clean_charts-0.1.2/LICENSE +21 -0
- clean_charts-0.1.2/PKG-INFO +86 -0
- clean_charts-0.1.2/README.md +73 -0
- clean_charts-0.1.2/clean_charts/__init__.py +4 -0
- clean_charts-0.1.2/clean_charts/data.py +64 -0
- clean_charts-0.1.2/clean_charts/plot.py +548 -0
- clean_charts-0.1.2/clean_charts.egg-info/PKG-INFO +86 -0
- clean_charts-0.1.2/clean_charts.egg-info/SOURCES.txt +13 -0
- clean_charts-0.1.2/clean_charts.egg-info/dependency_links.txt +1 -0
- clean_charts-0.1.2/clean_charts.egg-info/notebook_chart_long_title.png +0 -0
- clean_charts-0.1.2/clean_charts.egg-info/requires.txt +4 -0
- clean_charts-0.1.2/clean_charts.egg-info/top_level.txt +1 -0
- clean_charts-0.1.2/pyproject.toml +16 -0
- clean_charts-0.1.2/setup.cfg +4 -0
- clean_charts-0.1.2/tests/test_plot.py +147 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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,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
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
Binary file
|
|
@@ -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,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()
|