chartengineer 0.1.0__py3-none-any.whl
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 @@
|
|
|
1
|
+
from .core import (ChartMaker)
|
chartengineer/core.py
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
from plotly.subplots import make_subplots
|
|
2
|
+
import plotly.graph_objects as go
|
|
3
|
+
from plotly.graph_objects import Scatter, Bar
|
|
4
|
+
import plotly.offline as pyo
|
|
5
|
+
import plotly.io as pio
|
|
6
|
+
import copy
|
|
7
|
+
|
|
8
|
+
from pandas.api.types import is_datetime64_any_dtype
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from chartengineer.utils import (colors, clean_values,to_percentage,normalize_to_percent)
|
|
13
|
+
|
|
14
|
+
trace_map = {
|
|
15
|
+
"line": Scatter,
|
|
16
|
+
"area": Scatter,
|
|
17
|
+
"bar": Bar,
|
|
18
|
+
"pie": go.Pie
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
def validate_textposition(kind, textposition):
|
|
22
|
+
if kind == "bar":
|
|
23
|
+
valid_bar_pos = ["inside", "outside", "auto", "none"]
|
|
24
|
+
return textposition if textposition in valid_bar_pos else "auto"
|
|
25
|
+
return textposition
|
|
26
|
+
|
|
27
|
+
class ChartMaker:
|
|
28
|
+
def __init__(self, default_options=None, shuffle_colors=False):
|
|
29
|
+
self.colors = colors(shuffle_colors)
|
|
30
|
+
self.color_index = 0
|
|
31
|
+
self.fig = None
|
|
32
|
+
self.merged_opts = None
|
|
33
|
+
self.title = None
|
|
34
|
+
self.series = []
|
|
35
|
+
self.df = None
|
|
36
|
+
self.default_options = default_options or {
|
|
37
|
+
"font_color": "black",
|
|
38
|
+
"font_family": "Cardo",
|
|
39
|
+
"orientation": "v",
|
|
40
|
+
"legend_orientation": "v",
|
|
41
|
+
"legend_background": dict(bgcolor="rgba(0,0,0,0)",bordercolor="rgba(0,0,0,0)",
|
|
42
|
+
borderwidth=1,itemsizing='constant',buffer=5,
|
|
43
|
+
traceorder='normal'),
|
|
44
|
+
'legend_placement': dict(x=0.01,y=1.1),
|
|
45
|
+
"connectgap": True,
|
|
46
|
+
"barmode": "stack",
|
|
47
|
+
"bgcolor": "rgba(0,0,0,0)",
|
|
48
|
+
"autosize": True,
|
|
49
|
+
"margin": dict(l=10, r=10, t=10, b=10),
|
|
50
|
+
"dimensions": dict(width=730, height=400),
|
|
51
|
+
"font_size": dict(axes=16,legend=12,textfont=12),
|
|
52
|
+
"axes_titles": dict(x=None,y1=None,y2=None),
|
|
53
|
+
"decimals": True,
|
|
54
|
+
"decimal_places": 1,
|
|
55
|
+
"show_text": False,
|
|
56
|
+
"dt_format": '%b. %d, %Y',
|
|
57
|
+
"auto_title": False,
|
|
58
|
+
"auto_color": True,
|
|
59
|
+
"normalize": False,
|
|
60
|
+
"line_width": 4,
|
|
61
|
+
"marker_size": 10,
|
|
62
|
+
"cumulative_sort": True,
|
|
63
|
+
"hole_size": 0.6,
|
|
64
|
+
"annotations": False,
|
|
65
|
+
"max_annotation": False,
|
|
66
|
+
'tickprefix': dict(y1=None, y2=None),
|
|
67
|
+
'ticksuffix': dict(y1=None,y2=None),
|
|
68
|
+
'save_directory': None,
|
|
69
|
+
'space_buffer': 5,
|
|
70
|
+
'descending': True,
|
|
71
|
+
'datetime_format': '%b. %d, %Y',
|
|
72
|
+
'tickformat': dict(x=None,y1=None,y2=None),
|
|
73
|
+
'normalize': False,
|
|
74
|
+
'text_freq': 1,
|
|
75
|
+
'textposition':'top center',
|
|
76
|
+
"orientation":'v'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def get_next_color(self):
|
|
80
|
+
color = self.colors[self.color_index]
|
|
81
|
+
self.color_index = (self.color_index + 1) % len(self.colors)
|
|
82
|
+
return color
|
|
83
|
+
|
|
84
|
+
def return_df(self):
|
|
85
|
+
return self.df.copy()
|
|
86
|
+
|
|
87
|
+
def save_fig(self, save_directory=None, filetype='png'):
|
|
88
|
+
"""Save the figure to the specified directory with the given filetype."""
|
|
89
|
+
# Construct the full file path using os.path.join
|
|
90
|
+
if save_directory:
|
|
91
|
+
self.save_directory = save_directory
|
|
92
|
+
|
|
93
|
+
file_path = os.path.join(self.save_directory, f'{self.title}.{filetype}')
|
|
94
|
+
|
|
95
|
+
print(f'Saving figure to: {file_path}')
|
|
96
|
+
|
|
97
|
+
if filetype != 'html':
|
|
98
|
+
# Save as image using Kaleido engine
|
|
99
|
+
self.fig.write_image(file_path, engine="kaleido")
|
|
100
|
+
else:
|
|
101
|
+
# Save as HTML
|
|
102
|
+
self.fig.write_html(file_path)
|
|
103
|
+
|
|
104
|
+
def show_fig(self,browser=False):
|
|
105
|
+
if browser==False:
|
|
106
|
+
pyo.iplot(self.fig)
|
|
107
|
+
else:
|
|
108
|
+
pyo.plot(self.fig)
|
|
109
|
+
|
|
110
|
+
def clear(self):
|
|
111
|
+
self.fig = None
|
|
112
|
+
self.series = []
|
|
113
|
+
self.df = None
|
|
114
|
+
self.color_index = 0
|
|
115
|
+
|
|
116
|
+
def return_fig(self):
|
|
117
|
+
return self.fig
|
|
118
|
+
|
|
119
|
+
def _prepare_grouped_series(self, df, groupby_col, num_col, descending=True, cumulative_sort=True):
|
|
120
|
+
"""
|
|
121
|
+
Groups and sorts data for multi-line plots.
|
|
122
|
+
Supports cumulative or latest value sorting.
|
|
123
|
+
Returns: list of sorted categories, color map
|
|
124
|
+
"""
|
|
125
|
+
# Decide aggregation method based on cumulative_sort flag
|
|
126
|
+
if cumulative_sort:
|
|
127
|
+
# Sort by cumulative (sum) of the values
|
|
128
|
+
sort_agg = df.groupby(groupby_col)[num_col].sum().sort_values(ascending=not descending)
|
|
129
|
+
else:
|
|
130
|
+
# Sort by latest (last known) value
|
|
131
|
+
sort_agg = df.groupby(groupby_col)[num_col].last().sort_values(ascending=not descending)
|
|
132
|
+
|
|
133
|
+
sort_list = sort_agg.index.tolist()
|
|
134
|
+
|
|
135
|
+
color_map = {
|
|
136
|
+
cat: self.get_next_color() for cat in sort_list
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return sort_list, color_map
|
|
140
|
+
|
|
141
|
+
def build(self, df, title, axes_data=None, chart_type={"y1": "line", "y2": "line"}, options=None,
|
|
142
|
+
groupby_col=None, num_col=None):
|
|
143
|
+
options = options or {}
|
|
144
|
+
axes_data = axes_data or {}
|
|
145
|
+
|
|
146
|
+
merged_opts = copy.deepcopy(self.default_options)
|
|
147
|
+
|
|
148
|
+
for key, val in (options or {}).items():
|
|
149
|
+
# if both default and override are dicts, update the nested dict
|
|
150
|
+
if key in merged_opts and isinstance(merged_opts[key], dict) and isinstance(val, dict):
|
|
151
|
+
merged_opts[key].update(val)
|
|
152
|
+
else:
|
|
153
|
+
merged_opts[key] = val
|
|
154
|
+
|
|
155
|
+
if merged_opts.get('normalize') == True:
|
|
156
|
+
print(f'normalizing to % ...')
|
|
157
|
+
df = normalize_to_percent(df=df,num_col=num_col)
|
|
158
|
+
|
|
159
|
+
self.df = df if self.df is None else pd.concat([self.df, df]).drop_duplicates()
|
|
160
|
+
self.merged_opts = merged_opts
|
|
161
|
+
|
|
162
|
+
orientation = merged_opts.get("orientation", "v")
|
|
163
|
+
|
|
164
|
+
self.title = title
|
|
165
|
+
self.save_directory = merged_opts.get('save_directory', None)
|
|
166
|
+
plotted_cols = []
|
|
167
|
+
|
|
168
|
+
space_buffer = " " * merged_opts.get('space_buffer')
|
|
169
|
+
|
|
170
|
+
# Detect if a pie chart was requested as a string
|
|
171
|
+
if isinstance(chart_type, str):
|
|
172
|
+
if chart_type.lower() == "pie":
|
|
173
|
+
kind = "pie"
|
|
174
|
+
elif chart_type.lower() == 'heatmap':
|
|
175
|
+
kind='heatmap'
|
|
176
|
+
else:
|
|
177
|
+
chart_type = {"y1": chart_type, "y2": chart_type}
|
|
178
|
+
kind = None
|
|
179
|
+
else:
|
|
180
|
+
kind = None
|
|
181
|
+
|
|
182
|
+
# === PIE CHART HANDLING ===
|
|
183
|
+
if kind == "pie":
|
|
184
|
+
if groupby_col and num_col:
|
|
185
|
+
index_col = groupby_col
|
|
186
|
+
sum_col = num_col
|
|
187
|
+
else:
|
|
188
|
+
sum_col = axes_data.get("y1", [])[0] if isinstance(axes_data.get("y1"), list) else axes_data.get("y1")
|
|
189
|
+
index_col = axes_data.get("x") or df.index.name or df.index
|
|
190
|
+
|
|
191
|
+
if not sum_col or not index_col:
|
|
192
|
+
raise ValueError("For pie chart, either (groupby_col and num_col) or axes_data['x'] and ['y1'] must be provided.")
|
|
193
|
+
|
|
194
|
+
# Extract merged options with fallbacks to the pie_chart function defaults
|
|
195
|
+
colors = merged_opts.get("colors", self.colors)
|
|
196
|
+
bgcolor = merged_opts.get("bgcolor", "rgba(0,0,0,0)")
|
|
197
|
+
annotation_prefix = merged_opts.get("tickprefix", {}).get("y1") or ""
|
|
198
|
+
annotation_suffix = merged_opts.get("ticksuffix", {}).get("y1") or ""
|
|
199
|
+
annotation_font_size = merged_opts.get("annotation_font_size", 25)
|
|
200
|
+
decimals = merged_opts.get("decimals", True)
|
|
201
|
+
decimal_places = merged_opts.get("decimal_places", 1)
|
|
202
|
+
legend_font_size = merged_opts.get("font_size", {}).get("legend", 16)
|
|
203
|
+
font_size = merged_opts.get("font_size", {}).get("axes", 18)
|
|
204
|
+
legend_placement = merged_opts.get("legend_placement", dict(x=0.01, y=1.1))
|
|
205
|
+
margin = merged_opts.get("margin", dict(l=0, r=0, t=0, b=0))
|
|
206
|
+
hole_size = merged_opts.get("hole_size", 0.6)
|
|
207
|
+
line_width = merged_opts.get("line_width", 0)
|
|
208
|
+
legend_orientation = merged_opts.get("legend_orientation", "v")
|
|
209
|
+
itemsizing = merged_opts.get("legend_background", {}).get("itemsizing", "constant")
|
|
210
|
+
dimensions = merged_opts.get("dimensions", dict(width=730, height=400))
|
|
211
|
+
font_family = merged_opts.get("font_family", "Cardo")
|
|
212
|
+
font_color = merged_opts.get("font_color", "black")
|
|
213
|
+
textinfo = merged_opts.get("textinfo", "none")
|
|
214
|
+
show_legend = merged_opts.get("show_legend", False)
|
|
215
|
+
text_font_size = merged_opts.get("font_size", {}).get("textfont", 12)
|
|
216
|
+
text_font_color = merged_opts.get("text_font_color", "black")
|
|
217
|
+
texttemplate = merged_opts.get("texttemplate", None)
|
|
218
|
+
annotation = merged_opts.get("annotations", True)
|
|
219
|
+
file_type = merged_opts.get("file_type", "svg")
|
|
220
|
+
directory = merged_opts.get("save_directory", "../img")
|
|
221
|
+
|
|
222
|
+
# Calculate percentages if needed
|
|
223
|
+
if textinfo == 'percent+label':
|
|
224
|
+
percent=False
|
|
225
|
+
else:
|
|
226
|
+
percent=True
|
|
227
|
+
df, total = to_percentage(df, sum_col, index_col, percent=percent)
|
|
228
|
+
padded_labels = [f"{label} " for label in df.index]
|
|
229
|
+
|
|
230
|
+
fig = go.Figure(data=[
|
|
231
|
+
go.Pie(
|
|
232
|
+
labels=padded_labels,
|
|
233
|
+
values=df[sum_col],
|
|
234
|
+
hole=hole_size,
|
|
235
|
+
textinfo=textinfo,
|
|
236
|
+
showlegend=show_legend,
|
|
237
|
+
texttemplate=texttemplate,
|
|
238
|
+
marker=dict(colors=colors, line=dict(color='white', width=line_width)),
|
|
239
|
+
textfont=dict(
|
|
240
|
+
family=font_family,
|
|
241
|
+
size=text_font_size,
|
|
242
|
+
color=text_font_color
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
])
|
|
246
|
+
|
|
247
|
+
annote = None
|
|
248
|
+
if annotation:
|
|
249
|
+
annote = [dict(
|
|
250
|
+
text=f"Total: {annotation_prefix}{clean_values(total, decimals=decimals, decimal_places=decimal_places)}{annotation_suffix}",
|
|
251
|
+
x=0.5, y=0.5,
|
|
252
|
+
font=dict(size=annotation_font_size, family=font_family, color=font_color),
|
|
253
|
+
showarrow=False,
|
|
254
|
+
xref='paper', yref='paper', align='center'
|
|
255
|
+
)]
|
|
256
|
+
|
|
257
|
+
fig.update_layout(
|
|
258
|
+
template="plotly_white",
|
|
259
|
+
plot_bgcolor=bgcolor,
|
|
260
|
+
paper_bgcolor=bgcolor,
|
|
261
|
+
width=dimensions.get("width"),
|
|
262
|
+
height=dimensions.get("height"),
|
|
263
|
+
margin=margin,
|
|
264
|
+
font=dict(size=font_size, family=font_family),
|
|
265
|
+
annotations=annote,
|
|
266
|
+
legend=dict(
|
|
267
|
+
yanchor="top",
|
|
268
|
+
y=legend_placement.get("y", 1.1),
|
|
269
|
+
xanchor="left",
|
|
270
|
+
x=legend_placement.get("x", 0.01),
|
|
271
|
+
orientation=legend_orientation,
|
|
272
|
+
font=dict(size=legend_font_size, family=font_family, color=font_color),
|
|
273
|
+
bgcolor='rgba(0,0,0,0)',
|
|
274
|
+
itemsizing=itemsizing
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
self.fig = fig
|
|
279
|
+
return # Skip the rest for pie charts
|
|
280
|
+
|
|
281
|
+
# === HEATMAP CHART HANDLING ===
|
|
282
|
+
if kind == "heatmap":
|
|
283
|
+
x_col = axes_data.get("x")
|
|
284
|
+
y_col = axes_data.get("y1", [])[0] if isinstance(axes_data.get("y1"), list) else axes_data.get("y1")
|
|
285
|
+
z_col = num_col or y_col
|
|
286
|
+
|
|
287
|
+
if not x_col or not y_col or not z_col:
|
|
288
|
+
raise ValueError("Heatmap requires axes_data['x'] and axes_data['y1'] or groupby_col + num_col.")
|
|
289
|
+
|
|
290
|
+
color_base = merged_opts.get("heatmap_color", "#1f77b4")
|
|
291
|
+
width = merged_opts.get("dimensions", {}).get("width", 800)
|
|
292
|
+
height = merged_opts.get("dimensions", {}).get("height", 500)
|
|
293
|
+
margins = merged_opts.get("margin", dict(t=50, b=50, l=50, r=50))
|
|
294
|
+
font_size = merged_opts.get("font_size", {}).get("axes", 12)
|
|
295
|
+
legend_font_size = merged_opts.get("font_size", {}).get("legend", 10)
|
|
296
|
+
tick_color = merged_opts.get("font_color", "#333")
|
|
297
|
+
tick_suffix = merged_opts.get("ticksuffix", {}).get("y1", "")
|
|
298
|
+
bg_color = merged_opts.get("bgcolor", "#ffffff")
|
|
299
|
+
|
|
300
|
+
colorscale = [[0, "white"], [1, color_base]]
|
|
301
|
+
fig = go.Figure(data=go.Heatmap(
|
|
302
|
+
z=df[z_col],
|
|
303
|
+
x=df[x_col],
|
|
304
|
+
y=df[y_col],
|
|
305
|
+
colorscale=colorscale,
|
|
306
|
+
colorbar=dict(
|
|
307
|
+
title=dict(
|
|
308
|
+
text=z_col.replace("_", " ").title(),
|
|
309
|
+
font=dict(size=legend_font_size, color=tick_color)
|
|
310
|
+
),
|
|
311
|
+
tickfont=dict(size=legend_font_size, color=tick_color)
|
|
312
|
+
)
|
|
313
|
+
))
|
|
314
|
+
|
|
315
|
+
fig.update_layout(
|
|
316
|
+
title=title,
|
|
317
|
+
width=width,
|
|
318
|
+
height=height,
|
|
319
|
+
xaxis_title=x_col.replace("_", " ").title(),
|
|
320
|
+
yaxis_title=y_col.replace("_", " ").title(),
|
|
321
|
+
font=dict(size=font_size, color=tick_color),
|
|
322
|
+
margin=margins,
|
|
323
|
+
plot_bgcolor=bg_color,
|
|
324
|
+
paper_bgcolor=bg_color,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
fig.update_xaxes(ticksuffix=tick_suffix)
|
|
328
|
+
|
|
329
|
+
self.fig = fig
|
|
330
|
+
return # skip rest
|
|
331
|
+
|
|
332
|
+
# === STANDARD CHART HANDLING (line, bar, etc.) ===
|
|
333
|
+
fig = make_subplots(specs=[[{"secondary_y": True}]])
|
|
334
|
+
fig.update_layout(xaxis2=dict(overlaying='x', side='top'))
|
|
335
|
+
|
|
336
|
+
if axes_data.get('x') is None and is_datetime64_any_dtype(df.index):
|
|
337
|
+
axes_data['x'] = df.index.name if df.index.name else df.index
|
|
338
|
+
|
|
339
|
+
if groupby_col and num_col:
|
|
340
|
+
print(f'groupby_col and num_col passed...')
|
|
341
|
+
sort_list, color_map = self._prepare_grouped_series(
|
|
342
|
+
df, groupby_col, num_col,
|
|
343
|
+
descending=merged_opts.get('descending', True),
|
|
344
|
+
cumulative_sort=merged_opts.get('cumulative_sort', True)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
axis = 'y1' # for now assume only y1 for grouped logic
|
|
348
|
+
kind = chart_type.get(axis, 'bar').lower()
|
|
349
|
+
trace_class = trace_map.get(kind, go.Bar)
|
|
350
|
+
secondary = False # override if needed later
|
|
351
|
+
|
|
352
|
+
for i in sort_list:
|
|
353
|
+
i_df = df[df[groupby_col] == i]
|
|
354
|
+
color = color_map.get(i)
|
|
355
|
+
last_val = i_df[num_col].values[-1]
|
|
356
|
+
|
|
357
|
+
name = f'{i} ({merged_opts.get("tickprefix").get(axis) or ""}{clean_values(last_val, decimals=merged_opts["decimals"], decimal_places=merged_opts["decimal_places"])}{merged_opts.get("ticksuffix").get(axis) or ""})'
|
|
358
|
+
|
|
359
|
+
trace_args = {
|
|
360
|
+
"name": name,
|
|
361
|
+
"showlegend": merged_opts.get("show_legend", True)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
# Add text labels if enabled
|
|
365
|
+
if merged_opts.get("show_text", False):
|
|
366
|
+
decimal_places = merged_opts.get("decimal_places", 1)
|
|
367
|
+
decimals = merged_opts.get("decimals", True)
|
|
368
|
+
tickprefix = merged_opts.get("tickprefix", {}).get(axis) or ''
|
|
369
|
+
ticksuffix = merged_opts.get("ticksuffix", {}).get(axis) or ''
|
|
370
|
+
text_freq = merged_opts.get("text_freq", 1)
|
|
371
|
+
textposition = merged_opts.get("textposition", "top center")
|
|
372
|
+
|
|
373
|
+
# Build text values per row (or single value if bar)
|
|
374
|
+
if kind == "bar":
|
|
375
|
+
text_val = f"{tickprefix}{clean_values(last_val, decimal_places=decimal_places, decimals=decimals)}{ticksuffix}"
|
|
376
|
+
trace_args["text"] = [text_val]
|
|
377
|
+
else:
|
|
378
|
+
trace_args["text"] = [
|
|
379
|
+
f"{tickprefix}{clean_values(v, decimal_places=decimal_places, decimals=decimals)}{ticksuffix}"
|
|
380
|
+
if i % text_freq == 0 else ""
|
|
381
|
+
for i, v in enumerate(i_df[num_col])
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
trace_args["textposition"] = validate_textposition(kind, textposition)
|
|
385
|
+
|
|
386
|
+
if kind == 'bar':
|
|
387
|
+
textposition = merged_opts.get("textposition", "auto")
|
|
388
|
+
if not pd.api.types.is_datetime64_any_dtype(df.index):
|
|
389
|
+
if orientation == 'h':
|
|
390
|
+
trace_args.update({
|
|
391
|
+
"x": [last_val],
|
|
392
|
+
"y": [i],
|
|
393
|
+
"orientation":orientation,
|
|
394
|
+
"marker": dict(color=color),
|
|
395
|
+
"textposition": validate_textposition(kind, textposition)
|
|
396
|
+
})
|
|
397
|
+
else:
|
|
398
|
+
trace_args.update({
|
|
399
|
+
"x": [i],
|
|
400
|
+
"y": [last_val],
|
|
401
|
+
"orientation":orientation,
|
|
402
|
+
"marker": dict(color=color),
|
|
403
|
+
"textposition": validate_textposition(kind, textposition)
|
|
404
|
+
})
|
|
405
|
+
else:
|
|
406
|
+
trace_args.update({
|
|
407
|
+
"x": i_df.index,
|
|
408
|
+
"y": i_df[num_col],
|
|
409
|
+
"marker": dict(color=color),
|
|
410
|
+
"textposition": validate_textposition(kind, textposition)
|
|
411
|
+
})
|
|
412
|
+
else:
|
|
413
|
+
trace_args.update({
|
|
414
|
+
"x": i_df.index,
|
|
415
|
+
"y": i_df[num_col],
|
|
416
|
+
"mode": merged_opts.get("mode", "lines"),
|
|
417
|
+
"line": dict(color=color, width=merged_opts.get("line_width", 3))
|
|
418
|
+
})
|
|
419
|
+
if kind == 'area':
|
|
420
|
+
trace_args["stackgroup"] = 'one'
|
|
421
|
+
|
|
422
|
+
fig.add_trace(trace_class(**trace_args), secondary_y=secondary)
|
|
423
|
+
self.series.append({"col": i_df[num_col], "name": name})
|
|
424
|
+
else:
|
|
425
|
+
for axis in ['y1', 'y2']:
|
|
426
|
+
secondary = axis == 'y2'
|
|
427
|
+
for col in axes_data.get(axis, []):
|
|
428
|
+
if col not in df.columns:
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
color = self.get_next_color()
|
|
432
|
+
kind = chart_type.get(axis, "line").lower()
|
|
433
|
+
trace_class = trace_map.get(kind, go.Scatter)
|
|
434
|
+
|
|
435
|
+
name = f"{col.replace('_', ' ').upper()} ({merged_opts.get('tickprefix', {}).get(axis) or ''}{clean_values(df[col].iloc[-1], decimals=merged_opts.get('decimals', True), decimal_places=merged_opts.get('decimal_places', 1))}{merged_opts.get('ticksuffix', {}).get(axis) or ''}){space_buffer}"
|
|
436
|
+
|
|
437
|
+
text_bool = merged_opts.get('show_text', False)
|
|
438
|
+
text_freq = merged_opts.get('text_freq', None)
|
|
439
|
+
tickprefix = merged_opts.get("tickprefix", {}).get(axis) or ''
|
|
440
|
+
ticksuffix = merged_opts.get("ticksuffix", {}).get(axis) or ''
|
|
441
|
+
textposition = validate_textposition(kind, merged_opts.get("textposition"))
|
|
442
|
+
|
|
443
|
+
if text_bool and text_freq and col in df.columns:
|
|
444
|
+
text_values = [
|
|
445
|
+
f"{tickprefix}{clean_values(val, decimal_places=merged_opts.get('decimal_places', 1), decimals=merged_opts.get('decimals', True))}{ticksuffix}"
|
|
446
|
+
if i % text_freq == 0 else ""
|
|
447
|
+
for i, val in enumerate(df[col])
|
|
448
|
+
]
|
|
449
|
+
else:
|
|
450
|
+
text_values = None
|
|
451
|
+
|
|
452
|
+
if orientation == 'h':
|
|
453
|
+
if kind in ['line', 'area', 'scatter']:
|
|
454
|
+
trace_args = {
|
|
455
|
+
"x": df[col],
|
|
456
|
+
"y": df.index,
|
|
457
|
+
"name": name,
|
|
458
|
+
"mode": "lines+text" if text_values else merged_opts.get("mode", "lines"),
|
|
459
|
+
"line": dict(color=color, width=merged_opts.get("line_width", 3)),
|
|
460
|
+
"text": text_values,
|
|
461
|
+
"textposition": textposition if text_values else None,
|
|
462
|
+
"showlegend": merged_opts.get("show_legend", False)
|
|
463
|
+
}
|
|
464
|
+
if kind == 'area':
|
|
465
|
+
trace_args["stackgroup"] = 'one'
|
|
466
|
+
elif kind == 'bar':
|
|
467
|
+
trace_args = {
|
|
468
|
+
"x": df[col],
|
|
469
|
+
"y": df.index,
|
|
470
|
+
"name": name,
|
|
471
|
+
"orientation": "h",
|
|
472
|
+
"marker": dict(color=color),
|
|
473
|
+
"text": text_values,
|
|
474
|
+
"textposition": textposition if text_values else None,
|
|
475
|
+
"showlegend": merged_opts.get("show_legend", False)
|
|
476
|
+
}
|
|
477
|
+
else:
|
|
478
|
+
if kind in ['line', 'area', 'scatter']:
|
|
479
|
+
trace_args = {
|
|
480
|
+
"x": df.index,
|
|
481
|
+
"y": df[col],
|
|
482
|
+
"name": name,
|
|
483
|
+
"mode": "lines+text" if text_values else merged_opts.get("mode", "lines"),
|
|
484
|
+
"line": dict(color=color, width=merged_opts.get("line_width", 3)),
|
|
485
|
+
"text": text_values,
|
|
486
|
+
"textposition": textposition if text_values else None,
|
|
487
|
+
"showlegend": merged_opts.get("show_legend", False)
|
|
488
|
+
}
|
|
489
|
+
if kind == 'area':
|
|
490
|
+
trace_args["stackgroup"] = 'one'
|
|
491
|
+
elif kind == 'bar':
|
|
492
|
+
trace_args = {
|
|
493
|
+
"x": df.index,
|
|
494
|
+
"y": df[col],
|
|
495
|
+
"name": name,
|
|
496
|
+
"orientation": "v",
|
|
497
|
+
"marker": dict(color=color),
|
|
498
|
+
"text": text_values,
|
|
499
|
+
"textposition": textposition if text_values else None,
|
|
500
|
+
"showlegend": merged_opts.get("show_legend", False)
|
|
501
|
+
}
|
|
502
|
+
if orientation == "h" and secondary:
|
|
503
|
+
trace_args["xaxis"] = "x2"
|
|
504
|
+
trace_args["yaxis"] = "y"
|
|
505
|
+
|
|
506
|
+
fig.add_trace(trace_class(**trace_args), secondary_y=secondary)
|
|
507
|
+
self.series.append({"col": df[col], "name": name})
|
|
508
|
+
plotted_cols.append(col)
|
|
509
|
+
|
|
510
|
+
# Layout config
|
|
511
|
+
fig.update_layout(
|
|
512
|
+
xaxis_title=merged_opts.get('axes_titles').get('x', ''),
|
|
513
|
+
legend=dict(
|
|
514
|
+
x=merged_opts.get('legend_placement').get('x'),
|
|
515
|
+
y=merged_opts.get('legend_placement').get('y'),
|
|
516
|
+
orientation=merged_opts["legend_orientation"],
|
|
517
|
+
xanchor=merged_opts.get('xanchor', 'left'),
|
|
518
|
+
yanchor=merged_opts.get('yanchor', 'top'),
|
|
519
|
+
bgcolor=merged_opts.get('legend_background').get('bgcolor'),
|
|
520
|
+
bordercolor=merged_opts.get('legend_background').get('bordercolor'),
|
|
521
|
+
borderwidth=merged_opts.get('legend_background').get('borderwidth'),
|
|
522
|
+
traceorder=merged_opts.get('legend_background').get('traceorder'),
|
|
523
|
+
font=dict(size=merged_opts["font_size"]["legend"], family=merged_opts["font_family"], color=merged_opts['font_color'])
|
|
524
|
+
),
|
|
525
|
+
template='plotly_white',
|
|
526
|
+
hovermode='x unified',
|
|
527
|
+
width=merged_opts.get('dimensions').get('width'),
|
|
528
|
+
height=merged_opts.get('dimensions').get('height'),
|
|
529
|
+
margin=merged_opts["margin"],
|
|
530
|
+
font=dict(color=merged_opts["font_color"], size=merged_opts["font_size"]["axes"], family=merged_opts["font_family"]),
|
|
531
|
+
autosize=merged_opts["autosize"],
|
|
532
|
+
barmode=merged_opts['barmode'],
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
if merged_opts.get('auto_title'):
|
|
536
|
+
y1_title_text = axes_data.get('y1', [''])[0].replace("_", " ").upper() if axes_data.get('y1') else None
|
|
537
|
+
y2_title_text = (
|
|
538
|
+
axes_data.get('y2', [''])[0].replace("_", " ").upper()
|
|
539
|
+
if axes_data.get('y2') and len(axes_data.get('y2')) > 0
|
|
540
|
+
else None
|
|
541
|
+
)
|
|
542
|
+
else:
|
|
543
|
+
y1_title_text = merged_opts.get('axes_titles').get('y1', '')
|
|
544
|
+
y2_title_text = merged_opts.get('axes_titles').get('y2', '')
|
|
545
|
+
|
|
546
|
+
if not axes_data.get('y2'):
|
|
547
|
+
merged_opts['auto_color'] = False
|
|
548
|
+
|
|
549
|
+
y1_color = self.colors[0] if merged_opts.get('auto_color') else 'black'
|
|
550
|
+
y2_color = self.colors[1] if merged_opts.get('auto_color') else 'black'
|
|
551
|
+
|
|
552
|
+
# determine if we should hide the “value” axis when we're only showing text on bars
|
|
553
|
+
hide_vals = (
|
|
554
|
+
merged_opts.get("show_text", False)
|
|
555
|
+
and all(chart_type.get(ax, "").lower() == "bar" for ax in chart_type)
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
if orientation == "h" and axes_data.get('y2'):
|
|
559
|
+
fig.update_layout(
|
|
560
|
+
xaxis2=dict(
|
|
561
|
+
side="top",
|
|
562
|
+
overlaying="x",
|
|
563
|
+
anchor="y",
|
|
564
|
+
title=y2_title_text,
|
|
565
|
+
tickfont=dict(color=y2_color),
|
|
566
|
+
tickprefix=merged_opts.get("tickprefix", {}).get("y2", ""),
|
|
567
|
+
ticksuffix=merged_opts.get("ticksuffix", {}).get("y2", ""),
|
|
568
|
+
tickformat=merged_opts.get("tickformat", {}).get("y2", "")
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
if orientation == "h":
|
|
573
|
+
# ─── horizontal: hide x‐axis if hiding values ───
|
|
574
|
+
fig.update_xaxes(
|
|
575
|
+
title_text="" if hide_vals else y1_title_text,
|
|
576
|
+
color=y1_color if not hide_vals else "rgba(0,0,0,0)",
|
|
577
|
+
showticklabels=not hide_vals,
|
|
578
|
+
tickprefix=merged_opts.get("tickprefix", {}).get("y1", ""),
|
|
579
|
+
ticksuffix=merged_opts.get("ticksuffix", {}).get("y1", ""),
|
|
580
|
+
tickformat=merged_opts.get("tickformat", {}).get("x", ""),
|
|
581
|
+
tickfont=dict(color=merged_opts["font_color"])
|
|
582
|
+
)
|
|
583
|
+
# leave y‐axis (categories) visible, label it with the x‐axis title
|
|
584
|
+
fig.update_yaxes(
|
|
585
|
+
title_text=merged_opts.get('axes_titles').get('x', ''),
|
|
586
|
+
color=merged_opts["font_color"],
|
|
587
|
+
tickfont=dict(color=merged_opts["font_color"])
|
|
588
|
+
)
|
|
589
|
+
else:
|
|
590
|
+
# ─── vertical: hide y1‐axis if hiding values ───
|
|
591
|
+
fig.update_yaxes(
|
|
592
|
+
title_text="" if hide_vals else y1_title_text,
|
|
593
|
+
secondary_y=False,
|
|
594
|
+
color=y1_color if not hide_vals else "rgba(0,0,0,0)",
|
|
595
|
+
showticklabels=not hide_vals,
|
|
596
|
+
tickprefix=merged_opts.get("tickprefix", {}).get("y1", ""),
|
|
597
|
+
ticksuffix=merged_opts.get("ticksuffix", {}).get("y1", ""),
|
|
598
|
+
tickformat=merged_opts.get("tickformat", {}).get("y1", ""),
|
|
599
|
+
tickfont=dict(color=merged_opts["font_color"])
|
|
600
|
+
)
|
|
601
|
+
# y2 (if used) remains visible
|
|
602
|
+
fig.update_yaxes(
|
|
603
|
+
title_text=y2_title_text,
|
|
604
|
+
secondary_y=True,
|
|
605
|
+
color=y2_color,
|
|
606
|
+
tickprefix=merged_opts.get("tickprefix", {}).get("y2", ""),
|
|
607
|
+
ticksuffix=merged_opts.get("ticksuffix", {}).get("y2", ""),
|
|
608
|
+
tickformat=merged_opts.get("tickformat", {}).get("y2", ""),
|
|
609
|
+
tickfont=dict(color=merged_opts["font_color"])
|
|
610
|
+
)
|
|
611
|
+
# always leave x‐axis visible
|
|
612
|
+
fig.update_xaxes(
|
|
613
|
+
title_text=merged_opts.get('axes_titles').get('x', ''),
|
|
614
|
+
tickfont=dict(color=merged_opts["font_color"]),
|
|
615
|
+
tickformat=merged_opts.get("tickformat", {}).get("x", "")
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# === Final Y-axis buffer adjustment for bar charts with outside text ===
|
|
619
|
+
try:
|
|
620
|
+
if any(chart_type.get(axis, "") == "bar" for axis in chart_type):
|
|
621
|
+
textposition = merged_opts.get("textposition", "")
|
|
622
|
+
if merged_opts.get("show_text", False) and validate_textposition("bar", textposition) == "outside":
|
|
623
|
+
if groupby_col and num_col:
|
|
624
|
+
series = df.groupby(groupby_col)[num_col].max()
|
|
625
|
+
else:
|
|
626
|
+
series = pd.concat([
|
|
627
|
+
df[col] for axis in ['y1', 'y2']
|
|
628
|
+
for col in axes_data.get(axis, []) if col in df.columns
|
|
629
|
+
])
|
|
630
|
+
|
|
631
|
+
y_max = series.max()
|
|
632
|
+
y_min = series.min()
|
|
633
|
+
y_range = y_max - y_min
|
|
634
|
+
y_buffer = y_range * 0.15 # Add 15% buffer
|
|
635
|
+
|
|
636
|
+
if orientation == 'v':
|
|
637
|
+
# Adjust Y-axis (value axis for vertical)
|
|
638
|
+
if y_min >= 0:
|
|
639
|
+
fig.update_yaxes(range=[0, y_max + y_buffer], secondary_y=False)
|
|
640
|
+
elif y_max <= 0:
|
|
641
|
+
fig.update_yaxes(range=[y_min - y_buffer, 0], secondary_y=False)
|
|
642
|
+
else:
|
|
643
|
+
fig.update_yaxes(range=[y_min - y_buffer, y_max + y_buffer], secondary_y=False)
|
|
644
|
+
else:
|
|
645
|
+
# Adjust X-axis (value axis for horizontal)
|
|
646
|
+
if y_min >= 0:
|
|
647
|
+
fig.update_xaxes(range=[0, y_max + y_buffer])
|
|
648
|
+
elif y_max <= 0:
|
|
649
|
+
fig.update_xaxes(range=[y_min - y_buffer, 0])
|
|
650
|
+
else:
|
|
651
|
+
fig.update_xaxes(range=[y_min - y_buffer, y_max + y_buffer])
|
|
652
|
+
except Exception as e:
|
|
653
|
+
print(f"Warning: could not apply axis buffer: {e}")
|
|
654
|
+
|
|
655
|
+
if pd.api.types.is_datetime64_any_dtype(df.index):
|
|
656
|
+
unique_days = df.index.to_series().diff().dt.days.dropna().mode().iloc[0]
|
|
657
|
+
|
|
658
|
+
if unique_days >= 7:
|
|
659
|
+
# Only apply custom ticks if your index is monthly/weekly/etc
|
|
660
|
+
fig.update_xaxes(
|
|
661
|
+
tickmode="array",
|
|
662
|
+
tickvals=list(df.index)
|
|
663
|
+
)
|
|
664
|
+
else:
|
|
665
|
+
# Let Plotly decide for dense series
|
|
666
|
+
fig.update_xaxes(tickmode="auto")
|
|
667
|
+
|
|
668
|
+
self.fig = fig
|
|
669
|
+
|
|
670
|
+
def add_title(self,title=None,subtitle=None, x=0.25, y=0.9):
|
|
671
|
+
# Add a title and subtitle
|
|
672
|
+
if not hasattr(self, 'title_position') or title_position is None:
|
|
673
|
+
title_position = {'x': None, 'y': None}
|
|
674
|
+
|
|
675
|
+
# Update title position if values are provided
|
|
676
|
+
if x is not None:
|
|
677
|
+
title_position['x'] = x
|
|
678
|
+
if y is not None:
|
|
679
|
+
title_position['y'] = y
|
|
680
|
+
|
|
681
|
+
if title == None:
|
|
682
|
+
title=self.title
|
|
683
|
+
if subtitle == None:
|
|
684
|
+
subtitle=""
|
|
685
|
+
|
|
686
|
+
self.fig.update_layout(
|
|
687
|
+
title={
|
|
688
|
+
'text': f"<span style='color: black; font-weight: normal;'>{title}</span><br><sub style='font-size: 18px; color: black; font-weight: normal;'>{subtitle}</sub>",
|
|
689
|
+
'y':1 if title_position['y'] == None else title_position['y'],
|
|
690
|
+
'x':0.2 if title_position['x'] == None else title_position['x'],
|
|
691
|
+
'xanchor': 'left',
|
|
692
|
+
'yanchor': 'top',
|
|
693
|
+
'font': {
|
|
694
|
+
'color': 'black', # Set the title color here
|
|
695
|
+
'size': 27, # You can also adjust the font size
|
|
696
|
+
'family': self.merged_opts['font_family']}
|
|
697
|
+
},
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def add_annotations(self, max_annotation=True, custom_annotations=None, annotation_placement=dict(x=0.5,y=0.5)):
|
|
701
|
+
if self.df is None or self.fig is None:
|
|
702
|
+
return # Cannot annotate without a figure and data
|
|
703
|
+
|
|
704
|
+
opts = self.merged_opts
|
|
705
|
+
fig = self.fig
|
|
706
|
+
df = self.df
|
|
707
|
+
|
|
708
|
+
font_color = opts.get("font_color", "black")
|
|
709
|
+
font_family = opts.get("font_family", "Cardo")
|
|
710
|
+
text_font_size = opts.get("font_size", {}).get("textfont", 12)
|
|
711
|
+
datetime_format = opts.get("datetime_format", "%b. %d, %Y")
|
|
712
|
+
decimal_places = opts.get("decimal_places", 1)
|
|
713
|
+
decimals = opts.get("decimals", True)
|
|
714
|
+
tickprefix = opts.get("tickprefix", {}).get("y1") or ''
|
|
715
|
+
ticksuffix = opts.get("ticksuffix", {}).get("y1") or ''
|
|
716
|
+
annotations = opts.get("annotations", True)
|
|
717
|
+
max_annotation_bool = opts.get("max_annotation", max_annotation)
|
|
718
|
+
|
|
719
|
+
# Determine which column was plotted
|
|
720
|
+
y1_cols = self.merged_opts.get("axes_titles", {}).get("y1", [])
|
|
721
|
+
y2_cols = self.merged_opts.get("axes_titles", {}).get("y2", [])
|
|
722
|
+
plotted_cols = self.series
|
|
723
|
+
|
|
724
|
+
if len(plotted_cols) != 1:
|
|
725
|
+
return # Only annotate if exactly one series was plotted
|
|
726
|
+
|
|
727
|
+
y1_col = plotted_cols[0]["col"].name
|
|
728
|
+
|
|
729
|
+
# Determine if index is datetime
|
|
730
|
+
datetime_tick = pd.api.types.is_datetime64_any_dtype(df.index)
|
|
731
|
+
|
|
732
|
+
df = df.sort_index() # Ensure consistent order
|
|
733
|
+
|
|
734
|
+
first_idx = df.index[0]
|
|
735
|
+
first_val = df.loc[first_idx, y1_col]
|
|
736
|
+
|
|
737
|
+
last_idx = df.index[-1]
|
|
738
|
+
last_val = df.loc[last_idx, y1_col]
|
|
739
|
+
|
|
740
|
+
first_text = f'{first_idx.strftime(datetime_format) if datetime_tick else first_idx}:<br>{tickprefix}{clean_values(first_val, decimal_places=decimal_places, decimals=decimals)}{ticksuffix}'
|
|
741
|
+
last_text = f'{last_idx.strftime(datetime_format) if datetime_tick else last_idx}:<br>{tickprefix}{clean_values(last_val, decimal_places=decimal_places, decimals=decimals)}{ticksuffix}'
|
|
742
|
+
|
|
743
|
+
if isinstance(fig.data[0], go.Pie):
|
|
744
|
+
total = sum(fig.data[0].values)
|
|
745
|
+
annotation_prefix = opts.get("tickprefix", {}).get("y1", "")
|
|
746
|
+
annotation_suffix = opts.get("ticksuffix", {}).get("y1", "")
|
|
747
|
+
|
|
748
|
+
total_text = f'{annotation_prefix}{clean_values(total, decimals=decimals, decimal_places=decimal_places)}{annotation_suffix}'
|
|
749
|
+
|
|
750
|
+
pie_annotation = dict(
|
|
751
|
+
text=f"Total: {total_text}",
|
|
752
|
+
x=annotation_placement['x'],
|
|
753
|
+
y=annotation_placement['x'],
|
|
754
|
+
font=dict(
|
|
755
|
+
size=text_font_size,
|
|
756
|
+
family=font_family,
|
|
757
|
+
color=font_color
|
|
758
|
+
),
|
|
759
|
+
showarrow=False,
|
|
760
|
+
xref='paper',
|
|
761
|
+
yref='paper',
|
|
762
|
+
align='center'
|
|
763
|
+
)
|
|
764
|
+
fig.update_layout(annotations=[pie_annotation])
|
|
765
|
+
|
|
766
|
+
orientation = self.merged_opts.get("orientation", "v")
|
|
767
|
+
|
|
768
|
+
if annotations:
|
|
769
|
+
|
|
770
|
+
# For the last point
|
|
771
|
+
fig.add_annotation(dict(
|
|
772
|
+
x=last_val if orientation == 'h' else last_idx,
|
|
773
|
+
y=last_idx if orientation == 'h' else last_val,
|
|
774
|
+
text=last_text,
|
|
775
|
+
showarrow=True,
|
|
776
|
+
arrowhead=2,
|
|
777
|
+
arrowsize=1.5,
|
|
778
|
+
arrowwidth=1.5,
|
|
779
|
+
ax=10,
|
|
780
|
+
ay=-50,
|
|
781
|
+
font=dict(size=text_font_size, family=font_family, color=font_color),
|
|
782
|
+
xref='x',
|
|
783
|
+
yref='y',
|
|
784
|
+
arrowcolor='black'
|
|
785
|
+
))
|
|
786
|
+
|
|
787
|
+
# For the first point
|
|
788
|
+
fig.add_annotation(dict(
|
|
789
|
+
x=first_val if orientation == 'h' else first_idx,
|
|
790
|
+
y=first_idx if orientation == 'h' else first_val,
|
|
791
|
+
text=first_text,
|
|
792
|
+
showarrow=True,
|
|
793
|
+
arrowhead=2,
|
|
794
|
+
arrowsize=1.5,
|
|
795
|
+
arrowwidth=1.5,
|
|
796
|
+
ax=10,
|
|
797
|
+
ay=-50,
|
|
798
|
+
font=dict(size=text_font_size, family=font_family, color=font_color),
|
|
799
|
+
xref='x',
|
|
800
|
+
yref='y',
|
|
801
|
+
arrowcolor='black'
|
|
802
|
+
))
|
|
803
|
+
|
|
804
|
+
if max_annotation_bool:
|
|
805
|
+
max_val = df[y1_col].max()
|
|
806
|
+
max_idx = df[df[y1_col] == max_val].index[0]
|
|
807
|
+
max_text = f'{max_idx.strftime(datetime_format) if datetime_tick else max_idx}:<br>{tickprefix}{clean_values(max_val, decimal_places=decimal_places, decimals=decimals)}{ticksuffix} (ATH)'
|
|
808
|
+
|
|
809
|
+
if max_idx not in [first_idx, last_idx]:
|
|
810
|
+
fig.add_annotation(dict(
|
|
811
|
+
x=max_val if orientation == 'h' else max_idx,
|
|
812
|
+
y=max_idx if orientation == 'h' else max_val,
|
|
813
|
+
text=max_text,
|
|
814
|
+
showarrow=True,
|
|
815
|
+
arrowhead=2,
|
|
816
|
+
arrowsize=1.5,
|
|
817
|
+
arrowwidth=1.5,
|
|
818
|
+
ax=-10,
|
|
819
|
+
ay=-50,
|
|
820
|
+
font=dict(size=text_font_size, family=font_family, color=font_color),
|
|
821
|
+
xref='x',
|
|
822
|
+
yref='y',
|
|
823
|
+
arrowcolor='black'
|
|
824
|
+
))
|
|
825
|
+
|
|
826
|
+
# Custom annotations
|
|
827
|
+
if custom_annotations is not None and isinstance(custom_annotations, dict):
|
|
828
|
+
for date, label in custom_annotations.items():
|
|
829
|
+
if date in df.index:
|
|
830
|
+
y_val = df.loc[date, y1_col]
|
|
831
|
+
fig.add_annotation(dict(
|
|
832
|
+
x=y_val if orientation == 'h' else date,
|
|
833
|
+
y=date if orientation == 'h' else y_val,
|
|
834
|
+
text=label,
|
|
835
|
+
showarrow=True,
|
|
836
|
+
arrowhead=2,
|
|
837
|
+
arrowsize=1.5,
|
|
838
|
+
arrowwidth=1.5,
|
|
839
|
+
ax=-10,
|
|
840
|
+
ay=-50,
|
|
841
|
+
font=dict(size=text_font_size, family=font_family, color=font_color),
|
|
842
|
+
xref='x',
|
|
843
|
+
yref='y',
|
|
844
|
+
arrowcolor='black'
|
|
845
|
+
))
|
|
846
|
+
|
|
847
|
+
def add_dashed_line(self, date, annotation_text=None):
|
|
848
|
+
if self.df is None or self.fig is None:
|
|
849
|
+
print("Error: DataFrame or figure not initialized.")
|
|
850
|
+
return
|
|
851
|
+
|
|
852
|
+
opts = self.merged_opts
|
|
853
|
+
df = self.df
|
|
854
|
+
fig = self.fig
|
|
855
|
+
|
|
856
|
+
font_family = opts.get("font_family", "Cardo")
|
|
857
|
+
font_color = opts.get("font_color", "black")
|
|
858
|
+
text_font_size = opts.get("font_size", {}).get("textfont", 12)
|
|
859
|
+
datetime_format = opts.get("datetime_format", "%b. %d, %Y")
|
|
860
|
+
line_color = opts.get("dashed_line_color", "black")
|
|
861
|
+
line_width = opts.get("dashed_line_width", 3)
|
|
862
|
+
line_factor = opts.get("line_factor",1.5)
|
|
863
|
+
cols_to_plot = [s["col"].name for s in self.series] if self.series else df.columns.tolist()
|
|
864
|
+
|
|
865
|
+
if pd.api.types.is_datetime64_any_dtype(df.index):
|
|
866
|
+
date = pd.to_datetime(date)
|
|
867
|
+
|
|
868
|
+
if date not in df.index:
|
|
869
|
+
print(f"Error: {date} is not in the DataFrame index.")
|
|
870
|
+
return
|
|
871
|
+
|
|
872
|
+
if len(cols_to_plot) == 1:
|
|
873
|
+
col = cols_to_plot[0]
|
|
874
|
+
else:
|
|
875
|
+
col = df.loc[date, cols_to_plot].idxmax()
|
|
876
|
+
|
|
877
|
+
y_value = df.loc[date, col]
|
|
878
|
+
|
|
879
|
+
if pd.isna(y_value):
|
|
880
|
+
print(f"Warning: Missing value at {date} for {col}.")
|
|
881
|
+
return
|
|
882
|
+
|
|
883
|
+
orientation = self.merged_opts.get("orientation", "v")
|
|
884
|
+
|
|
885
|
+
if annotation_text is None:
|
|
886
|
+
annotation_text = f"{col}: {clean_values(y_value)}"
|
|
887
|
+
|
|
888
|
+
if orientation == 'h':
|
|
889
|
+
fig.add_shape(
|
|
890
|
+
type="line",
|
|
891
|
+
x0=0,
|
|
892
|
+
y0=date,
|
|
893
|
+
x1=y_value * line_factor, # <<< extend the line
|
|
894
|
+
y1=date,
|
|
895
|
+
line=dict(color=line_color, width=line_width, dash="dot"),
|
|
896
|
+
)
|
|
897
|
+
fig.add_annotation(
|
|
898
|
+
x=y_value * line_factor, # <<< move annotation slightly out too
|
|
899
|
+
y=date,
|
|
900
|
+
text=f"{annotation_text}<br>{pd.to_datetime(date).strftime(datetime_format)}",
|
|
901
|
+
showarrow=False,
|
|
902
|
+
xanchor='left',
|
|
903
|
+
yanchor='middle',
|
|
904
|
+
font=dict(size=text_font_size, family=font_family, color=font_color)
|
|
905
|
+
)
|
|
906
|
+
else:
|
|
907
|
+
fig.add_shape(
|
|
908
|
+
type="line",
|
|
909
|
+
x0=date,
|
|
910
|
+
y0=0,
|
|
911
|
+
x1=date,
|
|
912
|
+
y1=y_value * line_factor, # <<< extend the line vertically
|
|
913
|
+
line=dict(color=line_color, width=line_width, dash="dot"),
|
|
914
|
+
)
|
|
915
|
+
fig.add_annotation(
|
|
916
|
+
x=date,
|
|
917
|
+
y=y_value * line_factor, # <<< move annotation slightly out too
|
|
918
|
+
text=f"{annotation_text}<br>{pd.to_datetime(date).strftime(datetime_format)}",
|
|
919
|
+
showarrow=False,
|
|
920
|
+
xanchor='center',
|
|
921
|
+
yanchor='bottom',
|
|
922
|
+
font=dict(size=text_font_size, family=font_family, color=font_color)
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
|
chartengineer/utils.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import matplotlib.cm as cm
|
|
3
|
+
from matplotlib.colors import to_hex
|
|
4
|
+
import colorcet as cc
|
|
5
|
+
import plotly.colors as pc
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
def clean_values(x, decimals=True, decimal_places=1):
|
|
9
|
+
if isinstance(x, pd.Series):
|
|
10
|
+
return x.apply(clean_values, decimals=decimals, decimal_places=decimal_places)
|
|
11
|
+
|
|
12
|
+
if x == 0:
|
|
13
|
+
return '0'
|
|
14
|
+
|
|
15
|
+
if decimals == True:
|
|
16
|
+
if abs(x) < 1: # Handle numbers between -1 and 1 first
|
|
17
|
+
|
|
18
|
+
return f'{x:.2f}' # Keep small values with two decimal points
|
|
19
|
+
elif x >= 1e12 or x <= -1e12:
|
|
20
|
+
|
|
21
|
+
return f'{x / 1e12:.{decimal_places}f}T' # Trillion
|
|
22
|
+
elif x >= 1e9 or x <= -1e9:
|
|
23
|
+
|
|
24
|
+
return f'{x / 1e9:.{decimal_places}f}B' # Billion
|
|
25
|
+
elif x >= 1e6 or x <= -1e6:
|
|
26
|
+
|
|
27
|
+
return f'{x / 1e6:.{decimal_places}f}M' # Million
|
|
28
|
+
elif x >= 1e3 or x <= -1e3:
|
|
29
|
+
|
|
30
|
+
return f'{x / 1e3:.{decimal_places}f}K' # Thousand
|
|
31
|
+
elif x >= 1e2 or x <= -1e2:
|
|
32
|
+
|
|
33
|
+
return f'{x:.{decimal_places}f}' # Show as is for hundreds
|
|
34
|
+
elif x >= 1 or x <= -1:
|
|
35
|
+
|
|
36
|
+
return f'{x:.{decimal_places}f}' # Show whole numbers for numbers between 1 and 100
|
|
37
|
+
else:
|
|
38
|
+
|
|
39
|
+
return f'{x:.{decimal_places}f}' # Handle smaller numbers
|
|
40
|
+
else:
|
|
41
|
+
if abs(x) < 1: # Handle numbers between -1 and 1 first
|
|
42
|
+
return f'{x:.2f}' # Keep small values with two decimal points
|
|
43
|
+
elif x >= 1e12 or x <= -1e12:
|
|
44
|
+
return f'{x / 1e12:.0f}t' # Trillion
|
|
45
|
+
elif x >= 1e9 or x <= -1e9:
|
|
46
|
+
return f'{x / 1e9:.0f}b' # Billion
|
|
47
|
+
elif x >= 1e6 or x <= -1e6:
|
|
48
|
+
return f'{x / 1e6:.0f}m' # Million
|
|
49
|
+
elif x >= 1e3 or x <= -1e3:
|
|
50
|
+
return f'{x / 1e3:.0f}k' # Thousand
|
|
51
|
+
elif x >= 1e2 or x <= -1e2:
|
|
52
|
+
return f'{x:.0f}' # Show as is for hundreds
|
|
53
|
+
elif x >= 1 or x <= -1:
|
|
54
|
+
return f'{x:.0f}' # Show as is for numbers between 1 and 100
|
|
55
|
+
else:
|
|
56
|
+
return f'{x:.0f}' # Handle smaller numbers
|
|
57
|
+
|
|
58
|
+
def colors(shuffle=False):
|
|
59
|
+
# Existing Plotly palettes
|
|
60
|
+
color_palette = pc.qualitative.Plotly[::-1]
|
|
61
|
+
distinct_palette = pc.qualitative.Dark24 + pc.qualitative.Set3
|
|
62
|
+
|
|
63
|
+
# Add Matplotlib colors
|
|
64
|
+
matplotlib_colors = [to_hex(cm.tab10(i)) for i in range(10)] + \
|
|
65
|
+
[to_hex(cm.Set1(i)) for i in range(9)]
|
|
66
|
+
|
|
67
|
+
# Add Colorcet colors
|
|
68
|
+
colorcet_colors = cc.palette['glasbey_dark'] + cc.palette['glasbey_light']
|
|
69
|
+
|
|
70
|
+
# Combine all palettes
|
|
71
|
+
lib_colors = distinct_palette + color_palette + matplotlib_colors + colorcet_colors
|
|
72
|
+
|
|
73
|
+
if shuffle:
|
|
74
|
+
random.shuffle(lib_colors)
|
|
75
|
+
|
|
76
|
+
return lib_colors
|
|
77
|
+
|
|
78
|
+
def to_percentage(df, sum_col, index_col, percent=True):
|
|
79
|
+
|
|
80
|
+
df_copy = df.copy()
|
|
81
|
+
|
|
82
|
+
df_copy = df_copy.groupby(index_col)[sum_col].sum().reset_index()
|
|
83
|
+
|
|
84
|
+
# Calculate total usd_revenue
|
|
85
|
+
total = df[sum_col].sum()
|
|
86
|
+
|
|
87
|
+
if percent:
|
|
88
|
+
|
|
89
|
+
# Add a new column for percentage
|
|
90
|
+
df_copy['percentage'] = (df_copy[sum_col] / total) * 100
|
|
91
|
+
df_copy['legend_label'] = df_copy.apply(lambda x: f"{x[index_col]} ({x['percentage']:.1f}%)", axis=1)
|
|
92
|
+
df_copy.set_index('legend_label', inplace=True)
|
|
93
|
+
else:
|
|
94
|
+
df_copy.set_index(index_col, inplace=True)
|
|
95
|
+
print(f'df_copy: {df_copy}')
|
|
96
|
+
|
|
97
|
+
df_copy.sort_values(by=sum_col, ascending=False, inplace=True)
|
|
98
|
+
df_copy.drop_duplicates(inplace=True)
|
|
99
|
+
|
|
100
|
+
return df_copy, total
|
|
101
|
+
|
|
102
|
+
def normalize_to_percent(df,num_col=None):
|
|
103
|
+
print(f'num_col: {num_col}')
|
|
104
|
+
|
|
105
|
+
if num_col == None:
|
|
106
|
+
|
|
107
|
+
df_copy = df.copy()
|
|
108
|
+
|
|
109
|
+
df_copy['total'] = df_copy.sum(axis=1)
|
|
110
|
+
# Exclude the 'total_transactions' column from the percentage calculation
|
|
111
|
+
chains_columns = df_copy.columns.difference(['total'])
|
|
112
|
+
|
|
113
|
+
for col in chains_columns:
|
|
114
|
+
df_copy[f'{col}_percentage'] = (df_copy[col] / df_copy['total']) * 100
|
|
115
|
+
|
|
116
|
+
# Drop the 'total_transactions' column if you don't need it
|
|
117
|
+
df_copy = df_copy.drop(columns=['total'])
|
|
118
|
+
|
|
119
|
+
percent_cols = [col for col in df_copy.columns if '_percentage' in col]
|
|
120
|
+
df_copy = df_copy[percent_cols]
|
|
121
|
+
|
|
122
|
+
df_copy.columns = df_copy.columns.str.replace('_percentage', '', regex=False)
|
|
123
|
+
|
|
124
|
+
print(f'percent_cols:{df_copy.columns}')
|
|
125
|
+
else:
|
|
126
|
+
df_copy = df.copy()
|
|
127
|
+
total = df_copy.groupby(df_copy.index)[num_col].sum()
|
|
128
|
+
total = total.to_frame(f'total_{num_col}')
|
|
129
|
+
df_copy = df_copy.merge(total, left_index=True, right_index=True, how='inner')
|
|
130
|
+
# Calculate percentage of daily active users for each app
|
|
131
|
+
df_copy['percentage'] = (df_copy[num_col] / df_copy[f'total_{num_col}']) * 100
|
|
132
|
+
|
|
133
|
+
# Drop the total_active_users column if no longer needed
|
|
134
|
+
df_copy = df_copy.drop(columns=[f'total_{num_col}'])
|
|
135
|
+
df_copy.drop(columns=num_col,inplace=True)
|
|
136
|
+
|
|
137
|
+
df_copy.rename(columns={"percentage":num_col},inplace=True)
|
|
138
|
+
|
|
139
|
+
df_copy.drop_duplicates(inplace=True)
|
|
140
|
+
|
|
141
|
+
return df_copy
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: chartengineer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Library for quick and modern chart building.
|
|
5
|
+
Home-page:
|
|
6
|
+
Author: Brandyn Hamilton
|
|
7
|
+
Author-email: brandynham1120@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.7
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: pandas
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Requires-Dist: dotenv
|
|
16
|
+
Requires-Dist: plotly
|
|
17
|
+
Requires-Dist: matplotlib
|
|
18
|
+
Requires-Dist: colorcet
|
|
19
|
+
Requires-Dist: nbformat >=4.2.0
|
|
20
|
+
Requires-Dist: kaleido ==0.1.0.post1
|
|
21
|
+
|
|
22
|
+
# `chartengineer` Documentation
|
|
23
|
+
|
|
24
|
+
**chartengineer** is a lightweight Python package for building publication-ready, highly customizable Plotly charts from pandas DataFrames.
|
|
25
|
+
|
|
26
|
+
It supports a flexible API for pie charts, grouped bar charts, heatmaps, time series, and area/line plots, with robust formatting, annotations, and layout tools.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install chartengineer
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or install from source:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git clone https://github.com/your-org/chartengineer
|
|
40
|
+
cd chartengineer
|
|
41
|
+
pip install -e .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from chartengineer import ChartMaker
|
|
50
|
+
|
|
51
|
+
cm = ChartMaker(shuffle_colors=True)
|
|
52
|
+
cm.build(
|
|
53
|
+
df=my_df,
|
|
54
|
+
groupby_col="CHAIN",
|
|
55
|
+
num_col="TOTAL_VOLUME",
|
|
56
|
+
title="Bridge Volume by Chain",
|
|
57
|
+
chart_type="pie",
|
|
58
|
+
options={
|
|
59
|
+
"tickprefix": {"y1": "$"},
|
|
60
|
+
"annotations": True,
|
|
61
|
+
"texttemplate": "%{label}<br>%{percent}"
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
cm.add_title(subtitle="As of 2025-04-01")
|
|
65
|
+
cm.show_fig()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Supported Chart Types
|
|
71
|
+
|
|
72
|
+
- `"line"` (default)
|
|
73
|
+
- `"bar"`
|
|
74
|
+
- `"area"`
|
|
75
|
+
- `"pie"`
|
|
76
|
+
- `"heatmap"`
|
|
77
|
+
|
|
78
|
+
You can use a string or dictionary:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
chart_type = "bar" # applies to both y1/y2
|
|
82
|
+
chart_type = {"y1": "line", "y2": "bar"} # axis-specific
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Main Methods
|
|
88
|
+
|
|
89
|
+
### `ChartMaker.build(...)`
|
|
90
|
+
|
|
91
|
+
Build a chart.
|
|
92
|
+
|
|
93
|
+
**Arguments**
|
|
94
|
+
|
|
95
|
+
- `df`: pandas DataFrame
|
|
96
|
+
- `title`: Chart title
|
|
97
|
+
- `chart_type`: string or dict
|
|
98
|
+
- `groupby_col`, `num_col`: for grouped series or pie/bar
|
|
99
|
+
- `axes_data`: e.g. `{"x": "DATE", "y1": ["TVL"]}`
|
|
100
|
+
- `options`: plot style and behavior options
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### `ChartMaker.show_fig()`
|
|
105
|
+
|
|
106
|
+
Render the current chart inline (Jupyter) or open in browser.
|
|
107
|
+
|
|
108
|
+
### `ChartMaker.save_fig(path, filetype='png')`
|
|
109
|
+
|
|
110
|
+
Save the chart as `.png`, `.svg`, or `.html`.
|
|
111
|
+
|
|
112
|
+
### ChartMaker.add_annotations(max_annotation=True, custom_annotations=None, annotation_placement=dict(x=0.5,y=0.5))
|
|
113
|
+
|
|
114
|
+
If called and the chart is plotting timeseries data, this automatically adds annotations for the first and last data points. If max_annotation is True, it dynamically calculates the max value in the dataset and annotates it. Note that this is meant for plotting single-series timeseries data.
|
|
115
|
+
|
|
116
|
+
If the chart is a Pie chart, the annotation_placement parameter enables moving the location of where the annotation is placed.
|
|
117
|
+
|
|
118
|
+
### ChartMaker.add_dashed_line(date, annotation_text=None)
|
|
119
|
+
|
|
120
|
+
Adds a dashed line and annotation at the specified date; meant for timeseries data. If annotation_text is None, it uses the column name that contains the max value for the specified date.
|
|
121
|
+
|
|
122
|
+
### ChartMaker.return_df()
|
|
123
|
+
|
|
124
|
+
Returns the dataframe used in a chart.
|
|
125
|
+
|
|
126
|
+
### ChartMaker.return_fig()
|
|
127
|
+
|
|
128
|
+
Returns the Plotly figure that was created from calling the build method.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Customization Options
|
|
133
|
+
|
|
134
|
+
All style options can be passed via the `options` parameter. These are the same options that can be passed to a base Plotly figure. Refer to the Plotly library documentation for a full list of accepted parameters.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
options = {
|
|
138
|
+
"tickprefix": {"y1": "$"},
|
|
139
|
+
"ticksuffix": {"y1": "%"},
|
|
140
|
+
"dimensions": {"width": 800, "height": 400},
|
|
141
|
+
"font_family": "Cardo",
|
|
142
|
+
"font_size": {"axes": 16, "legend": 12, "textfont": 12},
|
|
143
|
+
"legend_placement": {"x": 1.05, "y": 1},
|
|
144
|
+
"show_text": True,
|
|
145
|
+
"annotations": True,
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Chart Features
|
|
152
|
+
|
|
153
|
+
- Grouped bar plots with custom sort and color mapping
|
|
154
|
+
- Automatic annotations for first/last/max points
|
|
155
|
+
- Time series support with datetime formatting
|
|
156
|
+
- Pie chart labels, percentages, donut hole support
|
|
157
|
+
- Heatmaps with flexible x/y/z column mapping
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Project Structure
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
chartengineer/
|
|
165
|
+
├── __init__.py
|
|
166
|
+
├── core.py # ChartMaker class
|
|
167
|
+
├── utils.py # Plotting utils, formatting
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT License © Brandyn Hamilton
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
chartengineer/__init__.py,sha256=69J7YPzlwMBZ0JLc1hQlGHFYZS0IYYiotxB-aHBr7UE,31
|
|
2
|
+
chartengineer/core.py,sha256=CGvB6fSs3IZBZz9IURTt04QtJP5zWwYnTaVPb3gRhsk,42025
|
|
3
|
+
chartengineer/utils.py,sha256=OUb8PFTzqhDLmtKmyqGgD2F0vqRYMLFwpYT4vvlA0UQ,5073
|
|
4
|
+
chartengineer-0.1.0.dist-info/METADATA,sha256=yijLo0T9B8FATOoZxahME8XdHG5FI4SEYUWcxriRtUc,4574
|
|
5
|
+
chartengineer-0.1.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
6
|
+
chartengineer-0.1.0.dist-info/top_level.txt,sha256=nmZVp7DWlqBtvLUAPQY7psQVB0DugCfZDBGu4cqNk1E,14
|
|
7
|
+
chartengineer-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chartengineer
|