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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ chartengineer