From 3cbfa4a56edf7a96846644c1e04b43f25d3183e6 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 23 Jul 2026 14:14:19 +0200 Subject: [PATCH] Add gantt/fire charts (#49) * Improve barplots (#48) * Improved barplots * Formatting Added Gantt chart Support tikz * Add gantt charts with all backends * Formatting * Added flame_chart * Formatting * Added examples * Added src/maxplotlib/tests/test_flame_chart.py * Added tutorials and gantt test * Formatting * Updated version number --- examples/flame_matplotlib.py | 62 ++++ examples/flame_plotext.py | 58 ++++ examples/flame_plotly.py | 60 ++++ examples/flame_tikz.py | 57 ++++ examples/gantt_matplotlib.py | 50 +++ examples/gantt_plotext.py | 48 +++ examples/gantt_plotly.py | 49 +++ examples/gantt_tikz.py | 48 +++ pyproject.toml | 2 +- src/maxplotlib/canvas/canvas.py | 102 ++++++ src/maxplotlib/subfigure/line_plot.py | 338 +++++++++++++++++++ src/maxplotlib/tests/test_flame_chart.py | 273 +++++++++++++++ src/maxplotlib/tests/test_gantt_chart.py | 274 ++++++++++++++++ tutorials/tutorial_11_gantt_charts.ipynb | 297 +++++++++++++++++ tutorials/tutorial_12_flame_charts.ipynb | 401 +++++++++++++++++++++++ 15 files changed, 2118 insertions(+), 1 deletion(-) 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_matplotlib.py create mode 100644 examples/gantt_plotext.py create mode 100644 examples/gantt_plotly.py create mode 100644 examples/gantt_tikz.py create mode 100644 src/maxplotlib/tests/test_flame_chart.py 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/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_matplotlib.py b/examples/gantt_matplotlib.py new file mode 100644 index 0000000..3490d96 --- /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..6dafd28 --- /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..8dd6312 --- /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/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() 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" diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index a321776..e224614 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -430,6 +430,58 @@ 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 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) @@ -840,6 +892,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, @@ -1095,6 +1156,47 @@ 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: diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 44779d4..4a5a49d 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -185,6 +185,52 @@ 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 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. @@ -463,6 +509,77 @@ 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"] == "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, @@ -560,6 +677,86 @@ 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"], + ) + 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()) @@ -693,6 +890,84 @@ 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 == "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"]) @@ -1458,6 +1733,69 @@ def plot_plotext(self, ax, layers=None): transformed_heights.tolist(), **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 == "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() diff --git a/src/maxplotlib/tests/test_flame_chart.py b/src/maxplotlib/tests/test_flame_chart.py new file mode 100644 index 0000000..64c5e91 --- /dev/null +++ b/src/maxplotlib/tests/test_flame_chart.py @@ -0,0 +1,273 @@ +""" +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 diff --git a/src/maxplotlib/tests/test_gantt_chart.py b/src/maxplotlib/tests/test_gantt_chart.py new file mode 100644 index 0000000..66e24fd --- /dev/null +++ b/src/maxplotlib/tests/test_gantt_chart.py @@ -0,0 +1,274 @@ +""" +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..cc90229 --- /dev/null +++ b/tutorials/tutorial_11_gantt_charts.ipynb @@ -0,0 +1,297 @@ +{ + "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(\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", + "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..9c35492 --- /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 +}