From 9e7e533552cee53ca71da2ce41d400399a1f04db Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 20 Jul 2026 21:44:32 +0200 Subject: [PATCH 01/10] Improve barplots (#48) * Improved barplots * Formatting Added Gantt chart Support tikz --- examples/gantt_matplotlib.py | 50 +++++++++ examples/gantt_plotext.py | 48 +++++++++ examples/gantt_plotly.py | 49 +++++++++ src/maxplotlib/canvas/canvas.py | 33 ++++++ src/maxplotlib/subfigure/line_plot.py | 140 ++++++++++++++++++++++++++ 5 files changed, 320 insertions(+) create mode 100644 examples/gantt_matplotlib.py create mode 100644 examples/gantt_plotext.py create mode 100644 examples/gantt_plotly.py diff --git a/examples/gantt_matplotlib.py b/examples/gantt_matplotlib.py new file mode 100644 index 0000000..3979764 --- /dev/null +++ b/examples/gantt_matplotlib.py @@ -0,0 +1,50 @@ +import numpy as np + +from maxplotlib import Canvas + + +def main() -> None: + # Define project tasks + tasks = [ + "Planning", + "Design", + "Development", + "Testing", + "Deployment", + "Documentation", + ] + + # Start times (in days from project start) + start_times = np.array([0, 5, 10, 25, 35, 30]) + + # Duration of each task (in days) + durations = np.array([5, 5, 15, 10, 5, 10]) + + # Create canvas + canvas = Canvas(width="14cm", ratio=0.6, dpi=150) + + # Add gantt chart + canvas.gantt( + tasks=tasks, + start_times=start_times, + durations=durations, + color="steelblue", + alpha=0.7, + edgecolor="black", + label="Project Tasks" + ) + + # Configure plot + canvas.set_title("Project Timeline - Gantt Chart") + canvas.set_xlabel("Days from Project Start") + canvas.set_ylabel("Tasks") + canvas.set_grid(True) + canvas.set_xlim(0, 45) + + # Save figure + canvas.savefig("gantt_matplotlib.png", backend="matplotlib") + print("Gantt chart saved as gantt_matplotlib.png") + + +if __name__ == "__main__": + main() diff --git a/examples/gantt_plotext.py b/examples/gantt_plotext.py new file mode 100644 index 0000000..598327f --- /dev/null +++ b/examples/gantt_plotext.py @@ -0,0 +1,48 @@ +import numpy as np + +from maxplotlib import Canvas + + +def main() -> None: + # Define project tasks + tasks = [ + "Planning", + "Design", + "Development", + "Testing", + "Deployment", + "Documentation", + ] + + # Start times (in days from project start) + start_times = np.array([0, 5, 10, 25, 35, 30]) + + # Duration of each task (in days) + durations = np.array([5, 5, 15, 10, 5, 10]) + + # Create canvas + canvas = Canvas(width="14cm", ratio=0.6) + + # Add gantt chart + canvas.gantt( + tasks=tasks, + start_times=start_times, + durations=durations, + color="cyan", + label="Project Tasks" + ) + + # Configure plot + canvas.set_title("Project Timeline - Gantt Chart (Plotext)") + canvas.set_xlabel("Days from Project Start") + canvas.set_ylabel("Tasks") + canvas.set_grid(True) + canvas.set_xlim(0, 45) + + # Save figure (plotext renders to terminal) + canvas.savefig("gantt_plotext.txt", backend="plotext") + print("Gantt chart saved as gantt_plotext.txt") + + +if __name__ == "__main__": + main() diff --git a/examples/gantt_plotly.py b/examples/gantt_plotly.py new file mode 100644 index 0000000..fc150d7 --- /dev/null +++ b/examples/gantt_plotly.py @@ -0,0 +1,49 @@ +import numpy as np + +from maxplotlib import Canvas + + +def main() -> None: + # Define project tasks + tasks = [ + "Planning", + "Design", + "Development", + "Testing", + "Deployment", + "Documentation", + ] + + # Start times (in days from project start) + start_times = np.array([0, 5, 10, 25, 35, 30]) + + # Duration of each task (in days) + durations = np.array([5, 5, 15, 10, 5, 10]) + + # Create canvas + canvas = Canvas(width="14cm", ratio=0.6) + + # Add gantt chart + canvas.gantt( + tasks=tasks, + start_times=start_times, + durations=durations, + color="steelblue", + alpha=0.7, + label="Project Tasks" + ) + + # Configure plot + canvas.set_title("Project Timeline - Gantt Chart (Plotly)") + canvas.set_xlabel("Days from Project Start") + canvas.set_ylabel("Tasks") + canvas.set_grid(True) + canvas.set_xlim(0, 45) + + # Save figure + canvas.savefig("gantt_plotly.html", backend="plotly") + print("Gantt chart saved as gantt_plotly.html") + + +if __name__ == "__main__": + main() diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index a321776..11c2cce 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -430,6 +430,30 @@ def bar( sp = self._get_or_create_subplot(row, col) sp.bar(x, height, layer=layer, **kwargs) + def gantt( + self, + tasks, + start_times, + durations, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """ + Add a Gantt chart to the canvas (matplotlib-style convenience method). + + Parameters: + tasks (array-like): Task names or labels (y-axis). + start_times (array-like): Start times for each task (x-axis). + durations (array-like): Duration of each task. + layer (int): Layer index (default 0). + row, col (int): Subplot position (default top-left). + **kwargs: Forwarded to the backend (e.g., color, alpha, edgecolor, label). + """ + sp = self._get_or_create_subplot(row, col) + sp.gantt(tasks, start_times, durations, layer=layer, **kwargs) + def set_xlabel(self, label: str, row: int | None = None, col: int | None = None): """Set the x-axis label for a subplot (default top-left).""" self._get_or_create_subplot(row, col).set_xlabel(label) @@ -840,6 +864,15 @@ def savefig( self._save_plotly(fig, full_filepath) if verbose: print(f"Saved {full_filepath}") + elif backend == "tikzfigure": + if layers is not None: + raise NotImplementedError( + "Layer-by-layer rendering is not supported for tikzfigure backend" + ) + fig = self.plot(backend="tikzfigure", savefig=False) + fig.savefig(filename) + if verbose: + print(f"Saved {filename}") def plot( self, diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 8704ea1..4a63855 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -185,6 +185,28 @@ def bar(self, x, height, layer=0, **kwargs): } self._add(ld, layer) + def gantt(self, tasks, start_times, durations, layer=0, **kwargs): + """ + Add a Gantt chart to the subplot. + + Parameters: + tasks (array-like): Task names or labels (y-axis). + start_times (array-like): Start times for each task (x-axis). + durations (array-like): Duration of each task. + layer (int): Layer index (default 0). + **kwargs: Additional keyword arguments forwarded to the backend + (e.g., color, alpha, edgecolor, label). + """ + ld = { + "tasks": list(tasks), + "start_times": np.array(start_times), + "durations": np.array(durations), + "layer": layer, + "plot_type": "gantt", + "kwargs": kwargs, + } + self._add(ld, layer) + def axhline(self, y=0, layer=0, **kwargs): """ Add a horizontal line spanning the full width of the axes. @@ -463,6 +485,14 @@ def plot_matplotlib( line["height"] * self._yscale, **line["kwargs"], ) + elif line["plot_type"] == "gantt": + tasks = line["tasks"] + start_times = (line["start_times"] + self._xshift) * self._xscale + durations = line["durations"] * self._xscale + y_positions = np.arange(len(tasks)) + ax.barh(y_positions, durations, left=start_times, **line["kwargs"]) + ax.set_yticks(y_positions) + ax.set_yticklabels(tasks) elif line["plot_type"] == "fill_between": ax.fill_between( (line["x"] + self._xshift) * self._xscale, @@ -560,6 +590,28 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: nodes = [[xi, yi] for xi, yi in zip(x, y)] tikz_figure.draw(nodes=nodes, **line["kwargs"]) + elif line["plot_type"] == "gantt": + tasks = line["tasks"] + start_times = (line["start_times"] + self._xshift) * self._xscale + durations = line["durations"] * self._xscale + y_positions = np.arange(len(tasks)) + + # Draw horizontal bars for each task + for i, (task, start, duration) in enumerate(zip(tasks, start_times, durations)): + # Create rectangle nodes for the bar + x_start = start + x_end = start + duration + y_pos = y_positions[i] + bar_height = 0.8 # Bar thickness + + # Draw rectangle as a path + rect_nodes = [ + [x_start, y_pos - bar_height/2], + [x_end, y_pos - bar_height/2], + [x_end, y_pos + bar_height/2], + [x_start, y_pos + bar_height/2], + ] + tikz_figure.draw(nodes=rect_nodes, cycle=True, fill=line["kwargs"].get("color", "blue"), **line["kwargs"]) if verbose: print("Generated TikZ figure:") print(tikz_figure.generate_tikz()) @@ -599,6 +651,12 @@ def plot_plotly(self, layers=None): shapes: list[dict] = [] annotations: list[dict] = [] last_heatmap_idx: int | None = None + # Plotly shapes (unlike traces) don't participate in axis autorange, + # so patches would otherwise be clipped or invisible unless the caller + # sets explicit axis limits. Track each patch's bounding box here and + # add one invisible marker trace at the end so autorange sees them. + patch_bounds_x: list[float] = [] + patch_bounds_y: list[float] = [] def tx(values): return self._transform_x(values) @@ -687,6 +745,23 @@ def plotly_color(value): marker_color=plotly_color(kwargs.get("color", None)), ) traces.append(trace) + elif plot_type == "gantt": + kwargs = line["kwargs"] + tasks = line["tasks"] + start_times = tx(line["start_times"]) + durations = np.asarray(line["durations"]) * self._xscale + y_positions = list(range(len(tasks))) + trace = go.Bar( + x=durations, + y=y_positions, + base=start_times, + orientation='h', + name=kwargs.get("label", ""), + showlegend=bool(kwargs.get("label")) and bool(self._legend), + marker_color=plotly_color(kwargs.get("color", None)), + opacity=kwargs.get("alpha", None), + ) + traces.append(trace) elif plot_type == "fill_between": kwargs = line["kwargs"] x = tx(line["x"]) @@ -917,6 +992,29 @@ def _patch_fill_color(): if raw and not str(raw).startswith("_"): patch_label = str(raw) + hovertext = kwargs.get("hovertext") + + def _add_hover_trace(x_pts, y_pts, hovertext=hovertext): + # Plotly shapes can't show hover info themselves, so an + # invisible filled polygon trace is overlaid on top of + # the shape's outline to make the whole area hoverable. + if hovertext is None: + return + traces.append( + go.Scatter( + x=list(x_pts) + [x_pts[0]], + y=list(y_pts) + [y_pts[0]], + mode="lines", + fill="toself", + fillcolor="rgba(0,0,0,0)", + line=dict(width=0), + hoveron="fills", + hoverinfo="text", + hovertext=hovertext, + showlegend=False, + ) + ) + if mpl_patches is not None and isinstance(patch, mpl_patches.Rectangle): x0 = txs(patch.get_x()) y0 = tys(patch.get_y()) @@ -934,6 +1032,9 @@ def _patch_fill_color(): opacity=kwargs.get("alpha", None), ) ) + patch_bounds_x.extend([x0, x1]) + patch_bounds_y.extend([y0, y1]) + _add_hover_trace([x0, x1, x1, x0], [y0, y0, y1, y1]) elif mpl_patches is not None and isinstance(patch, mpl_patches.Circle): cx = txs(patch.center[0]) cy = tys(patch.center[1]) @@ -953,6 +1054,10 @@ def _patch_fill_color(): opacity=kwargs.get("alpha", None), ) ) + patch_bounds_x.extend([cx - rx, cx + rx]) + patch_bounds_y.extend([cy - ry, cy + ry]) + angles = np.linspace(0, 2 * np.pi, 32, endpoint=False) + _add_hover_trace(cx + rx * np.cos(angles), cy + ry * np.sin(angles)) elif mpl_patches is not None and isinstance(patch, mpl_patches.Ellipse): cx = txs(patch.center[0]) cy = tys(patch.center[1]) @@ -973,6 +1078,10 @@ def _patch_fill_color(): opacity=kwargs.get("alpha", None), ) ) + patch_bounds_x.extend([cx - rx, cx + rx]) + patch_bounds_y.extend([cy - ry, cy + ry]) + angles = np.linspace(0, 2 * np.pi, 32, endpoint=False) + _add_hover_trace(cx + rx * np.cos(angles), cy + ry * np.sin(angles)) elif mpl_patches is not None and isinstance(patch, mpl_patches.Polygon): pts = patch.get_xy() if len(pts) >= 2: @@ -987,6 +1096,9 @@ def _patch_fill_color(): opacity=kwargs.get("alpha", None), ) ) + patch_bounds_x.extend(x for x, _ in pts_t) + patch_bounds_y.extend(y for _, y in pts_t) + _add_hover_trace([x for x, _ in pts_t], [y for _, y in pts_t]) # Plotly shapes don't participate in legends; add a dummy trace. if patch_label and bool(self._legend): @@ -1001,6 +1113,18 @@ def _patch_fill_color(): ) ) + if patch_bounds_x: + traces.append( + go.Scatter( + x=patch_bounds_x, + y=patch_bounds_y, + mode="markers", + marker=dict(opacity=0), + showlegend=False, + hoverinfo="skip", + ) + ) + return traces, shapes, annotations def _iter_layer_lines(self, layers=None): @@ -1404,6 +1528,22 @@ def plot_plotext(self, ax, layers=None): **bar_kwargs, ) legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "gantt": + tasks = line["tasks"] + start_times = self._transform_x(line["start_times"]).tolist() + durations = (np.asarray(line["durations"]) * self._xscale).tolist() + y_positions = list(range(len(tasks))) + gantt_kwargs = self._plotext_bar_kwargs(kwargs) + gantt_kwargs["orientation"] = "h" + for i, (start, duration) in enumerate(zip(start_times, durations)): + ax.bar( + [start + duration / 2], + [i], + width=duration, + **gantt_kwargs, + ) + ax.yticks(y_positions, tasks) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) elif plot_type == "fill_between": x = self._transform_x(line["x"]).tolist() y1 = self._transform_y(line["y1"]).tolist() From a2a8bcaf93089ea61b0eb37ce26a64a9330e9604 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 23 Jul 2026 11:44:03 +0200 Subject: [PATCH 02/10] Add gantt charts with all backends --- src/maxplotlib/canvas/canvas.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index 11c2cce..c19c328 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -1128,6 +1128,38 @@ def plot_tikzfigure( color=kwargs.get("color", "black"), line_width=kwargs.get("linewidth", 1.0), ) + elif line_data.get("plot_type") == "gantt": + tasks = line_data["tasks"] + start_times = (line_data["start_times"] + line_plot._xshift) * line_plot._xscale + durations = line_data["durations"] * line_plot._xscale + y_positions = np.arange(len(tasks)) + kwargs = line_data.get("kwargs", {}) + + # Draw horizontal bars for each task as filled rectangles + for i, (task, start, duration) in enumerate(zip(tasks, start_times, durations)): + x_start = float(start) + x_end = float(start + duration) + y_pos = float(y_positions[i]) + bar_height = 0.8 + + # Create rectangle coordinates for the bar + x_coords = [x_start, x_end, x_end, x_start, x_start] + y_coords = [y_pos - bar_height/2, y_pos - bar_height/2, + y_pos + bar_height/2, y_pos + bar_height/2, y_pos - bar_height/2] + + # Add as a filled plot + color = kwargs.get("color", "blue") + ax.add_plot( + x=x_coords, + y=y_coords, + color=color, + fill=True, + line_width=0, + ) + + # Set y-axis ticks to show task names + if line_plot._yticks is None: + ax.set_ticks('y', list(y_positions), tasks) # Add legend if requested if line_plot._legend and len(line_plot.line_data) > 0: From 71b2a77b2bf8f7cafd6dbcfd248956a08d66baac Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 23 Jul 2026 11:46:49 +0200 Subject: [PATCH 03/10] Formatting --- examples/gantt_matplotlib.py | 14 +++++++------- examples/gantt_plotext.py | 14 +++++++------- examples/gantt_plotly.py | 14 +++++++------- src/maxplotlib/canvas/canvas.py | 27 ++++++++++++++++++--------- src/maxplotlib/subfigure/line_plot.py | 25 ++++++++++++++++--------- 5 files changed, 55 insertions(+), 39 deletions(-) diff --git a/examples/gantt_matplotlib.py b/examples/gantt_matplotlib.py index 3979764..3490d96 100644 --- a/examples/gantt_matplotlib.py +++ b/examples/gantt_matplotlib.py @@ -13,16 +13,16 @@ def main() -> None: "Deployment", "Documentation", ] - + # Start times (in days from project start) start_times = np.array([0, 5, 10, 25, 35, 30]) - + # Duration of each task (in days) durations = np.array([5, 5, 15, 10, 5, 10]) - + # Create canvas canvas = Canvas(width="14cm", ratio=0.6, dpi=150) - + # Add gantt chart canvas.gantt( tasks=tasks, @@ -31,16 +31,16 @@ def main() -> None: color="steelblue", alpha=0.7, edgecolor="black", - label="Project Tasks" + label="Project Tasks", ) - + # Configure plot canvas.set_title("Project Timeline - Gantt Chart") canvas.set_xlabel("Days from Project Start") canvas.set_ylabel("Tasks") canvas.set_grid(True) canvas.set_xlim(0, 45) - + # Save figure canvas.savefig("gantt_matplotlib.png", backend="matplotlib") print("Gantt chart saved as gantt_matplotlib.png") diff --git a/examples/gantt_plotext.py b/examples/gantt_plotext.py index 598327f..6dafd28 100644 --- a/examples/gantt_plotext.py +++ b/examples/gantt_plotext.py @@ -13,32 +13,32 @@ def main() -> None: "Deployment", "Documentation", ] - + # Start times (in days from project start) start_times = np.array([0, 5, 10, 25, 35, 30]) - + # Duration of each task (in days) durations = np.array([5, 5, 15, 10, 5, 10]) - + # Create canvas canvas = Canvas(width="14cm", ratio=0.6) - + # Add gantt chart canvas.gantt( tasks=tasks, start_times=start_times, durations=durations, color="cyan", - label="Project Tasks" + label="Project Tasks", ) - + # Configure plot canvas.set_title("Project Timeline - Gantt Chart (Plotext)") canvas.set_xlabel("Days from Project Start") canvas.set_ylabel("Tasks") canvas.set_grid(True) canvas.set_xlim(0, 45) - + # Save figure (plotext renders to terminal) canvas.savefig("gantt_plotext.txt", backend="plotext") print("Gantt chart saved as gantt_plotext.txt") diff --git a/examples/gantt_plotly.py b/examples/gantt_plotly.py index fc150d7..8dd6312 100644 --- a/examples/gantt_plotly.py +++ b/examples/gantt_plotly.py @@ -13,16 +13,16 @@ def main() -> None: "Deployment", "Documentation", ] - + # Start times (in days from project start) start_times = np.array([0, 5, 10, 25, 35, 30]) - + # Duration of each task (in days) durations = np.array([5, 5, 15, 10, 5, 10]) - + # Create canvas canvas = Canvas(width="14cm", ratio=0.6) - + # Add gantt chart canvas.gantt( tasks=tasks, @@ -30,16 +30,16 @@ def main() -> None: durations=durations, color="steelblue", alpha=0.7, - label="Project Tasks" + label="Project Tasks", ) - + # Configure plot canvas.set_title("Project Timeline - Gantt Chart (Plotly)") canvas.set_xlabel("Days from Project Start") canvas.set_ylabel("Tasks") canvas.set_grid(True) canvas.set_xlim(0, 45) - + # Save figure canvas.savefig("gantt_plotly.html", backend="plotly") print("Gantt chart saved as gantt_plotly.html") diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index c19c328..9ffec61 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -1130,23 +1130,32 @@ def plot_tikzfigure( ) elif line_data.get("plot_type") == "gantt": tasks = line_data["tasks"] - start_times = (line_data["start_times"] + line_plot._xshift) * line_plot._xscale + start_times = ( + line_data["start_times"] + line_plot._xshift + ) * line_plot._xscale durations = line_data["durations"] * line_plot._xscale y_positions = np.arange(len(tasks)) kwargs = line_data.get("kwargs", {}) - + # Draw horizontal bars for each task as filled rectangles - for i, (task, start, duration) in enumerate(zip(tasks, start_times, durations)): + for i, (task, start, duration) in enumerate( + zip(tasks, start_times, durations) + ): x_start = float(start) x_end = float(start + duration) y_pos = float(y_positions[i]) bar_height = 0.8 - + # Create rectangle coordinates for the bar x_coords = [x_start, x_end, x_end, x_start, x_start] - y_coords = [y_pos - bar_height/2, y_pos - bar_height/2, - y_pos + bar_height/2, y_pos + bar_height/2, y_pos - bar_height/2] - + y_coords = [ + y_pos - bar_height / 2, + y_pos - bar_height / 2, + y_pos + bar_height / 2, + y_pos + bar_height / 2, + y_pos - bar_height / 2, + ] + # Add as a filled plot color = kwargs.get("color", "blue") ax.add_plot( @@ -1156,10 +1165,10 @@ def plot_tikzfigure( fill=True, line_width=0, ) - + # Set y-axis ticks to show task names if line_plot._yticks is None: - ax.set_ticks('y', list(y_positions), tasks) + ax.set_ticks("y", list(y_positions), tasks) # Add legend if requested if line_plot._legend and len(line_plot.line_data) > 0: diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 4a63855..8b10b90 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -595,23 +595,30 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: start_times = (line["start_times"] + self._xshift) * self._xscale durations = line["durations"] * self._xscale y_positions = np.arange(len(tasks)) - + # Draw horizontal bars for each task - for i, (task, start, duration) in enumerate(zip(tasks, start_times, durations)): + for i, (task, start, duration) in enumerate( + zip(tasks, start_times, durations) + ): # Create rectangle nodes for the bar x_start = start x_end = start + duration y_pos = y_positions[i] bar_height = 0.8 # Bar thickness - + # Draw rectangle as a path rect_nodes = [ - [x_start, y_pos - bar_height/2], - [x_end, y_pos - bar_height/2], - [x_end, y_pos + bar_height/2], - [x_start, y_pos + bar_height/2], + [x_start, y_pos - bar_height / 2], + [x_end, y_pos - bar_height / 2], + [x_end, y_pos + bar_height / 2], + [x_start, y_pos + bar_height / 2], ] - tikz_figure.draw(nodes=rect_nodes, cycle=True, fill=line["kwargs"].get("color", "blue"), **line["kwargs"]) + tikz_figure.draw( + nodes=rect_nodes, + cycle=True, + fill=line["kwargs"].get("color", "blue"), + **line["kwargs"], + ) if verbose: print("Generated TikZ figure:") print(tikz_figure.generate_tikz()) @@ -755,7 +762,7 @@ def plotly_color(value): x=durations, y=y_positions, base=start_times, - orientation='h', + orientation="h", name=kwargs.get("label", ""), showlegend=bool(kwargs.get("label")) and bool(self._legend), marker_color=plotly_color(kwargs.get("color", None)), From 255c4eff41c042efbff84003d6ed833bb7d3940c Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 23 Jul 2026 11:56:42 +0200 Subject: [PATCH 04/10] Added flame_chart --- src/maxplotlib/canvas/canvas.py | 27 ++++ src/maxplotlib/subfigure/line_plot.py | 212 ++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index 9ffec61..9794276 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -454,6 +454,33 @@ def gantt( sp = self._get_or_create_subplot(row, col) sp.gantt(tasks, start_times, durations, layer=layer, **kwargs) + def flame_chart( + self, + labels, + parents, + values, + start_times=None, + layer=0, + row: int | None = None, + col: int | None = None, + **kwargs, + ): + """ + Add a flame chart to the canvas (matplotlib-style convenience method). + + Parameters: + labels (array-like): Labels for each stack frame/function. + parents (array-like): Parent indices for each frame (None for root, or index of parent). + values (array-like): Duration/sample count for each frame. + start_times (array-like, optional): Start times for each frame. If None, computed from hierarchy. + layer (int): Layer index (default 0). + row, col (int): Subplot position (default top-left). + **kwargs: Forwarded to the backend (e.g., colormap, edgecolor, label). + """ + sp = self._get_or_create_subplot(row, col) + sp.flame_chart(labels, parents, values, start_times=start_times, layer=layer, **kwargs) + + def set_xlabel(self, label: str, row: int | None = None, col: int | None = None): """Set the x-axis label for a subplot (default top-left).""" self._get_or_create_subplot(row, col).set_xlabel(label) diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 8b10b90..e75c4e6 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -207,6 +207,30 @@ def gantt(self, tasks, start_times, durations, layer=0, **kwargs): } self._add(ld, layer) + def flame_chart(self, labels, parents, values, start_times=None, layer=0, **kwargs): + """ + Add a flame chart to the subplot for hierarchical profiling data. + + Parameters: + labels (array-like): Labels for each stack frame/function. + parents (array-like): Parent indices for each frame (None for root, or index of parent). + values (array-like): Duration/sample count for each frame. + start_times (array-like, optional): Start times for each frame. If None, computed from hierarchy. + layer (int): Layer index (default 0). + **kwargs: Additional keyword arguments forwarded to the backend + (e.g., colormap, edgecolor, label). + """ + ld = { + "labels": list(labels), + "parents": list(parents), + "values": np.array(values), + "start_times": np.array(start_times) if start_times is not None else None, + "layer": layer, + "plot_type": "flame_chart", + "kwargs": kwargs, + } + self._add(ld, layer) + def axhline(self, y=0, layer=0, **kwargs): """ Add a horizontal line spanning the full width of the axes. @@ -493,6 +517,60 @@ def plot_matplotlib( ax.barh(y_positions, durations, left=start_times, **line["kwargs"]) ax.set_yticks(y_positions) ax.set_yticklabels(tasks) + elif line["plot_type"] == "flame_chart": + labels = line["labels"] + parents = line["parents"] + values = line["values"] * self._xscale + start_times = line["start_times"] + + # Calculate depth levels and positions + n = len(labels) + depths = np.zeros(n, dtype=int) + if start_times is None: + start_times = np.zeros(n) + else: + start_times = (start_times + self._xshift) * self._xscale + + # Calculate depths based on parent relationships + for i in range(n): + if parents[i] is None: + depths[i] = 0 + else: + parent_idx = parents[i] if isinstance(parents[i], int) else list(labels).index(parents[i]) + depths[i] = depths[parent_idx] + 1 + + # Draw rectangles for each frame + import matplotlib.patches as mpatches + colormap = line["kwargs"].get("colormap", "viridis") + cmap = plt.get_cmap(colormap) + max_depth = depths.max() + 1 + + for i in range(n): + color = cmap(depths[i] / max_depth) if max_depth > 1 else cmap(0.5) + rect = mpatches.Rectangle( + (start_times[i], depths[i]), + values[i], + 0.9, + facecolor=color, + edgecolor=line["kwargs"].get("edgecolor", "black"), + linewidth=0.5, + ) + ax.add_patch(rect) + + # Add label if rectangle is wide enough + if values[i] > 0.1 * (start_times.max() + values.max()): + ax.text( + start_times[i] + values[i] / 2, + depths[i] + 0.45, + labels[i], + ha="center", + va="center", + fontsize=8, + color="white" if depths[i] / max_depth > 0.5 else "black", + ) + + ax.set_ylim(-0.5, max_depth) + ax.set_ylabel("Stack Depth") elif line["plot_type"] == "fill_between": ax.fill_between( (line["x"] + self._xshift) * self._xscale, @@ -619,6 +697,49 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: fill=line["kwargs"].get("color", "blue"), **line["kwargs"], ) + elif line["plot_type"] == "flame_chart": + labels = line["labels"] + parents = line["parents"] + values = line["values"] * self._xscale + start_times = line["start_times"] + + # Calculate depths + n = len(labels) + depths = np.zeros(n, dtype=int) + if start_times is None: + start_times = np.zeros(n) + else: + start_times = (start_times + self._xshift) * self._xscale + + for i in range(n): + if parents[i] is None: + depths[i] = 0 + else: + parent_idx = parents[i] if isinstance(parents[i], int) else list(labels).index(parents[i]) + depths[i] = depths[parent_idx] + 1 + + # Draw rectangles for each frame + bar_height = 0.8 + colors = ["red", "blue", "green", "orange", "purple", "cyan"] + + for i in range(n): + x_start = start_times[i] + x_end = start_times[i] + values[i] + y_pos = depths[i] + color = colors[depths[i] % len(colors)] + + rect_nodes = [ + [x_start, y_pos - bar_height / 2], + [x_end, y_pos - bar_height / 2], + [x_end, y_pos + bar_height / 2], + [x_start, y_pos + bar_height / 2], + ] + tikz_figure.draw( + nodes=rect_nodes, + cycle=True, + fill=color, + **{k: v for k, v in line["kwargs"].items() if k != "colormap"}, + ) if verbose: print("Generated TikZ figure:") print(tikz_figure.generate_tikz()) @@ -769,6 +890,54 @@ def plotly_color(value): opacity=kwargs.get("alpha", None), ) traces.append(trace) + elif plot_type == "flame_chart": + kwargs = line["kwargs"] + labels = line["labels"] + parents = line["parents"] + values = np.asarray(line["values"]) * self._xscale + start_times = line["start_times"] + + # Calculate depths + n = len(labels) + depths = np.zeros(n, dtype=int) + if start_times is None: + start_times = np.zeros(n) + else: + start_times = tx(start_times) + + for i in range(n): + if parents[i] is None: + depths[i] = 0 + else: + parent_idx = parents[i] if isinstance(parents[i], int) else list(labels).index(parents[i]) + depths[i] = depths[parent_idx] + 1 + + # Create rectangles as shapes + colormap = kwargs.get("colormap", "Viridis") + import plotly.express as px + colors = px.colors.sample_colorscale(colormap, np.linspace(0, 1, depths.max() + 1)) + + for i in range(n): + color = colors[depths[i]] if depths.max() > 0 else colors[0] + shapes.append(dict( + type="rect", + x0=float(start_times[i]), + x1=float(start_times[i] + values[i]), + y0=float(depths[i]), + y1=float(depths[i] + 0.9), + fillcolor=color, + line=dict(color=kwargs.get("edgecolor", "black"), width=0.5), + )) + + # Add text annotation if wide enough + if values[i] > 0.1 * (start_times.max() + values.max()): + annotations.append(dict( + x=float(start_times[i] + values[i] / 2), + y=float(depths[i] + 0.45), + text=labels[i], + showarrow=False, + font=dict(size=8, color="white"), + )) elif plot_type == "fill_between": kwargs = line["kwargs"] x = tx(line["x"]) @@ -1550,6 +1719,49 @@ def plot_plotext(self, ax, layers=None): **gantt_kwargs, ) ax.yticks(y_positions, tasks) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) + elif plot_type == "flame_chart": + labels = line["labels"] + parents = line["parents"] + values = (np.asarray(line["values"]) * self._xscale).tolist() + start_times = line["start_times"] + + # Calculate depths + n = len(labels) + depths = np.zeros(n, dtype=int) + if start_times is None: + start_times = np.zeros(n).tolist() + else: + start_times = self._transform_x(line["start_times"]).tolist() + + for i in range(n): + if parents[i] is None: + depths[i] = 0 + else: + parent_idx = parents[i] if isinstance(parents[i], int) else list(labels).index(parents[i]) + depths[i] = depths[parent_idx] + 1 + + # Draw bars for each frame + flame_kwargs = self._plotext_bar_kwargs(kwargs) + flame_kwargs["orientation"] = "h" + + # Use different colors for different depths + colormap = kwargs.get("colormap", "viridis") + max_depth = int(depths.max()) + 1 + + for i in range(n): + # Simple color cycling based on depth + depth_colors = ["red", "green", "blue", "yellow", "cyan", "magenta"] + color = depth_colors[depths[i] % len(depth_colors)] + + ax.bar( + [start_times[i] + values[i] / 2], + [depths[i]], + width=values[i], + color=color, + **{k: v for k, v in flame_kwargs.items() if k != "color"}, + ) + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) elif plot_type == "fill_between": x = self._transform_x(line["x"]).tolist() From 78093d1e35136282da416ee108cf978a43bebbfe Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 23 Jul 2026 11:56:58 +0200 Subject: [PATCH 05/10] Formatting --- src/maxplotlib/canvas/canvas.py | 5 +- src/maxplotlib/subfigure/line_plot.py | 128 ++++++++++++++++---------- 2 files changed, 84 insertions(+), 49 deletions(-) diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index 9794276..e224614 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -478,8 +478,9 @@ def flame_chart( **kwargs: Forwarded to the backend (e.g., colormap, edgecolor, label). """ sp = self._get_or_create_subplot(row, col) - sp.flame_chart(labels, parents, values, start_times=start_times, layer=layer, **kwargs) - + sp.flame_chart( + labels, parents, values, start_times=start_times, layer=layer, **kwargs + ) def set_xlabel(self, label: str, row: int | None = None, col: int | None = None): """Set the x-axis label for a subplot (default top-left).""" diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index e75c4e6..4a5a49d 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -522,7 +522,7 @@ def plot_matplotlib( parents = line["parents"] values = line["values"] * self._xscale start_times = line["start_times"] - + # Calculate depth levels and positions n = len(labels) depths = np.zeros(n, dtype=int) @@ -530,23 +530,30 @@ def plot_matplotlib( start_times = np.zeros(n) else: start_times = (start_times + self._xshift) * self._xscale - + # Calculate depths based on parent relationships for i in range(n): if parents[i] is None: depths[i] = 0 else: - parent_idx = parents[i] if isinstance(parents[i], int) else list(labels).index(parents[i]) + parent_idx = ( + parents[i] + if isinstance(parents[i], int) + else list(labels).index(parents[i]) + ) depths[i] = depths[parent_idx] + 1 - + # Draw rectangles for each frame import matplotlib.patches as mpatches + colormap = line["kwargs"].get("colormap", "viridis") cmap = plt.get_cmap(colormap) max_depth = depths.max() + 1 - + for i in range(n): - color = cmap(depths[i] / max_depth) if max_depth > 1 else cmap(0.5) + color = ( + cmap(depths[i] / max_depth) if max_depth > 1 else cmap(0.5) + ) rect = mpatches.Rectangle( (start_times[i], depths[i]), values[i], @@ -556,7 +563,7 @@ def plot_matplotlib( linewidth=0.5, ) ax.add_patch(rect) - + # Add label if rectangle is wide enough if values[i] > 0.1 * (start_times.max() + values.max()): ax.text( @@ -566,9 +573,11 @@ def plot_matplotlib( ha="center", va="center", fontsize=8, - color="white" if depths[i] / max_depth > 0.5 else "black", + color=( + "white" if depths[i] / max_depth > 0.5 else "black" + ), ) - + ax.set_ylim(-0.5, max_depth) ax.set_ylabel("Stack Depth") elif line["plot_type"] == "fill_between": @@ -702,7 +711,7 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: parents = line["parents"] values = line["values"] * self._xscale start_times = line["start_times"] - + # Calculate depths n = len(labels) depths = np.zeros(n, dtype=int) @@ -710,24 +719,28 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: start_times = np.zeros(n) else: start_times = (start_times + self._xshift) * self._xscale - + for i in range(n): if parents[i] is None: depths[i] = 0 else: - parent_idx = parents[i] if isinstance(parents[i], int) else list(labels).index(parents[i]) + parent_idx = ( + parents[i] + if isinstance(parents[i], int) + else list(labels).index(parents[i]) + ) depths[i] = depths[parent_idx] + 1 - + # Draw rectangles for each frame bar_height = 0.8 colors = ["red", "blue", "green", "orange", "purple", "cyan"] - + for i in range(n): x_start = start_times[i] x_end = start_times[i] + values[i] y_pos = depths[i] color = colors[depths[i] % len(colors)] - + rect_nodes = [ [x_start, y_pos - bar_height / 2], [x_end, y_pos - bar_height / 2], @@ -738,7 +751,11 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: nodes=rect_nodes, cycle=True, fill=color, - **{k: v for k, v in line["kwargs"].items() if k != "colormap"}, + **{ + k: v + for k, v in line["kwargs"].items() + if k != "colormap" + }, ) if verbose: print("Generated TikZ figure:") @@ -896,7 +913,7 @@ def plotly_color(value): parents = line["parents"] values = np.asarray(line["values"]) * self._xscale start_times = line["start_times"] - + # Calculate depths n = len(labels) depths = np.zeros(n, dtype=int) @@ -904,40 +921,53 @@ def plotly_color(value): start_times = np.zeros(n) else: start_times = tx(start_times) - + for i in range(n): if parents[i] is None: depths[i] = 0 else: - parent_idx = parents[i] if isinstance(parents[i], int) else list(labels).index(parents[i]) + parent_idx = ( + parents[i] + if isinstance(parents[i], int) + else list(labels).index(parents[i]) + ) depths[i] = depths[parent_idx] + 1 - + # Create rectangles as shapes colormap = kwargs.get("colormap", "Viridis") import plotly.express as px - colors = px.colors.sample_colorscale(colormap, np.linspace(0, 1, depths.max() + 1)) - + + colors = px.colors.sample_colorscale( + colormap, np.linspace(0, 1, depths.max() + 1) + ) + for i in range(n): color = colors[depths[i]] if depths.max() > 0 else colors[0] - shapes.append(dict( - type="rect", - x0=float(start_times[i]), - x1=float(start_times[i] + values[i]), - y0=float(depths[i]), - y1=float(depths[i] + 0.9), - fillcolor=color, - line=dict(color=kwargs.get("edgecolor", "black"), width=0.5), - )) - + shapes.append( + dict( + type="rect", + x0=float(start_times[i]), + x1=float(start_times[i] + values[i]), + y0=float(depths[i]), + y1=float(depths[i] + 0.9), + fillcolor=color, + line=dict( + color=kwargs.get("edgecolor", "black"), width=0.5 + ), + ) + ) + # Add text annotation if wide enough if values[i] > 0.1 * (start_times.max() + values.max()): - annotations.append(dict( - x=float(start_times[i] + values[i] / 2), - y=float(depths[i] + 0.45), - text=labels[i], - showarrow=False, - font=dict(size=8, color="white"), - )) + annotations.append( + dict( + x=float(start_times[i] + values[i] / 2), + y=float(depths[i] + 0.45), + text=labels[i], + showarrow=False, + font=dict(size=8, color="white"), + ) + ) elif plot_type == "fill_between": kwargs = line["kwargs"] x = tx(line["x"]) @@ -1725,7 +1755,7 @@ def plot_plotext(self, ax, layers=None): parents = line["parents"] values = (np.asarray(line["values"]) * self._xscale).tolist() start_times = line["start_times"] - + # Calculate depths n = len(labels) depths = np.zeros(n, dtype=int) @@ -1733,27 +1763,31 @@ def plot_plotext(self, ax, layers=None): start_times = np.zeros(n).tolist() else: start_times = self._transform_x(line["start_times"]).tolist() - + for i in range(n): if parents[i] is None: depths[i] = 0 else: - parent_idx = parents[i] if isinstance(parents[i], int) else list(labels).index(parents[i]) + parent_idx = ( + parents[i] + if isinstance(parents[i], int) + else list(labels).index(parents[i]) + ) depths[i] = depths[parent_idx] + 1 - + # Draw bars for each frame flame_kwargs = self._plotext_bar_kwargs(kwargs) flame_kwargs["orientation"] = "h" - + # Use different colors for different depths colormap = kwargs.get("colormap", "viridis") max_depth = int(depths.max()) + 1 - + for i in range(n): # Simple color cycling based on depth depth_colors = ["red", "green", "blue", "yellow", "cyan", "magenta"] color = depth_colors[depths[i] % len(depth_colors)] - + ax.bar( [start_times[i] + values[i] / 2], [depths[i]], @@ -1761,7 +1795,7 @@ def plot_plotext(self, ax, layers=None): color=color, **{k: v for k, v in flame_kwargs.items() if k != "color"}, ) - + legend_entries.append((kwargs.get("label"), kwargs.get("color"))) elif plot_type == "fill_between": x = self._transform_x(line["x"]).tolist() From 60ade268f0728c998afe2fed8ad8ff99506e2466 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 23 Jul 2026 11:58:37 +0200 Subject: [PATCH 06/10] Added examples --- examples/flame_matplotlib.py | 62 ++++++++++++++++++++++++++++++++++++ examples/flame_plotext.py | 58 +++++++++++++++++++++++++++++++++ examples/flame_plotly.py | 60 ++++++++++++++++++++++++++++++++++ examples/flame_tikz.py | 57 +++++++++++++++++++++++++++++++++ examples/gantt_tikz.py | 48 ++++++++++++++++++++++++++++ 5 files changed, 285 insertions(+) create mode 100644 examples/flame_matplotlib.py create mode 100644 examples/flame_plotext.py create mode 100644 examples/flame_plotly.py create mode 100644 examples/flame_tikz.py create mode 100644 examples/gantt_tikz.py diff --git a/examples/flame_matplotlib.py b/examples/flame_matplotlib.py new file mode 100644 index 0000000..2e92b45 --- /dev/null +++ b/examples/flame_matplotlib.py @@ -0,0 +1,62 @@ +""" +Example: Flame Chart with Matplotlib Backend + +This example demonstrates how to create a flame chart visualization +using the matplotlib backend. Flame charts are useful for visualizing +hierarchical profiling data, showing function call stacks and their +execution times. +""" + +from maxplotlib import Canvas + +# Example profiling data: function call hierarchy +# Each function has: label, parent index (None for root), and duration +labels = [ + "main()", # 0 - root + "process_data()", # 1 - child of main + "load_file()", # 2 - child of process_data + "parse_json()", # 3 - child of process_data + "validate()", # 4 - child of process_data + "compute()", # 5 - child of main + "algorithm_a()", # 6 - child of compute + "algorithm_b()", # 7 - child of compute + "save_results()", # 8 - child of main +] + +parents = [ + None, # main() is root + 0, # process_data() called by main() + 1, # load_file() called by process_data() + 1, # parse_json() called by process_data() + 1, # validate() called by process_data() + 0, # compute() called by main() + 5, # algorithm_a() called by compute() + 5, # algorithm_b() called by compute() + 0, # save_results() called by main() +] + +# Duration of each function call (in milliseconds) +values = [100, 40, 10, 15, 15, 50, 25, 25, 10] + +# Start times for each function (when they begin execution) +start_times = [0, 0, 0, 10, 25, 40, 40, 65, 90] + +# Create canvas and add flame chart +canvas = Canvas(nrows=1, ncols=1, figsize=(12, 6)) +canvas.flame_chart( + labels=labels, + parents=parents, + values=values, + start_times=start_times, + colormap="viridis", + edgecolor="black", +) + +# Configure the plot +canvas.set_xlabel("Time (ms)") +canvas.set_ylabel("Stack Depth") +canvas.set_title("Flame Chart: Function Call Hierarchy") + +# Save the figure +canvas.savefig("flame_matplotlib.png", backend="matplotlib") +print("Flame chart saved as flame_matplotlib.png") diff --git a/examples/flame_plotext.py b/examples/flame_plotext.py new file mode 100644 index 0000000..0be2923 --- /dev/null +++ b/examples/flame_plotext.py @@ -0,0 +1,58 @@ +""" +Example: Flame Chart with Plotext Backend + +This example demonstrates how to create a flame chart visualization +using the plotext backend for terminal/console output. +""" + +from maxplotlib import Canvas + +# Example profiling data: function call hierarchy +labels = [ + "main()", # 0 - root + "process_data()", # 1 - child of main + "load_file()", # 2 - child of process_data + "parse_json()", # 3 - child of process_data + "validate()", # 4 - child of process_data + "compute()", # 5 - child of main + "algorithm_a()", # 6 - child of compute + "algorithm_b()", # 7 - child of compute + "save_results()", # 8 - child of main +] + +parents = [ + None, # main() is root + 0, # process_data() called by main() + 1, # load_file() called by process_data() + 1, # parse_json() called by process_data() + 1, # validate() called by process_data() + 0, # compute() called by main() + 5, # algorithm_a() called by compute() + 5, # algorithm_b() called by compute() + 0, # save_results() called by main() +] + +# Duration of each function call (in milliseconds) +values = [100, 40, 10, 15, 15, 50, 25, 25, 10] + +# Start times for each function (when they begin execution) +start_times = [0, 0, 0, 10, 25, 40, 40, 65, 90] + +# Create canvas and add flame chart +canvas = Canvas(nrows=1, ncols=1) +canvas.flame_chart( + labels=labels, + parents=parents, + values=values, + start_times=start_times, + colormap="viridis", +) + +# Configure the plot +canvas.set_xlabel("Time (ms)") +canvas.set_ylabel("Stack Depth") +canvas.set_title("Flame Chart: Function Call Hierarchy") + +# Save the figure +canvas.savefig("flame_plotext.txt", backend="plotext") +print("Flame chart saved as flame_plotext.txt") diff --git a/examples/flame_plotly.py b/examples/flame_plotly.py new file mode 100644 index 0000000..22782df --- /dev/null +++ b/examples/flame_plotly.py @@ -0,0 +1,60 @@ +""" +Example: Flame Chart with Plotly Backend + +This example demonstrates how to create an interactive flame chart +using the plotly backend. The interactive nature allows zooming and +hovering over function calls to see details. +""" + +from maxplotlib import Canvas + +# Example profiling data: function call hierarchy +labels = [ + "main()", # 0 - root + "process_data()", # 1 - child of main + "load_file()", # 2 - child of process_data + "parse_json()", # 3 - child of process_data + "validate()", # 4 - child of process_data + "compute()", # 5 - child of main + "algorithm_a()", # 6 - child of compute + "algorithm_b()", # 7 - child of compute + "save_results()", # 8 - child of main +] + +parents = [ + None, # main() is root + 0, # process_data() called by main() + 1, # load_file() called by process_data() + 1, # parse_json() called by process_data() + 1, # validate() called by process_data() + 0, # compute() called by main() + 5, # algorithm_a() called by compute() + 5, # algorithm_b() called by compute() + 0, # save_results() called by main() +] + +# Duration of each function call (in milliseconds) +values = [100, 40, 10, 15, 15, 50, 25, 25, 10] + +# Start times for each function (when they begin execution) +start_times = [0, 0, 0, 10, 25, 40, 40, 65, 90] + +# Create canvas and add flame chart +canvas = Canvas(nrows=1, ncols=1, figsize=(12, 6)) +canvas.flame_chart( + labels=labels, + parents=parents, + values=values, + start_times=start_times, + colormap="Plasma", + edgecolor="black", +) + +# Configure the plot +canvas.set_xlabel("Time (ms)") +canvas.set_ylabel("Stack Depth") +canvas.set_title("Interactive Flame Chart: Function Call Hierarchy") + +# Save the figure +canvas.savefig("flame_plotly.html", backend="plotly") +print("Interactive flame chart saved as flame_plotly.html") diff --git a/examples/flame_tikz.py b/examples/flame_tikz.py new file mode 100644 index 0000000..070322e --- /dev/null +++ b/examples/flame_tikz.py @@ -0,0 +1,57 @@ +""" +Example: Flame Chart with TikzFigure Backend + +This example demonstrates how to create a flame chart visualization +using the tikzfigure backend for LaTeX/PDF output. +""" + +from maxplotlib import Canvas + +# Example profiling data: function call hierarchy +labels = [ + "main()", # 0 - root + "process_data()", # 1 - child of main + "load_file()", # 2 - child of process_data + "parse_json()", # 3 - child of process_data + "validate()", # 4 - child of process_data + "compute()", # 5 - child of main + "algorithm_a()", # 6 - child of compute + "algorithm_b()", # 7 - child of compute + "save_results()", # 8 - child of main +] + +parents = [ + None, # main() is root + 0, # process_data() called by main() + 1, # load_file() called by process_data() + 1, # parse_json() called by process_data() + 1, # validate() called by process_data() + 0, # compute() called by main() + 5, # algorithm_a() called by compute() + 5, # algorithm_b() called by compute() + 0, # save_results() called by main() +] + +# Duration of each function call (in milliseconds) +values = [100, 40, 10, 15, 15, 50, 25, 25, 10] + +# Start times for each function (when they begin execution) +start_times = [0, 0, 0, 10, 25, 40, 40, 65, 90] + +# Create canvas and add flame chart +canvas = Canvas(nrows=1, ncols=1) +canvas.flame_chart( + labels=labels, + parents=parents, + values=values, + start_times=start_times, +) + +# Configure the plot +canvas.set_xlabel("Time (ms)") +canvas.set_ylabel("Stack Depth") +canvas.set_title("Flame Chart: Function Call Hierarchy") + +# Save the figure +canvas.savefig("flame_tikz.pdf", backend="tikzfigure") +print("Flame chart saved as flame_tikz.pdf") diff --git a/examples/gantt_tikz.py b/examples/gantt_tikz.py new file mode 100644 index 0000000..770d78d --- /dev/null +++ b/examples/gantt_tikz.py @@ -0,0 +1,48 @@ +import numpy as np + +from maxplotlib import Canvas + + +def main() -> None: + # Define project tasks + tasks = [ + "Planning", + "Design", + "Development", + "Testing", + "Deployment", + "Documentation", + ] + + # Start times (in days from project start) + start_times = np.array([0, 5, 10, 25, 35, 30]) + + # Duration of each task (in days) + durations = np.array([5, 5, 15, 10, 5, 10]) + + # Create canvas + canvas = Canvas(width="14cm", ratio=0.6) + + # Add gantt chart + canvas.gantt( + tasks=tasks, + start_times=start_times, + durations=durations, + color="blue!50", + label="Project Tasks", + ) + + # Configure plot + canvas.set_title("Project Timeline - Gantt Chart (TikZ)") + canvas.set_xlabel("Days from Project Start") + canvas.set_ylabel("Tasks") + canvas.set_grid(True) + canvas.set_xlim(0, 45) + + # Save figure + canvas.savefig("gantt_tikz.pdf", backend="tikzfigure") + print("Gantt chart saved as gantt_tikz.pdf") + + +if __name__ == "__main__": + main() From e1015576a68881c0ac6cc10aa007790ff26f4705 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 23 Jul 2026 12:00:50 +0200 Subject: [PATCH 07/10] Added src/maxplotlib/tests/test_flame_chart.py --- src/maxplotlib/tests/test_flame_chart.py | 272 +++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 src/maxplotlib/tests/test_flame_chart.py diff --git a/src/maxplotlib/tests/test_flame_chart.py b/src/maxplotlib/tests/test_flame_chart.py new file mode 100644 index 0000000..1751c65 --- /dev/null +++ b/src/maxplotlib/tests/test_flame_chart.py @@ -0,0 +1,272 @@ +""" +Tests for flame chart functionality across all backends. +""" + +import numpy as np +import pytest +from maxplotlib import Canvas + + +@pytest.fixture +def sample_flame_data(): + """Sample hierarchical profiling data for testing.""" + return { + "labels": [ + "main()", + "process_data()", + "load_file()", + "parse_json()", + "validate()", + "compute()", + "algorithm_a()", + "algorithm_b()", + "save_results()", + ], + "parents": [None, 0, 1, 1, 1, 0, 5, 5, 0], + "values": [100, 40, 10, 15, 15, 50, 25, 25, 10], + "start_times": [0, 0, 0, 10, 25, 40, 40, 65, 90], + } + + +def test_flame_chart_basic(sample_flame_data): + """Test basic flame chart creation.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.flame_chart( + labels=sample_flame_data["labels"], + parents=sample_flame_data["parents"], + values=sample_flame_data["values"], + start_times=sample_flame_data["start_times"], + ) + + # Verify subplot was created + assert len(canvas._subplots) == 1 + subplot = canvas._subplots[(0, 0)] + + # Verify flame chart data was added + assert len(subplot.line_data) == 1 + flame_data = subplot.line_data[0] + assert flame_data["plot_type"] == "flame_chart" + assert flame_data["labels"] == sample_flame_data["labels"] + assert flame_data["parents"] == sample_flame_data["parents"] + np.testing.assert_array_equal(flame_data["values"], sample_flame_data["values"]) + + +def test_flame_chart_without_start_times(sample_flame_data): + """Test flame chart with auto-computed start times.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.flame_chart( + labels=sample_flame_data["labels"], + parents=sample_flame_data["parents"], + values=sample_flame_data["values"], + start_times=None, # Should be auto-computed + ) + + subplot = canvas._subplots[(0, 0)] + flame_data = subplot.line_data[0] + assert flame_data["start_times"] is None + + +def test_flame_chart_with_kwargs(sample_flame_data): + """Test flame chart with additional kwargs.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.flame_chart( + labels=sample_flame_data["labels"], + parents=sample_flame_data["parents"], + values=sample_flame_data["values"], + start_times=sample_flame_data["start_times"], + colormap="plasma", + edgecolor="red", + label="Test Flame", + ) + + subplot = canvas._subplots[(0, 0)] + flame_data = subplot.line_data[0] + assert flame_data["kwargs"]["colormap"] == "plasma" + assert flame_data["kwargs"]["edgecolor"] == "red" + assert flame_data["kwargs"]["label"] == "Test Flame" + + +def test_flame_chart_matplotlib_backend(sample_flame_data, tmp_path): + """Test flame chart rendering with matplotlib backend.""" + canvas = Canvas(nrows=1, ncols=1, figsize=(10, 6)) + canvas.flame_chart( + labels=sample_flame_data["labels"], + parents=sample_flame_data["parents"], + values=sample_flame_data["values"], + start_times=sample_flame_data["start_times"], + colormap="viridis", + ) + canvas.set_xlabel("Time (ms)") + canvas.set_ylabel("Stack Depth") + canvas.set_title("Test Flame Chart") + + output_file = tmp_path / "test_flame_matplotlib.png" + canvas.savefig(str(output_file), backend="matplotlib") + + assert output_file.exists() + assert output_file.stat().st_size > 0 + + +def test_flame_chart_plotly_backend(sample_flame_data, tmp_path): + """Test flame chart rendering with plotly backend.""" + canvas = Canvas(nrows=1, ncols=1, figsize=(10, 6)) + canvas.flame_chart( + labels=sample_flame_data["labels"], + parents=sample_flame_data["parents"], + values=sample_flame_data["values"], + start_times=sample_flame_data["start_times"], + colormap="Plasma", + ) + canvas.set_xlabel("Time (ms)") + canvas.set_ylabel("Stack Depth") + canvas.set_title("Test Flame Chart") + + output_file = tmp_path / "test_flame_plotly.html" + canvas.savefig(str(output_file), backend="plotly") + + assert output_file.exists() + assert output_file.stat().st_size > 0 + + +def test_flame_chart_plotext_backend(sample_flame_data, tmp_path): + """Test flame chart rendering with plotext backend.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.flame_chart( + labels=sample_flame_data["labels"], + parents=sample_flame_data["parents"], + values=sample_flame_data["values"], + start_times=sample_flame_data["start_times"], + ) + canvas.set_xlabel("Time (ms)") + canvas.set_ylabel("Stack Depth") + canvas.set_title("Test Flame Chart") + + output_file = tmp_path / "test_flame_plotext.txt" + canvas.savefig(str(output_file), backend="plotext") + + assert output_file.exists() + assert output_file.stat().st_size > 0 + + +def test_flame_chart_tikzfigure_backend(sample_flame_data, tmp_path): + """Test flame chart rendering with tikzfigure backend.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.flame_chart( + labels=sample_flame_data["labels"], + parents=sample_flame_data["parents"], + values=sample_flame_data["values"], + start_times=sample_flame_data["start_times"], + ) + canvas.set_xlabel("Time (ms)") + canvas.set_ylabel("Stack Depth") + canvas.set_title("Test Flame Chart") + + output_file = tmp_path / "test_flame_tikz.pdf" + canvas.savefig(str(output_file), backend="tikzfigure") + + assert output_file.exists() + assert output_file.stat().st_size > 0 + + +def test_flame_chart_simple_hierarchy(): + """Test flame chart with simple 2-level hierarchy.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.flame_chart( + labels=["root", "child1", "child2"], + parents=[None, 0, 0], + values=[100, 50, 50], + start_times=[0, 0, 50], + ) + + subplot = canvas._subplots[(0, 0)] + assert len(subplot.line_data) == 1 + flame_data = subplot.line_data[0] + assert len(flame_data["labels"]) == 3 + + +def test_flame_chart_deep_hierarchy(): + """Test flame chart with deep nesting (4 levels).""" + canvas = Canvas(nrows=1, ncols=1) + canvas.flame_chart( + labels=["level0", "level1", "level2", "level3"], + parents=[None, 0, 1, 2], + values=[100, 80, 60, 40], + start_times=[0, 0, 0, 0], + ) + + subplot = canvas._subplots[(0, 0)] + flame_data = subplot.line_data[0] + assert len(flame_data["labels"]) == 4 + + +def test_flame_chart_multiple_roots(): + """Test flame chart with multiple root nodes.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.flame_chart( + labels=["root1", "root2", "child1", "child2"], + parents=[None, None, 0, 1], + values=[50, 50, 30, 30], + start_times=[0, 50, 0, 50], + ) + + subplot = canvas._subplots[(0, 0)] + flame_data = subplot.line_data[0] + assert flame_data["parents"].count(None) == 2 + + +def test_flame_chart_with_layers(): + """Test flame chart with different layers.""" + canvas = Canvas(nrows=1, ncols=1) + + # Add flame chart to layer 0 + canvas.flame_chart( + labels=["func1", "func2"], + parents=[None, 0], + values=[100, 50], + layer=0, + ) + + # Add another flame chart to layer 1 + canvas.flame_chart( + labels=["func3", "func4"], + parents=[None, 0], + values=[80, 40], + layer=1, + ) + + subplot = canvas._subplots[(0, 0)] + assert len(subplot.line_data) == 2 + assert subplot.line_data[0]["layer"] == 0 + assert subplot.line_data[1]["layer"] == 1 + + +def test_flame_chart_empty_data(): + """Test flame chart with empty data.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.flame_chart( + labels=[], + parents=[], + values=[], + start_times=[], + ) + + subplot = canvas._subplots[(0, 0)] + flame_data = subplot.line_data[0] + assert len(flame_data["labels"]) == 0 + assert len(flame_data["values"]) == 0 + + +def test_flame_chart_single_node(): + """Test flame chart with single root node.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.flame_chart( + labels=["main"], + parents=[None], + values=[100], + start_times=[0], + ) + + subplot = canvas._subplots[(0, 0)] + flame_data = subplot.line_data[0] + assert len(flame_data["labels"]) == 1 + assert flame_data["parents"][0] is None From 0e6e7d6f4ddc01fc51c8be363aa6437d28489290 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 23 Jul 2026 14:06:24 +0200 Subject: [PATCH 08/10] Added tutorials and gantt test --- src/maxplotlib/tests/test_gantt_chart.py | 266 +++++++++++++++ tutorials/tutorial_11_gantt_charts.ipynb | 279 ++++++++++++++++ tutorials/tutorial_12_flame_charts.ipynb | 401 +++++++++++++++++++++++ 3 files changed, 946 insertions(+) create mode 100644 src/maxplotlib/tests/test_gantt_chart.py create mode 100644 tutorials/tutorial_11_gantt_charts.ipynb create mode 100644 tutorials/tutorial_12_flame_charts.ipynb diff --git a/src/maxplotlib/tests/test_gantt_chart.py b/src/maxplotlib/tests/test_gantt_chart.py new file mode 100644 index 0000000..4f6bab6 --- /dev/null +++ b/src/maxplotlib/tests/test_gantt_chart.py @@ -0,0 +1,266 @@ +""" +Tests for gantt chart functionality across all backends. +""" + +import numpy as np +import pytest +from maxplotlib import Canvas + + +@pytest.fixture +def sample_gantt_data(): + """Sample project task data for testing.""" + return { + "tasks": ["Planning", "Design", "Development", "Testing", "Deployment"], + "start_times": [0, 5, 10, 25, 35], + "durations": [5, 5, 15, 10, 5], + } + + +def test_gantt_chart_basic(sample_gantt_data): + """Test basic gantt chart creation.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=sample_gantt_data["tasks"], + start_times=sample_gantt_data["start_times"], + durations=sample_gantt_data["durations"], + ) + + # Verify subplot was created + assert len(canvas._subplots) == 1 + subplot = canvas._subplots[(0, 0)] + + # Verify gantt chart data was added + assert len(subplot.line_data) == 1 + gantt_data = subplot.line_data[0] + assert gantt_data["plot_type"] == "gantt" + assert gantt_data["tasks"] == sample_gantt_data["tasks"] + np.testing.assert_array_equal(gantt_data["start_times"], sample_gantt_data["start_times"]) + np.testing.assert_array_equal(gantt_data["durations"], sample_gantt_data["durations"]) + + +def test_gantt_chart_with_kwargs(sample_gantt_data): + """Test gantt chart with additional kwargs.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=sample_gantt_data["tasks"], + start_times=sample_gantt_data["start_times"], + durations=sample_gantt_data["durations"], + color="steelblue", + alpha=0.7, + edgecolor="black", + label="Project Tasks", + ) + + subplot = canvas._subplots[(0, 0)] + gantt_data = subplot.line_data[0] + assert gantt_data["kwargs"]["color"] == "steelblue" + assert gantt_data["kwargs"]["alpha"] == 0.7 + assert gantt_data["kwargs"]["edgecolor"] == "black" + assert gantt_data["kwargs"]["label"] == "Project Tasks" + + +def test_gantt_chart_matplotlib_backend(sample_gantt_data, tmp_path): + """Test gantt chart rendering with matplotlib backend.""" + canvas = Canvas(nrows=1, ncols=1, figsize=(10, 6)) + canvas.gantt( + tasks=sample_gantt_data["tasks"], + start_times=sample_gantt_data["start_times"], + durations=sample_gantt_data["durations"], + color="skyblue", + edgecolor="navy", + ) + canvas.set_xlabel("Time (days)") + canvas.set_title("Project Timeline") + + output_file = tmp_path / "test_gantt_matplotlib.png" + canvas.savefig(str(output_file), backend="matplotlib") + + assert output_file.exists() + assert output_file.stat().st_size > 0 + + +def test_gantt_chart_plotly_backend(sample_gantt_data, tmp_path): + """Test gantt chart rendering with plotly backend.""" + canvas = Canvas(nrows=1, ncols=1, figsize=(10, 6)) + canvas.gantt( + tasks=sample_gantt_data["tasks"], + start_times=sample_gantt_data["start_times"], + durations=sample_gantt_data["durations"], + color="lightblue", + ) + canvas.set_xlabel("Time (days)") + canvas.set_title("Project Timeline") + + output_file = tmp_path / "test_gantt_plotly.html" + canvas.savefig(str(output_file), backend="plotly") + + assert output_file.exists() + assert output_file.stat().st_size > 0 + + +def test_gantt_chart_plotext_backend(sample_gantt_data, tmp_path): + """Test gantt chart rendering with plotext backend.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=sample_gantt_data["tasks"], + start_times=sample_gantt_data["start_times"], + durations=sample_gantt_data["durations"], + ) + canvas.set_xlabel("Time (days)") + canvas.set_title("Project Timeline") + + output_file = tmp_path / "test_gantt_plotext.txt" + canvas.savefig(str(output_file), backend="plotext") + + assert output_file.exists() + assert output_file.stat().st_size > 0 + + +def test_gantt_chart_tikzfigure_backend(sample_gantt_data, tmp_path): + """Test gantt chart rendering with tikzfigure backend.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=sample_gantt_data["tasks"], + start_times=sample_gantt_data["start_times"], + durations=sample_gantt_data["durations"], + ) + canvas.set_xlabel("Time (days)") + canvas.set_title("Project Timeline") + + output_file = tmp_path / "test_gantt_tikz.pdf" + canvas.savefig(str(output_file), backend="tikzfigure") + + assert output_file.exists() + assert output_file.stat().st_size > 0 + + +def test_gantt_chart_single_task(): + """Test gantt chart with single task.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=["Single Task"], + start_times=[0], + durations=[10], + ) + + subplot = canvas._subplots[(0, 0)] + assert len(subplot.line_data) == 1 + gantt_data = subplot.line_data[0] + assert len(gantt_data["tasks"]) == 1 + + +def test_gantt_chart_many_tasks(): + """Test gantt chart with many tasks.""" + n_tasks = 20 + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=[f"Task {i+1}" for i in range(n_tasks)], + start_times=list(range(0, n_tasks * 2, 2)), + durations=[2] * n_tasks, + ) + + subplot = canvas._subplots[(0, 0)] + gantt_data = subplot.line_data[0] + assert len(gantt_data["tasks"]) == n_tasks + + +def test_gantt_chart_overlapping_tasks(): + """Test gantt chart with overlapping tasks.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=["Task A", "Task B", "Task C"], + start_times=[0, 5, 5], # B and C start at same time + durations=[10, 8, 6], + ) + + subplot = canvas._subplots[(0, 0)] + gantt_data = subplot.line_data[0] + assert gantt_data["start_times"][1] == gantt_data["start_times"][2] + + +def test_gantt_chart_sequential_tasks(): + """Test gantt chart with sequential non-overlapping tasks.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=["Phase 1", "Phase 2", "Phase 3"], + start_times=[0, 10, 20], + durations=[10, 10, 10], + ) + + subplot = canvas._subplots[(0, 0)] + gantt_data = subplot.line_data[0] + # Verify tasks are sequential + assert gantt_data["start_times"][1] == gantt_data["start_times"][0] + gantt_data["durations"][0] + + +def test_gantt_chart_with_layers(): + """Test gantt chart with different layers.""" + canvas = Canvas(nrows=1, ncols=1) + + # Add gantt chart to layer 0 + canvas.gantt( + tasks=["Task 1", "Task 2"], + start_times=[0, 5], + durations=[5, 5], + layer=0, + color="blue", + ) + + # Add another gantt chart to layer 1 + canvas.gantt( + tasks=["Task 3", "Task 4"], + start_times=[0, 5], + durations=[5, 5], + layer=1, + color="red", + ) + + subplot = canvas._subplots[(0, 0)] + assert len(subplot.line_data) == 2 + assert subplot.line_data[0]["layer"] == 0 + assert subplot.line_data[1]["layer"] == 1 + + +def test_gantt_chart_variable_durations(): + """Test gantt chart with varying task durations.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=["Short", "Medium", "Long", "Very Long"], + start_times=[0, 2, 7, 15], + durations=[2, 5, 8, 20], + ) + + subplot = canvas._subplots[(0, 0)] + gantt_data = subplot.line_data[0] + durations = gantt_data["durations"] + assert durations[0] < durations[1] < durations[2] < durations[3] + + +def test_gantt_chart_zero_duration(): + """Test gantt chart with zero duration task (milestone).""" + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=["Start", "Milestone", "End"], + start_times=[0, 5, 5], + durations=[5, 0, 5], # Milestone has zero duration + ) + + subplot = canvas._subplots[(0, 0)] + gantt_data = subplot.line_data[0] + assert gantt_data["durations"][1] == 0 + + +def test_gantt_chart_empty_data(): + """Test gantt chart with empty data.""" + canvas = Canvas(nrows=1, ncols=1) + canvas.gantt( + tasks=[], + start_times=[], + durations=[], + ) + + subplot = canvas._subplots[(0, 0)] + gantt_data = subplot.line_data[0] + assert len(gantt_data["tasks"]) == 0 + assert len(gantt_data["durations"]) == 0 diff --git a/tutorials/tutorial_11_gantt_charts.ipynb b/tutorials/tutorial_11_gantt_charts.ipynb new file mode 100644 index 0000000..17d5652 --- /dev/null +++ b/tutorials/tutorial_11_gantt_charts.ipynb @@ -0,0 +1,279 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Tutorial 11: Gantt Charts\n", + "\n", + "This tutorial demonstrates how to create Gantt charts using maxplotlib across different backends.\n", + "\n", + "Gantt charts are useful for visualizing project schedules, task timelines, and resource allocation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from maxplotlib import Canvas\n", + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Basic Gantt Chart\n", + "\n", + "Let's create a simple project timeline with 5 tasks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Define project tasks\n", + "tasks = [\"Planning\", \"Design\", \"Development\", \"Testing\", \"Deployment\"]\n", + "start_times = [0, 5, 10, 25, 35] # Days from project start\n", + "durations = [5, 5, 15, 10, 5] # Duration in days\n", + "\n", + "# Create canvas and add gantt chart\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(12, 6))\n", + "canvas.gantt(\n", + " tasks=tasks,\n", + " start_times=start_times,\n", + " durations=durations,\n", + " color=\"skyblue\",\n", + " edgecolor=\"navy\",\n", + " alpha=0.8\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Time (days)\")\n", + "canvas.set_title(\"Project Timeline\")\n", + "canvas.plot(backend=\"matplotlib\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multiple Project Phases\n", + "\n", + "Visualize different project phases with different colors using layers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Phase 1: Planning and Design\n", + "phase1_tasks = [\"Requirements\", \"Architecture\", \"UI Design\"]\n", + "phase1_starts = [0, 3, 6]\n", + "phase1_durations = [3, 3, 4]\n", + "\n", + "# Phase 2: Implementation\n", + "phase2_tasks = [\"Backend Dev\", \"Frontend Dev\", \"Integration\"]\n", + "phase2_starts = [10, 10, 20]\n", + "phase2_durations = [10, 10, 5]\n", + "\n", + "# Phase 3: Quality Assurance\n", + "phase3_tasks = [\"Unit Tests\", \"Integration Tests\", \"UAT\"]\n", + "phase3_starts = [25, 28, 32]\n", + "phase3_durations = [3, 4, 3]\n", + "\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(14, 8))\n", + "\n", + "# Add each phase with different colors\n", + "canvas.gantt(phase1_tasks, phase1_starts, phase1_durations, \n", + " color=\"lightblue\", edgecolor=\"blue\", label=\"Planning\")\n", + "canvas.gantt(phase2_tasks, phase2_starts, phase2_durations,\n", + " color=\"lightgreen\", edgecolor=\"green\", label=\"Development\")\n", + "canvas.gantt(phase3_tasks, phase3_starts, phase3_durations,\n", + " color=\"lightyellow\", edgecolor=\"orange\", label=\"Testing\")\n", + "\n", + "canvas.set_xlabel(\"Time (days)\")\n", + "canvas.set_title(\"Multi-Phase Project Timeline\")\n", + "canvas.set_legend(True)\n", + "canvas.plot(backend=\"matplotlib\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Resource Allocation\n", + "\n", + "Show how different team members are allocated across tasks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Team member assignments\n", + "team_members = [\"Alice\", \"Bob\", \"Charlie\", \"Diana\", \"Eve\"]\n", + "task_starts = [0, 5, 3, 8, 10]\n", + "task_durations = [8, 6, 10, 7, 5]\n", + "\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(12, 6))\n", + "canvas.gantt(\n", + " tasks=team_members,\n", + " start_times=task_starts,\n", + " durations=task_durations,\n", + " color=\"coral\",\n", + " edgecolor=\"darkred\",\n", + " alpha=0.7\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Week\")\n", + "canvas.set_title(\"Team Resource Allocation\")\n", + "canvas.plot(backend=\"matplotlib\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Interactive Gantt Chart with Plotly\n", + "\n", + "Create an interactive version that allows zooming and hovering." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tasks = [\"Research\", \"Prototype\", \"Development\", \"Testing\", \"Launch\"]\n", + "start_times = [0, 10, 20, 40, 50]\n", + "durations = [10, 10, 20, 10, 5]\n", + "\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(12, 6))\n", + "canvas.gantt(\n", + " tasks=tasks,\n", + " start_times=start_times,\n", + " durations=durations,\n", + " color=\"steelblue\",\n", + " alpha=0.8\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Days\")\n", + "canvas.set_title(\"Interactive Project Timeline\")\n", + "fig = canvas.plot(backend=\"plotly\")\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Milestones and Dependencies\n", + "\n", + "Visualize project milestones (zero-duration tasks) alongside regular tasks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Regular tasks\n", + "tasks = [\"Phase 1\", \"Milestone: Review\", \"Phase 2\", \"Milestone: Demo\", \"Phase 3\"]\n", + "start_times = [0, 10, 10, 25, 25]\n", + "durations = [10, 0, 15, 0, 10] # Milestones have zero duration\n", + "\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(12, 6))\n", + "canvas.gantt(\n", + " tasks=tasks,\n", + " start_times=start_times,\n", + " durations=durations,\n", + " color=\"lightseagreen\",\n", + " edgecolor=\"teal\"\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Days\")\n", + "canvas.set_title(\"Project with Milestones\")\n", + "canvas.plot(backend=\"matplotlib\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Comparison: Different Backends\n", + "\n", + "The same Gantt chart rendered with different backends." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tasks = [\"Task A\", \"Task B\", \"Task C\", \"Task D\"]\n", + "start_times = [0, 5, 8, 12]\n", + "durations = [5, 6, 8, 4]\n", + "\n", + "# Matplotlib\n", + "canvas_mpl = Canvas(nrows=1, ncols=1, figsize=(10, 5))\n", + "canvas_mpl.gantt(tasks, start_times, durations, color=\"skyblue\")\n", + "canvas_mpl.set_title(\"Matplotlib Backend\")\n", + "canvas_mpl.plot(backend=\"matplotlib\")\n", + "\n", + "# Plotly\n", + "canvas_plotly = Canvas(nrows=1, ncols=1, figsize=(10, 5))\n", + "canvas_plotly.gantt(tasks, start_times, durations, color=\"skyblue\")\n", + "canvas_plotly.set_title(\"Plotly Backend\")\n", + "fig = canvas_plotly.plot(backend=\"plotly\")\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "Gantt charts in maxplotlib support:\n", + "- Simple task timelines\n", + "- Multiple phases with different colors\n", + "- Resource allocation visualization\n", + "- Milestones (zero-duration tasks)\n", + "- All backends: matplotlib, plotly, plotext, tikzfigure\n", + "- Customizable colors, transparency, and edge colors" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tutorials/tutorial_12_flame_charts.ipynb b/tutorials/tutorial_12_flame_charts.ipynb new file mode 100644 index 0000000..56a2311 --- /dev/null +++ b/tutorials/tutorial_12_flame_charts.ipynb @@ -0,0 +1,401 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Tutorial 12: Flame Charts\n", + "\n", + "This tutorial demonstrates how to create flame charts using maxplotlib across different backends.\n", + "\n", + "Flame charts are powerful visualizations for hierarchical profiling data, showing function call stacks and their execution times or sample counts." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from maxplotlib import Canvas\n", + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Basic Flame Chart\n", + "\n", + "Let's create a simple flame chart showing a function call hierarchy." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Define function call hierarchy\n", + "labels = [\n", + " \"main()\", # 0 - root\n", + " \"process_data()\", # 1 - child of main\n", + " \"load_file()\", # 2 - child of process_data\n", + " \"parse_json()\", # 3 - child of process_data\n", + " \"validate()\", # 4 - child of process_data\n", + "]\n", + "\n", + "# Parent indices (None for root, index for parent)\n", + "parents = [None, 0, 1, 1, 1]\n", + "\n", + "# Duration or sample count for each function\n", + "values = [100, 60, 15, 20, 25]\n", + "\n", + "# Start times (when each function begins)\n", + "start_times = [0, 0, 0, 15, 35]\n", + "\n", + "# Create canvas and add flame chart\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(12, 6))\n", + "canvas.flame_chart(\n", + " labels=labels,\n", + " parents=parents,\n", + " values=values,\n", + " start_times=start_times,\n", + " colormap=\"viridis\",\n", + " edgecolor=\"black\"\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Time (ms)\")\n", + "canvas.set_ylabel(\"Stack Depth\")\n", + "canvas.set_title(\"Function Call Hierarchy\")\n", + "canvas.plot(backend=\"matplotlib\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Complex Call Stack\n", + "\n", + "Visualize a more complex profiling scenario with multiple levels." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# More complex hierarchy\n", + "labels = [\n", + " \"main()\",\n", + " \"init()\",\n", + " \"setup_db()\",\n", + " \"connect()\",\n", + " \"authenticate()\",\n", + " \"process()\",\n", + " \"fetch_data()\",\n", + " \"query_db()\",\n", + " \"transform()\",\n", + " \"map()\",\n", + " \"reduce()\",\n", + " \"cleanup()\",\n", + "]\n", + "\n", + "parents = [\n", + " None, # main\n", + " 0, # init -> main\n", + " 1, # setup_db -> init\n", + " 2, # connect -> setup_db\n", + " 2, # authenticate -> setup_db\n", + " 0, # process -> main\n", + " 5, # fetch_data -> process\n", + " 6, # query_db -> fetch_data\n", + " 5, # transform -> process\n", + " 8, # map -> transform\n", + " 8, # reduce -> transform\n", + " 0, # cleanup -> main\n", + "]\n", + "\n", + "values = [200, 40, 30, 15, 15, 120, 50, 40, 70, 35, 35, 20]\n", + "start_times = [0, 0, 0, 0, 15, 40, 40, 40, 90, 90, 125, 180]\n", + "\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(14, 8))\n", + "canvas.flame_chart(\n", + " labels=labels,\n", + " parents=parents,\n", + " values=values,\n", + " start_times=start_times,\n", + " colormap=\"plasma\",\n", + " edgecolor=\"black\"\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Time (ms)\")\n", + "canvas.set_ylabel(\"Stack Depth\")\n", + "canvas.set_title(\"Complex Application Profiling\")\n", + "canvas.plot(backend=\"matplotlib\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## CPU Profiling Simulation\n", + "\n", + "Simulate CPU profiling data with multiple parallel execution paths." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Simulate CPU profiling with parallel tasks\n", + "labels = [\n", + " \"app_main\",\n", + " \"worker_1\",\n", + " \"compute_heavy\",\n", + " \"math_ops\",\n", + " \"worker_2\",\n", + " \"io_bound\",\n", + " \"read_file\",\n", + " \"worker_3\",\n", + " \"network_call\",\n", + " \"http_request\",\n", + "]\n", + "\n", + "parents = [None, 0, 1, 2, 0, 4, 5, 0, 7, 8]\n", + "values = [150, 50, 40, 30, 45, 35, 25, 55, 45, 35]\n", + "start_times = [0, 0, 0, 5, 50, 50, 55, 95, 95, 100]\n", + "\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(12, 6))\n", + "canvas.flame_chart(\n", + " labels=labels,\n", + " parents=parents,\n", + " values=values,\n", + " start_times=start_times,\n", + " colormap=\"inferno\",\n", + " edgecolor=\"darkred\"\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Time (ms)\")\n", + "canvas.set_ylabel(\"Call Stack Depth\")\n", + "canvas.set_title(\"CPU Profiling: Parallel Workers\")\n", + "canvas.plot(backend=\"matplotlib\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Interactive Flame Chart with Plotly\n", + "\n", + "Create an interactive version for detailed exploration." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "labels = [\n", + " \"main()\",\n", + " \"process_data()\",\n", + " \"load_file()\",\n", + " \"parse_json()\",\n", + " \"validate()\",\n", + " \"compute()\",\n", + " \"algorithm_a()\",\n", + " \"algorithm_b()\",\n", + " \"save_results()\",\n", + "]\n", + "\n", + "parents = [None, 0, 1, 1, 1, 0, 5, 5, 0]\n", + "values = [100, 40, 10, 15, 15, 50, 25, 25, 10]\n", + "start_times = [0, 0, 0, 10, 25, 40, 40, 65, 90]\n", + "\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(12, 6))\n", + "canvas.flame_chart(\n", + " labels=labels,\n", + " parents=parents,\n", + " values=values,\n", + " start_times=start_times,\n", + " colormap=\"Viridis\",\n", + " edgecolor=\"black\"\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Time (ms)\")\n", + "canvas.set_ylabel(\"Stack Depth\")\n", + "canvas.set_title(\"Interactive Flame Chart - Hover for Details\")\n", + "fig = canvas.plot(backend=\"plotly\")\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Auto-computed Start Times\n", + "\n", + "Let maxplotlib compute start times automatically from the hierarchy." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# When start_times=None, they are computed automatically\n", + "labels = [\"root\", \"child1\", \"child2\", \"grandchild1\", \"grandchild2\"]\n", + "parents = [None, 0, 0, 1, 1]\n", + "values = [100, 50, 50, 25, 25]\n", + "\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(10, 5))\n", + "canvas.flame_chart(\n", + " labels=labels,\n", + " parents=parents,\n", + " values=values,\n", + " start_times=None, # Auto-computed\n", + " colormap=\"coolwarm\"\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Relative Time\")\n", + "canvas.set_ylabel(\"Depth\")\n", + "canvas.set_title(\"Flame Chart with Auto-computed Start Times\")\n", + "canvas.plot(backend=\"matplotlib\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Comparison: Different Colormaps\n", + "\n", + "Explore different colormap options for flame charts." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "labels = [\"main\", \"func1\", \"func2\", \"func3\", \"func4\"]\n", + "parents = [None, 0, 1, 1, 0]\n", + "values = [100, 60, 30, 30, 40]\n", + "start_times = [0, 0, 0, 30, 60]\n", + "\n", + "colormaps = [\"viridis\", \"plasma\", \"inferno\", \"magma\"]\n", + "\n", + "for cmap in colormaps:\n", + " canvas = Canvas(nrows=1, ncols=1, figsize=(10, 4))\n", + " canvas.flame_chart(\n", + " labels=labels,\n", + " parents=parents,\n", + " values=values,\n", + " start_times=start_times,\n", + " colormap=cmap,\n", + " edgecolor=\"black\"\n", + " )\n", + " canvas.set_title(f\"Colormap: {cmap}\")\n", + " canvas.plot(backend=\"matplotlib\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Real-world Example: Web Request Processing\n", + "\n", + "Visualize the processing of a web request through various layers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "labels = [\n", + " \"handle_request\",\n", + " \"authenticate\",\n", + " \"check_token\",\n", + " \"verify_signature\",\n", + " \"route_handler\",\n", + " \"validate_input\",\n", + " \"business_logic\",\n", + " \"db_query\",\n", + " \"cache_check\",\n", + " \"serialize_response\",\n", + " \"json_encode\",\n", + " \"send_response\",\n", + "]\n", + "\n", + "parents = [None, 0, 1, 2, 0, 4, 4, 6, 6, 0, 9, 0]\n", + "values = [200, 30, 20, 15, 140, 15, 100, 50, 30, 20, 15, 10]\n", + "start_times = [0, 0, 5, 10, 30, 30, 45, 45, 95, 170, 170, 190]\n", + "\n", + "canvas = Canvas(nrows=1, ncols=1, figsize=(14, 7))\n", + "canvas.flame_chart(\n", + " labels=labels,\n", + " parents=parents,\n", + " values=values,\n", + " start_times=start_times,\n", + " colormap=\"RdYlGn_r\",\n", + " edgecolor=\"black\"\n", + ")\n", + "\n", + "canvas.set_xlabel(\"Time (ms)\")\n", + "canvas.set_ylabel(\"Call Stack\")\n", + "canvas.set_title(\"Web Request Processing Profile\")\n", + "canvas.plot(backend=\"matplotlib\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "Flame charts in maxplotlib support:\n", + "- Hierarchical profiling data visualization\n", + "- Automatic depth calculation from parent relationships\n", + "- Optional auto-computed start times\n", + "- Multiple colormap options\n", + "- All backends: matplotlib, plotly, plotext, tikzfigure\n", + "- Interactive exploration with plotly backend\n", + "- Customizable colors and edge styling\n", + "\n", + "Perfect for:\n", + "- CPU profiling analysis\n", + "- Function call stack visualization\n", + "- Performance bottleneck identification\n", + "- Execution time analysis" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From 147e906d2ba61c0accab612e850ee85c03fee567 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 23 Jul 2026 14:06:54 +0200 Subject: [PATCH 09/10] Formatting --- src/maxplotlib/tests/test_flame_chart.py | 41 ++++++++--------- src/maxplotlib/tests/test_gantt_chart.py | 56 ++++++++++++++---------- tutorials/tutorial_11_gantt_charts.ipynb | 40 ++++++++++++----- tutorials/tutorial_12_flame_charts.ipynb | 46 +++++++++---------- 4 files changed, 105 insertions(+), 78 deletions(-) diff --git a/src/maxplotlib/tests/test_flame_chart.py b/src/maxplotlib/tests/test_flame_chart.py index 1751c65..64c5e91 100644 --- a/src/maxplotlib/tests/test_flame_chart.py +++ b/src/maxplotlib/tests/test_flame_chart.py @@ -4,6 +4,7 @@ import numpy as np import pytest + from maxplotlib import Canvas @@ -37,11 +38,11 @@ def test_flame_chart_basic(sample_flame_data): values=sample_flame_data["values"], start_times=sample_flame_data["start_times"], ) - + # Verify subplot was created assert len(canvas._subplots) == 1 subplot = canvas._subplots[(0, 0)] - + # Verify flame chart data was added assert len(subplot.line_data) == 1 flame_data = subplot.line_data[0] @@ -60,7 +61,7 @@ def test_flame_chart_without_start_times(sample_flame_data): values=sample_flame_data["values"], start_times=None, # Should be auto-computed ) - + subplot = canvas._subplots[(0, 0)] flame_data = subplot.line_data[0] assert flame_data["start_times"] is None @@ -78,7 +79,7 @@ def test_flame_chart_with_kwargs(sample_flame_data): edgecolor="red", label="Test Flame", ) - + subplot = canvas._subplots[(0, 0)] flame_data = subplot.line_data[0] assert flame_data["kwargs"]["colormap"] == "plasma" @@ -99,10 +100,10 @@ def test_flame_chart_matplotlib_backend(sample_flame_data, tmp_path): canvas.set_xlabel("Time (ms)") canvas.set_ylabel("Stack Depth") canvas.set_title("Test Flame Chart") - + output_file = tmp_path / "test_flame_matplotlib.png" canvas.savefig(str(output_file), backend="matplotlib") - + assert output_file.exists() assert output_file.stat().st_size > 0 @@ -120,10 +121,10 @@ def test_flame_chart_plotly_backend(sample_flame_data, tmp_path): canvas.set_xlabel("Time (ms)") canvas.set_ylabel("Stack Depth") canvas.set_title("Test Flame Chart") - + output_file = tmp_path / "test_flame_plotly.html" canvas.savefig(str(output_file), backend="plotly") - + assert output_file.exists() assert output_file.stat().st_size > 0 @@ -140,10 +141,10 @@ def test_flame_chart_plotext_backend(sample_flame_data, tmp_path): canvas.set_xlabel("Time (ms)") canvas.set_ylabel("Stack Depth") canvas.set_title("Test Flame Chart") - + output_file = tmp_path / "test_flame_plotext.txt" canvas.savefig(str(output_file), backend="plotext") - + assert output_file.exists() assert output_file.stat().st_size > 0 @@ -160,10 +161,10 @@ def test_flame_chart_tikzfigure_backend(sample_flame_data, tmp_path): canvas.set_xlabel("Time (ms)") canvas.set_ylabel("Stack Depth") canvas.set_title("Test Flame Chart") - + output_file = tmp_path / "test_flame_tikz.pdf" canvas.savefig(str(output_file), backend="tikzfigure") - + assert output_file.exists() assert output_file.stat().st_size > 0 @@ -177,7 +178,7 @@ def test_flame_chart_simple_hierarchy(): values=[100, 50, 50], start_times=[0, 0, 50], ) - + subplot = canvas._subplots[(0, 0)] assert len(subplot.line_data) == 1 flame_data = subplot.line_data[0] @@ -193,7 +194,7 @@ def test_flame_chart_deep_hierarchy(): values=[100, 80, 60, 40], start_times=[0, 0, 0, 0], ) - + subplot = canvas._subplots[(0, 0)] flame_data = subplot.line_data[0] assert len(flame_data["labels"]) == 4 @@ -208,7 +209,7 @@ def test_flame_chart_multiple_roots(): values=[50, 50, 30, 30], start_times=[0, 50, 0, 50], ) - + subplot = canvas._subplots[(0, 0)] flame_data = subplot.line_data[0] assert flame_data["parents"].count(None) == 2 @@ -217,7 +218,7 @@ def test_flame_chart_multiple_roots(): def test_flame_chart_with_layers(): """Test flame chart with different layers.""" canvas = Canvas(nrows=1, ncols=1) - + # Add flame chart to layer 0 canvas.flame_chart( labels=["func1", "func2"], @@ -225,7 +226,7 @@ def test_flame_chart_with_layers(): values=[100, 50], layer=0, ) - + # Add another flame chart to layer 1 canvas.flame_chart( labels=["func3", "func4"], @@ -233,7 +234,7 @@ def test_flame_chart_with_layers(): values=[80, 40], layer=1, ) - + subplot = canvas._subplots[(0, 0)] assert len(subplot.line_data) == 2 assert subplot.line_data[0]["layer"] == 0 @@ -249,7 +250,7 @@ def test_flame_chart_empty_data(): values=[], start_times=[], ) - + subplot = canvas._subplots[(0, 0)] flame_data = subplot.line_data[0] assert len(flame_data["labels"]) == 0 @@ -265,7 +266,7 @@ def test_flame_chart_single_node(): values=[100], start_times=[0], ) - + subplot = canvas._subplots[(0, 0)] flame_data = subplot.line_data[0] assert len(flame_data["labels"]) == 1 diff --git a/src/maxplotlib/tests/test_gantt_chart.py b/src/maxplotlib/tests/test_gantt_chart.py index 4f6bab6..66e24fd 100644 --- a/src/maxplotlib/tests/test_gantt_chart.py +++ b/src/maxplotlib/tests/test_gantt_chart.py @@ -4,6 +4,7 @@ import numpy as np import pytest + from maxplotlib import Canvas @@ -25,18 +26,22 @@ def test_gantt_chart_basic(sample_gantt_data): start_times=sample_gantt_data["start_times"], durations=sample_gantt_data["durations"], ) - + # Verify subplot was created assert len(canvas._subplots) == 1 subplot = canvas._subplots[(0, 0)] - + # Verify gantt chart data was added assert len(subplot.line_data) == 1 gantt_data = subplot.line_data[0] assert gantt_data["plot_type"] == "gantt" assert gantt_data["tasks"] == sample_gantt_data["tasks"] - np.testing.assert_array_equal(gantt_data["start_times"], sample_gantt_data["start_times"]) - np.testing.assert_array_equal(gantt_data["durations"], sample_gantt_data["durations"]) + np.testing.assert_array_equal( + gantt_data["start_times"], sample_gantt_data["start_times"] + ) + np.testing.assert_array_equal( + gantt_data["durations"], sample_gantt_data["durations"] + ) def test_gantt_chart_with_kwargs(sample_gantt_data): @@ -51,7 +56,7 @@ def test_gantt_chart_with_kwargs(sample_gantt_data): edgecolor="black", label="Project Tasks", ) - + subplot = canvas._subplots[(0, 0)] gantt_data = subplot.line_data[0] assert gantt_data["kwargs"]["color"] == "steelblue" @@ -72,10 +77,10 @@ def test_gantt_chart_matplotlib_backend(sample_gantt_data, tmp_path): ) canvas.set_xlabel("Time (days)") canvas.set_title("Project Timeline") - + output_file = tmp_path / "test_gantt_matplotlib.png" canvas.savefig(str(output_file), backend="matplotlib") - + assert output_file.exists() assert output_file.stat().st_size > 0 @@ -91,10 +96,10 @@ def test_gantt_chart_plotly_backend(sample_gantt_data, tmp_path): ) canvas.set_xlabel("Time (days)") canvas.set_title("Project Timeline") - + output_file = tmp_path / "test_gantt_plotly.html" canvas.savefig(str(output_file), backend="plotly") - + assert output_file.exists() assert output_file.stat().st_size > 0 @@ -109,10 +114,10 @@ def test_gantt_chart_plotext_backend(sample_gantt_data, tmp_path): ) canvas.set_xlabel("Time (days)") canvas.set_title("Project Timeline") - + output_file = tmp_path / "test_gantt_plotext.txt" canvas.savefig(str(output_file), backend="plotext") - + assert output_file.exists() assert output_file.stat().st_size > 0 @@ -127,10 +132,10 @@ def test_gantt_chart_tikzfigure_backend(sample_gantt_data, tmp_path): ) canvas.set_xlabel("Time (days)") canvas.set_title("Project Timeline") - + output_file = tmp_path / "test_gantt_tikz.pdf" canvas.savefig(str(output_file), backend="tikzfigure") - + assert output_file.exists() assert output_file.stat().st_size > 0 @@ -143,7 +148,7 @@ def test_gantt_chart_single_task(): start_times=[0], durations=[10], ) - + subplot = canvas._subplots[(0, 0)] assert len(subplot.line_data) == 1 gantt_data = subplot.line_data[0] @@ -159,7 +164,7 @@ def test_gantt_chart_many_tasks(): start_times=list(range(0, n_tasks * 2, 2)), durations=[2] * n_tasks, ) - + subplot = canvas._subplots[(0, 0)] gantt_data = subplot.line_data[0] assert len(gantt_data["tasks"]) == n_tasks @@ -173,7 +178,7 @@ def test_gantt_chart_overlapping_tasks(): start_times=[0, 5, 5], # B and C start at same time durations=[10, 8, 6], ) - + subplot = canvas._subplots[(0, 0)] gantt_data = subplot.line_data[0] assert gantt_data["start_times"][1] == gantt_data["start_times"][2] @@ -187,17 +192,20 @@ def test_gantt_chart_sequential_tasks(): start_times=[0, 10, 20], durations=[10, 10, 10], ) - + subplot = canvas._subplots[(0, 0)] gantt_data = subplot.line_data[0] # Verify tasks are sequential - assert gantt_data["start_times"][1] == gantt_data["start_times"][0] + gantt_data["durations"][0] + assert ( + gantt_data["start_times"][1] + == gantt_data["start_times"][0] + gantt_data["durations"][0] + ) def test_gantt_chart_with_layers(): """Test gantt chart with different layers.""" canvas = Canvas(nrows=1, ncols=1) - + # Add gantt chart to layer 0 canvas.gantt( tasks=["Task 1", "Task 2"], @@ -206,7 +214,7 @@ def test_gantt_chart_with_layers(): layer=0, color="blue", ) - + # Add another gantt chart to layer 1 canvas.gantt( tasks=["Task 3", "Task 4"], @@ -215,7 +223,7 @@ def test_gantt_chart_with_layers(): layer=1, color="red", ) - + subplot = canvas._subplots[(0, 0)] assert len(subplot.line_data) == 2 assert subplot.line_data[0]["layer"] == 0 @@ -230,7 +238,7 @@ def test_gantt_chart_variable_durations(): start_times=[0, 2, 7, 15], durations=[2, 5, 8, 20], ) - + subplot = canvas._subplots[(0, 0)] gantt_data = subplot.line_data[0] durations = gantt_data["durations"] @@ -245,7 +253,7 @@ def test_gantt_chart_zero_duration(): start_times=[0, 5, 5], durations=[5, 0, 5], # Milestone has zero duration ) - + subplot = canvas._subplots[(0, 0)] gantt_data = subplot.line_data[0] assert gantt_data["durations"][1] == 0 @@ -259,7 +267,7 @@ def test_gantt_chart_empty_data(): start_times=[], durations=[], ) - + subplot = canvas._subplots[(0, 0)] gantt_data = subplot.line_data[0] assert len(gantt_data["tasks"]) == 0 diff --git a/tutorials/tutorial_11_gantt_charts.ipynb b/tutorials/tutorial_11_gantt_charts.ipynb index 17d5652..cc90229 100644 --- a/tutorials/tutorial_11_gantt_charts.ipynb +++ b/tutorials/tutorial_11_gantt_charts.ipynb @@ -39,7 +39,7 @@ "# Define project tasks\n", "tasks = [\"Planning\", \"Design\", \"Development\", \"Testing\", \"Deployment\"]\n", "start_times = [0, 5, 10, 25, 35] # Days from project start\n", - "durations = [5, 5, 15, 10, 5] # Duration in days\n", + "durations = [5, 5, 15, 10, 5] # Duration in days\n", "\n", "# Create canvas and add gantt chart\n", "canvas = Canvas(nrows=1, ncols=1, figsize=(12, 6))\n", @@ -49,7 +49,7 @@ " durations=durations,\n", " color=\"skyblue\",\n", " edgecolor=\"navy\",\n", - " alpha=0.8\n", + " alpha=0.8,\n", ")\n", "\n", "canvas.set_xlabel(\"Time (days)\")\n", @@ -90,12 +90,30 @@ "canvas = Canvas(nrows=1, ncols=1, figsize=(14, 8))\n", "\n", "# Add each phase with different colors\n", - "canvas.gantt(phase1_tasks, phase1_starts, phase1_durations, \n", - " color=\"lightblue\", edgecolor=\"blue\", label=\"Planning\")\n", - "canvas.gantt(phase2_tasks, phase2_starts, phase2_durations,\n", - " color=\"lightgreen\", edgecolor=\"green\", label=\"Development\")\n", - "canvas.gantt(phase3_tasks, phase3_starts, phase3_durations,\n", - " color=\"lightyellow\", edgecolor=\"orange\", label=\"Testing\")\n", + "canvas.gantt(\n", + " phase1_tasks,\n", + " phase1_starts,\n", + " phase1_durations,\n", + " color=\"lightblue\",\n", + " edgecolor=\"blue\",\n", + " label=\"Planning\",\n", + ")\n", + "canvas.gantt(\n", + " phase2_tasks,\n", + " phase2_starts,\n", + " phase2_durations,\n", + " color=\"lightgreen\",\n", + " edgecolor=\"green\",\n", + " label=\"Development\",\n", + ")\n", + "canvas.gantt(\n", + " phase3_tasks,\n", + " phase3_starts,\n", + " phase3_durations,\n", + " color=\"lightyellow\",\n", + " edgecolor=\"orange\",\n", + " label=\"Testing\",\n", + ")\n", "\n", "canvas.set_xlabel(\"Time (days)\")\n", "canvas.set_title(\"Multi-Phase Project Timeline\")\n", @@ -130,7 +148,7 @@ " durations=task_durations,\n", " color=\"coral\",\n", " edgecolor=\"darkred\",\n", - " alpha=0.7\n", + " alpha=0.7,\n", ")\n", "\n", "canvas.set_xlabel(\"Week\")\n", @@ -163,7 +181,7 @@ " start_times=start_times,\n", " durations=durations,\n", " color=\"steelblue\",\n", - " alpha=0.8\n", + " alpha=0.8,\n", ")\n", "\n", "canvas.set_xlabel(\"Days\")\n", @@ -198,7 +216,7 @@ " start_times=start_times,\n", " durations=durations,\n", " color=\"lightseagreen\",\n", - " edgecolor=\"teal\"\n", + " edgecolor=\"teal\",\n", ")\n", "\n", "canvas.set_xlabel(\"Days\")\n", diff --git a/tutorials/tutorial_12_flame_charts.ipynb b/tutorials/tutorial_12_flame_charts.ipynb index 56a2311..9c35492 100644 --- a/tutorials/tutorial_12_flame_charts.ipynb +++ b/tutorials/tutorial_12_flame_charts.ipynb @@ -38,11 +38,11 @@ "source": [ "# Define function call hierarchy\n", "labels = [\n", - " \"main()\", # 0 - root\n", - " \"process_data()\", # 1 - child of main\n", - " \"load_file()\", # 2 - child of process_data\n", - " \"parse_json()\", # 3 - child of process_data\n", - " \"validate()\", # 4 - child of process_data\n", + " \"main()\", # 0 - root\n", + " \"process_data()\", # 1 - child of main\n", + " \"load_file()\", # 2 - child of process_data\n", + " \"parse_json()\", # 3 - child of process_data\n", + " \"validate()\", # 4 - child of process_data\n", "]\n", "\n", "# Parent indices (None for root, index for parent)\n", @@ -62,7 +62,7 @@ " values=values,\n", " start_times=start_times,\n", " colormap=\"viridis\",\n", - " edgecolor=\"black\"\n", + " edgecolor=\"black\",\n", ")\n", "\n", "canvas.set_xlabel(\"Time (ms)\")\n", @@ -104,17 +104,17 @@ "\n", "parents = [\n", " None, # main\n", - " 0, # init -> main\n", - " 1, # setup_db -> init\n", - " 2, # connect -> setup_db\n", - " 2, # authenticate -> setup_db\n", - " 0, # process -> main\n", - " 5, # fetch_data -> process\n", - " 6, # query_db -> fetch_data\n", - " 5, # transform -> process\n", - " 8, # map -> transform\n", - " 8, # reduce -> transform\n", - " 0, # cleanup -> main\n", + " 0, # init -> main\n", + " 1, # setup_db -> init\n", + " 2, # connect -> setup_db\n", + " 2, # authenticate -> setup_db\n", + " 0, # process -> main\n", + " 5, # fetch_data -> process\n", + " 6, # query_db -> fetch_data\n", + " 5, # transform -> process\n", + " 8, # map -> transform\n", + " 8, # reduce -> transform\n", + " 0, # cleanup -> main\n", "]\n", "\n", "values = [200, 40, 30, 15, 15, 120, 50, 40, 70, 35, 35, 20]\n", @@ -127,7 +127,7 @@ " values=values,\n", " start_times=start_times,\n", " colormap=\"plasma\",\n", - " edgecolor=\"black\"\n", + " edgecolor=\"black\",\n", ")\n", "\n", "canvas.set_xlabel(\"Time (ms)\")\n", @@ -176,7 +176,7 @@ " values=values,\n", " start_times=start_times,\n", " colormap=\"inferno\",\n", - " edgecolor=\"darkred\"\n", + " edgecolor=\"darkred\",\n", ")\n", "\n", "canvas.set_xlabel(\"Time (ms)\")\n", @@ -223,7 +223,7 @@ " values=values,\n", " start_times=start_times,\n", " colormap=\"Viridis\",\n", - " edgecolor=\"black\"\n", + " edgecolor=\"black\",\n", ")\n", "\n", "canvas.set_xlabel(\"Time (ms)\")\n", @@ -259,7 +259,7 @@ " parents=parents,\n", " values=values,\n", " start_times=None, # Auto-computed\n", - " colormap=\"coolwarm\"\n", + " colormap=\"coolwarm\",\n", ")\n", "\n", "canvas.set_xlabel(\"Relative Time\")\n", @@ -298,7 +298,7 @@ " values=values,\n", " start_times=start_times,\n", " colormap=cmap,\n", - " edgecolor=\"black\"\n", + " edgecolor=\"black\",\n", " )\n", " canvas.set_title(f\"Colormap: {cmap}\")\n", " canvas.plot(backend=\"matplotlib\")" @@ -345,7 +345,7 @@ " values=values,\n", " start_times=start_times,\n", " colormap=\"RdYlGn_r\",\n", - " edgecolor=\"black\"\n", + " edgecolor=\"black\",\n", ")\n", "\n", "canvas.set_xlabel(\"Time (ms)\")\n", From a9e3df5f0d95dbae1e26a4d819d442547907c878 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Thu, 23 Jul 2026 14:08:57 +0200 Subject: [PATCH 10/10] Updated version number --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d1c1c03..0e286a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "maxplotlibx" -version = "0.1.4" +version = "0.1.5" description = "A reproducible plotting module with various backends and export options." readme = "README.md" requires-python = ">=3.8"