maxplotlibx 0.1.3__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- maxplotlib/canvas/canvas.py +488 -176
- maxplotlib/colors/colors.py +11 -10
- maxplotlib/linestyle/linestyle.py +11 -10
- maxplotlib/subfigure/line_plot.py +405 -59
- maxplotlib/utils/options.py +1 -1
- {maxplotlibx-0.1.3.dist-info → maxplotlibx-0.1.4.dist-info}/METADATA +3 -3
- {maxplotlibx-0.1.3.dist-info → maxplotlibx-0.1.4.dist-info}/RECORD +10 -10
- {maxplotlibx-0.1.3.dist-info → maxplotlibx-0.1.4.dist-info}/WHEEL +1 -1
- {maxplotlibx-0.1.3.dist-info → maxplotlibx-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {maxplotlibx-0.1.3.dist-info → maxplotlibx-0.1.4.dist-info}/top_level.txt +0 -0
maxplotlib/canvas/canvas.py
CHANGED
|
@@ -5,7 +5,7 @@ from typing import Dict
|
|
|
5
5
|
import matplotlib.patches as patches
|
|
6
6
|
import matplotlib.pyplot as plt
|
|
7
7
|
from plotly.subplots import make_subplots
|
|
8
|
-
from
|
|
8
|
+
from tikzfigure import TikzFigure
|
|
9
9
|
|
|
10
10
|
from maxplotlib.backends.matplotlib.utils import (
|
|
11
11
|
set_size,
|
|
@@ -18,6 +18,141 @@ from maxplotlib.subfigure.line_plot import LinePlot
|
|
|
18
18
|
from maxplotlib.utils.options import Backends
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def plot_matplotlib(tikzfigure: TikzFigure, ax, layers=None):
|
|
22
|
+
"""
|
|
23
|
+
Plot all nodes and paths on the provided axis using Matplotlib.
|
|
24
|
+
|
|
25
|
+
Parameters:
|
|
26
|
+
- ax (matplotlib.axes.Axes): Axis on which to plot the figure.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# TODO: Specify which layers to retreive nodes from with layers=layers
|
|
30
|
+
nodes = tikzfigure.layers.get_nodes()
|
|
31
|
+
paths = tikzfigure.layers.get_paths()
|
|
32
|
+
|
|
33
|
+
for path in paths:
|
|
34
|
+
x_coords = [node.x for node in path.nodes]
|
|
35
|
+
y_coords = [node.y for node in path.nodes]
|
|
36
|
+
|
|
37
|
+
# Parse path color
|
|
38
|
+
path_color_spec = path.kwargs.get("color", "black")
|
|
39
|
+
try:
|
|
40
|
+
color = Color(path_color_spec).to_rgb()
|
|
41
|
+
except ValueError as e:
|
|
42
|
+
print(e)
|
|
43
|
+
color = "black"
|
|
44
|
+
|
|
45
|
+
# Parse line width
|
|
46
|
+
line_width_spec = path.kwargs.get("line_width", 1)
|
|
47
|
+
if isinstance(line_width_spec, str):
|
|
48
|
+
match = re.match(r"([\d.]+)(pt)?", line_width_spec)
|
|
49
|
+
if match:
|
|
50
|
+
line_width = float(match.group(1))
|
|
51
|
+
else:
|
|
52
|
+
print(
|
|
53
|
+
f"Invalid line width specification: '{line_width_spec}', defaulting to 1",
|
|
54
|
+
)
|
|
55
|
+
line_width = 1
|
|
56
|
+
else:
|
|
57
|
+
line_width = float(line_width_spec)
|
|
58
|
+
|
|
59
|
+
# Parse line style using Linestyle class
|
|
60
|
+
style_spec = path.kwargs.get("style", "solid")
|
|
61
|
+
linestyle = Linestyle(style_spec).to_matplotlib()
|
|
62
|
+
|
|
63
|
+
ax.plot(
|
|
64
|
+
x_coords,
|
|
65
|
+
y_coords,
|
|
66
|
+
color=color,
|
|
67
|
+
linewidth=line_width,
|
|
68
|
+
linestyle=linestyle,
|
|
69
|
+
zorder=1, # Lower z-order to place behind nodes
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Plot nodes after paths so they appear on top
|
|
73
|
+
for node in nodes:
|
|
74
|
+
# Determine shape and size
|
|
75
|
+
shape = node.kwargs.get("shape", "circle")
|
|
76
|
+
fill_color_spec = node.kwargs.get("fill", "white")
|
|
77
|
+
edge_color_spec = node.kwargs.get("draw", "black")
|
|
78
|
+
linewidth = float(node.kwargs.get("line_width", 1))
|
|
79
|
+
size = float(node.kwargs.get("size", 1))
|
|
80
|
+
|
|
81
|
+
# Parse colors using the Color class
|
|
82
|
+
try:
|
|
83
|
+
facecolor = Color(fill_color_spec).to_rgb()
|
|
84
|
+
except ValueError as e:
|
|
85
|
+
print(e)
|
|
86
|
+
facecolor = "white"
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
edgecolor = Color(edge_color_spec).to_rgb()
|
|
90
|
+
except ValueError as e:
|
|
91
|
+
print(e)
|
|
92
|
+
edgecolor = "black"
|
|
93
|
+
|
|
94
|
+
# Plot shapes
|
|
95
|
+
if shape == "circle":
|
|
96
|
+
radius = size / 2
|
|
97
|
+
circle = patches.Circle(
|
|
98
|
+
(node.x, node.y),
|
|
99
|
+
radius,
|
|
100
|
+
facecolor=facecolor,
|
|
101
|
+
edgecolor=edgecolor,
|
|
102
|
+
linewidth=linewidth,
|
|
103
|
+
zorder=2, # Higher z-order to place on top of paths
|
|
104
|
+
)
|
|
105
|
+
ax.add_patch(circle)
|
|
106
|
+
elif shape == "rectangle":
|
|
107
|
+
width = height = size
|
|
108
|
+
rect = patches.Rectangle(
|
|
109
|
+
(node.x - width / 2, node.y - height / 2),
|
|
110
|
+
width,
|
|
111
|
+
height,
|
|
112
|
+
facecolor=facecolor,
|
|
113
|
+
edgecolor=edgecolor,
|
|
114
|
+
linewidth=linewidth,
|
|
115
|
+
zorder=2, # Higher z-order
|
|
116
|
+
)
|
|
117
|
+
ax.add_patch(rect)
|
|
118
|
+
else:
|
|
119
|
+
# Default to circle if shape is unknown
|
|
120
|
+
radius = size / 2
|
|
121
|
+
circle = patches.Circle(
|
|
122
|
+
(node.x, node.y),
|
|
123
|
+
radius,
|
|
124
|
+
facecolor=facecolor,
|
|
125
|
+
edgecolor=edgecolor,
|
|
126
|
+
linewidth=linewidth,
|
|
127
|
+
zorder=2,
|
|
128
|
+
)
|
|
129
|
+
ax.add_patch(circle)
|
|
130
|
+
|
|
131
|
+
# Add text inside the shape
|
|
132
|
+
if node.content:
|
|
133
|
+
ax.text(
|
|
134
|
+
node.x,
|
|
135
|
+
node.y,
|
|
136
|
+
node.content,
|
|
137
|
+
fontsize=10,
|
|
138
|
+
ha="center",
|
|
139
|
+
va="center",
|
|
140
|
+
wrap=True,
|
|
141
|
+
zorder=3, # Even higher z-order for text
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Remove axes, ticks, and legend
|
|
145
|
+
ax.axis("off")
|
|
146
|
+
|
|
147
|
+
# Adjust plot limits
|
|
148
|
+
all_x = [node.x for node in nodes]
|
|
149
|
+
all_y = [node.y for node in nodes]
|
|
150
|
+
padding = 1 # Adjust padding as needed
|
|
151
|
+
ax.set_xlim(min(all_x) - padding, max(all_x) + padding)
|
|
152
|
+
ax.set_ylim(min(all_y) - padding, max(all_y) + padding)
|
|
153
|
+
ax.set_aspect("equal", adjustable="datalim")
|
|
154
|
+
|
|
155
|
+
|
|
21
156
|
class Canvas:
|
|
22
157
|
def __init__(
|
|
23
158
|
self,
|
|
@@ -29,7 +164,7 @@ class Canvas:
|
|
|
29
164
|
label: str | None = None,
|
|
30
165
|
fontsize: int = 14,
|
|
31
166
|
dpi: int = 300,
|
|
32
|
-
width: str = "
|
|
167
|
+
width: str = "5cm",
|
|
33
168
|
ratio: str = "golden", # TODO Add literal
|
|
34
169
|
gridspec_kw: Dict = {"wspace": 0.08, "hspace": 0.1},
|
|
35
170
|
):
|
|
@@ -62,6 +197,10 @@ class Canvas:
|
|
|
62
197
|
self._ratio = ratio
|
|
63
198
|
self._gridspec_kw = gridspec_kw
|
|
64
199
|
self._plotted = False
|
|
200
|
+
self._matplotlib_fig = None
|
|
201
|
+
self._matplotlib_axes = None
|
|
202
|
+
self._suptitle: str | None = None
|
|
203
|
+
self._suptitle_kwargs: dict = {}
|
|
65
204
|
|
|
66
205
|
# Dictionary to store lines for each subplot
|
|
67
206
|
# Key: (row, col), Value: list of lines with their data and kwargs
|
|
@@ -70,14 +209,60 @@ class Canvas:
|
|
|
70
209
|
|
|
71
210
|
self._subplot_matrix = [[None] * self.ncols for _ in range(self.nrows)]
|
|
72
211
|
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
# Factory
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def subplots(
|
|
218
|
+
cls,
|
|
219
|
+
nrows: int = 1,
|
|
220
|
+
ncols: int = 1,
|
|
221
|
+
squeeze: bool = True,
|
|
222
|
+
**canvas_kwargs,
|
|
223
|
+
):
|
|
224
|
+
"""
|
|
225
|
+
Create a Canvas pre-filled with LinePlot subplots, mirroring
|
|
226
|
+
``matplotlib.pyplot.subplots()``.
|
|
227
|
+
|
|
228
|
+
Parameters:
|
|
229
|
+
nrows, ncols (int): Grid dimensions.
|
|
230
|
+
squeeze (bool): If True, return a single subplot instead of a 1-element
|
|
231
|
+
list when the grid is 1×1 or when one dimension is 1.
|
|
232
|
+
**canvas_kwargs: Forwarded to the Canvas constructor.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
(canvas, axes): A tuple of the Canvas and either a single LinePlot,
|
|
236
|
+
a flat list (when one dimension is 1 and squeeze=True), or a
|
|
237
|
+
2-D list of LinePlots.
|
|
238
|
+
|
|
239
|
+
Examples:
|
|
240
|
+
>>> canvas, ax = Canvas.subplots()
|
|
241
|
+
>>> canvas, (ax1, ax2) = Canvas.subplots(ncols=2)
|
|
242
|
+
>>> canvas, axes = Canvas.subplots(nrows=2, ncols=2) # axes[row][col]
|
|
243
|
+
"""
|
|
244
|
+
canvas = cls(nrows=nrows, ncols=ncols, **canvas_kwargs)
|
|
245
|
+
axes = [
|
|
246
|
+
[canvas.add_subplot(row=r, col=c) for c in range(ncols)]
|
|
247
|
+
for r in range(nrows)
|
|
248
|
+
]
|
|
249
|
+
if squeeze:
|
|
250
|
+
if nrows == 1 and ncols == 1:
|
|
251
|
+
return canvas, axes[0][0]
|
|
252
|
+
if nrows == 1:
|
|
253
|
+
return canvas, axes[0]
|
|
254
|
+
if ncols == 1:
|
|
255
|
+
return canvas, [row[0] for row in axes]
|
|
256
|
+
return canvas, axes
|
|
257
|
+
|
|
73
258
|
@property
|
|
74
|
-
def
|
|
259
|
+
def _subplot_dict(self):
|
|
75
260
|
return self._subplots
|
|
76
261
|
|
|
77
262
|
@property
|
|
78
263
|
def layers(self):
|
|
79
264
|
layers = []
|
|
80
|
-
for (row, col), subplot in self.
|
|
265
|
+
for (row, col), subplot in self._subplot_dict.items():
|
|
81
266
|
layers.extend(subplot.layers)
|
|
82
267
|
return list(set(layers))
|
|
83
268
|
|
|
@@ -100,13 +285,12 @@ class Canvas:
|
|
|
100
285
|
|
|
101
286
|
def add_line(
|
|
102
287
|
self,
|
|
103
|
-
|
|
104
|
-
|
|
288
|
+
x,
|
|
289
|
+
y,
|
|
105
290
|
layer=0,
|
|
106
291
|
subplot: LinePlot | None = None,
|
|
107
292
|
row: int | None = None,
|
|
108
293
|
col: int | None = None,
|
|
109
|
-
plot_type="plot",
|
|
110
294
|
**kwargs,
|
|
111
295
|
):
|
|
112
296
|
if row is not None and col is not None:
|
|
@@ -123,13 +307,258 @@ class Canvas:
|
|
|
123
307
|
subplot = self.add_subplot(col=col, row=row)
|
|
124
308
|
|
|
125
309
|
subplot.add_line(
|
|
126
|
-
|
|
127
|
-
|
|
310
|
+
x=x,
|
|
311
|
+
y=y,
|
|
128
312
|
layer=layer,
|
|
129
|
-
plot_type=plot_type,
|
|
130
313
|
**kwargs,
|
|
131
314
|
)
|
|
132
315
|
|
|
316
|
+
def _get_or_create_subplot(self, row, col):
|
|
317
|
+
"""Return the subplot at (row, col), creating it if needed."""
|
|
318
|
+
if row is not None and col is not None:
|
|
319
|
+
try:
|
|
320
|
+
sp = self._subplot_matrix[row][col]
|
|
321
|
+
except (IndexError, KeyError):
|
|
322
|
+
raise ValueError("Invalid subplot position.")
|
|
323
|
+
else:
|
|
324
|
+
row, col = 0, 0
|
|
325
|
+
sp = self._subplot_matrix[row][col]
|
|
326
|
+
if sp is None:
|
|
327
|
+
row, col = self.generate_new_rowcol(row, col)
|
|
328
|
+
sp = self.add_subplot(col=col, row=row)
|
|
329
|
+
return sp
|
|
330
|
+
|
|
331
|
+
def scatter(
|
|
332
|
+
self,
|
|
333
|
+
x,
|
|
334
|
+
y,
|
|
335
|
+
layer=0,
|
|
336
|
+
row: int | None = None,
|
|
337
|
+
col: int | None = None,
|
|
338
|
+
**kwargs,
|
|
339
|
+
):
|
|
340
|
+
"""
|
|
341
|
+
Add a scatter plot to the canvas (matplotlib-style convenience method).
|
|
342
|
+
|
|
343
|
+
Parameters:
|
|
344
|
+
x (array-like): X-axis data.
|
|
345
|
+
y (array-like): Y-axis data.
|
|
346
|
+
layer (int): Layer index (default 0).
|
|
347
|
+
row, col (int): Subplot position (default top-left).
|
|
348
|
+
**kwargs: Forwarded to the backend (e.g., color, marker, s, label).
|
|
349
|
+
"""
|
|
350
|
+
sp = self._get_or_create_subplot(row, col)
|
|
351
|
+
sp.scatter(x, y, layer=layer, **kwargs)
|
|
352
|
+
|
|
353
|
+
def bar(
|
|
354
|
+
self,
|
|
355
|
+
x,
|
|
356
|
+
height,
|
|
357
|
+
layer=0,
|
|
358
|
+
row: int | None = None,
|
|
359
|
+
col: int | None = None,
|
|
360
|
+
**kwargs,
|
|
361
|
+
):
|
|
362
|
+
"""
|
|
363
|
+
Add a bar chart to the canvas (matplotlib-style convenience method).
|
|
364
|
+
|
|
365
|
+
Parameters:
|
|
366
|
+
x (array-like): X positions of the bars.
|
|
367
|
+
height (array-like): Heights of the bars.
|
|
368
|
+
layer (int): Layer index (default 0).
|
|
369
|
+
row, col (int): Subplot position (default top-left).
|
|
370
|
+
**kwargs: Forwarded to the backend (e.g., color, width, label).
|
|
371
|
+
"""
|
|
372
|
+
sp = self._get_or_create_subplot(row, col)
|
|
373
|
+
sp.bar(x, height, layer=layer, **kwargs)
|
|
374
|
+
|
|
375
|
+
def set_xlabel(self, label: str, row: int | None = None, col: int | None = None):
|
|
376
|
+
"""Set the x-axis label for a subplot (default top-left)."""
|
|
377
|
+
self._get_or_create_subplot(row, col).set_xlabel(label)
|
|
378
|
+
|
|
379
|
+
def set_ylabel(self, label: str, row: int | None = None, col: int | None = None):
|
|
380
|
+
"""Set the y-axis label for a subplot (default top-left)."""
|
|
381
|
+
self._get_or_create_subplot(row, col).set_ylabel(label)
|
|
382
|
+
|
|
383
|
+
def set_title(self, title: str, row: int | None = None, col: int | None = None):
|
|
384
|
+
"""Set the title for a subplot (default top-left)."""
|
|
385
|
+
self._get_or_create_subplot(row, col).set_title(title)
|
|
386
|
+
|
|
387
|
+
def set_xlim(
|
|
388
|
+
self, left=None, right=None, row: int | None = None, col: int | None = None
|
|
389
|
+
):
|
|
390
|
+
"""Set the x-axis limits for a subplot (default top-left)."""
|
|
391
|
+
self._get_or_create_subplot(row, col).set_xlim(left, right)
|
|
392
|
+
|
|
393
|
+
def set_ylim(
|
|
394
|
+
self, bottom=None, top=None, row: int | None = None, col: int | None = None
|
|
395
|
+
):
|
|
396
|
+
"""Set the y-axis limits for a subplot (default top-left)."""
|
|
397
|
+
self._get_or_create_subplot(row, col).set_ylim(bottom, top)
|
|
398
|
+
|
|
399
|
+
def set_grid(
|
|
400
|
+
self, visible: bool = True, row: int | None = None, col: int | None = None
|
|
401
|
+
):
|
|
402
|
+
"""Show or hide the grid for a subplot (default top-left)."""
|
|
403
|
+
self._get_or_create_subplot(row, col).set_grid(visible)
|
|
404
|
+
|
|
405
|
+
def set_legend(
|
|
406
|
+
self, visible: bool = True, row: int | None = None, col: int | None = None
|
|
407
|
+
):
|
|
408
|
+
"""Show or hide the legend for a subplot (default top-left)."""
|
|
409
|
+
self._get_or_create_subplot(row, col).set_legend(visible)
|
|
410
|
+
|
|
411
|
+
def set_xscale(self, scale: str, row: int | None = None, col: int | None = None):
|
|
412
|
+
"""Set x-axis scale ('linear', 'log', 'symlog') for a subplot."""
|
|
413
|
+
self._get_or_create_subplot(row, col).set_xscale(scale)
|
|
414
|
+
|
|
415
|
+
def set_yscale(self, scale: str, row: int | None = None, col: int | None = None):
|
|
416
|
+
"""Set y-axis scale ('linear', 'log', 'symlog') for a subplot."""
|
|
417
|
+
self._get_or_create_subplot(row, col).set_yscale(scale)
|
|
418
|
+
|
|
419
|
+
def set_xticks(
|
|
420
|
+
self, ticks, labels=None, row: int | None = None, col: int | None = None
|
|
421
|
+
):
|
|
422
|
+
"""Set x-axis tick positions (and optional labels) for a subplot."""
|
|
423
|
+
self._get_or_create_subplot(row, col).set_xticks(ticks, labels)
|
|
424
|
+
|
|
425
|
+
def set_yticks(
|
|
426
|
+
self, ticks, labels=None, row: int | None = None, col: int | None = None
|
|
427
|
+
):
|
|
428
|
+
"""Set y-axis tick positions (and optional labels) for a subplot."""
|
|
429
|
+
self._get_or_create_subplot(row, col).set_yticks(ticks, labels)
|
|
430
|
+
|
|
431
|
+
def fill_between(
|
|
432
|
+
self,
|
|
433
|
+
x,
|
|
434
|
+
y1,
|
|
435
|
+
y2=0,
|
|
436
|
+
layer=0,
|
|
437
|
+
row: int | None = None,
|
|
438
|
+
col: int | None = None,
|
|
439
|
+
**kwargs,
|
|
440
|
+
):
|
|
441
|
+
"""Fill the region between two curves on a subplot."""
|
|
442
|
+
self._get_or_create_subplot(row, col).fill_between(
|
|
443
|
+
x, y1, y2, layer=layer, **kwargs
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def errorbar(
|
|
447
|
+
self,
|
|
448
|
+
x,
|
|
449
|
+
y,
|
|
450
|
+
yerr=None,
|
|
451
|
+
xerr=None,
|
|
452
|
+
layer=0,
|
|
453
|
+
row: int | None = None,
|
|
454
|
+
col: int | None = None,
|
|
455
|
+
**kwargs,
|
|
456
|
+
):
|
|
457
|
+
"""Add an error-bar line to a subplot."""
|
|
458
|
+
self._get_or_create_subplot(row, col).errorbar(
|
|
459
|
+
x, y, yerr=yerr, xerr=xerr, layer=layer, **kwargs
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def axhline(
|
|
463
|
+
self, y=0, layer=0, row: int | None = None, col: int | None = None, **kwargs
|
|
464
|
+
):
|
|
465
|
+
"""Add a full-width horizontal reference line to a subplot."""
|
|
466
|
+
self._get_or_create_subplot(row, col).axhline(y=y, layer=layer, **kwargs)
|
|
467
|
+
|
|
468
|
+
def axvline(
|
|
469
|
+
self, x=0, layer=0, row: int | None = None, col: int | None = None, **kwargs
|
|
470
|
+
):
|
|
471
|
+
"""Add a full-height vertical reference line to a subplot."""
|
|
472
|
+
self._get_or_create_subplot(row, col).axvline(x=x, layer=layer, **kwargs)
|
|
473
|
+
|
|
474
|
+
def hlines(
|
|
475
|
+
self,
|
|
476
|
+
y,
|
|
477
|
+
xmin,
|
|
478
|
+
xmax,
|
|
479
|
+
layer=0,
|
|
480
|
+
row: int | None = None,
|
|
481
|
+
col: int | None = None,
|
|
482
|
+
**kwargs,
|
|
483
|
+
):
|
|
484
|
+
"""Add horizontal lines at specified y positions to a subplot."""
|
|
485
|
+
self._get_or_create_subplot(row, col).hlines(
|
|
486
|
+
y, xmin, xmax, layer=layer, **kwargs
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def vlines(
|
|
490
|
+
self,
|
|
491
|
+
x,
|
|
492
|
+
ymin,
|
|
493
|
+
ymax,
|
|
494
|
+
layer=0,
|
|
495
|
+
row: int | None = None,
|
|
496
|
+
col: int | None = None,
|
|
497
|
+
**kwargs,
|
|
498
|
+
):
|
|
499
|
+
"""Add vertical lines at specified x positions to a subplot."""
|
|
500
|
+
self._get_or_create_subplot(row, col).vlines(
|
|
501
|
+
x, ymin, ymax, layer=layer, **kwargs
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def annotate(
|
|
505
|
+
self,
|
|
506
|
+
text,
|
|
507
|
+
xy,
|
|
508
|
+
xytext=None,
|
|
509
|
+
layer=0,
|
|
510
|
+
row: int | None = None,
|
|
511
|
+
col: int | None = None,
|
|
512
|
+
**kwargs,
|
|
513
|
+
):
|
|
514
|
+
"""Add an annotation (with optional arrow) to a subplot."""
|
|
515
|
+
self._get_or_create_subplot(row, col).annotate(
|
|
516
|
+
text, xy, xytext=xytext, layer=layer, **kwargs
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def text(
|
|
520
|
+
self, x, y, s, layer=0, row: int | None = None, col: int | None = None, **kwargs
|
|
521
|
+
):
|
|
522
|
+
"""Add a text label at (x, y) on a subplot."""
|
|
523
|
+
self._get_or_create_subplot(row, col).text(x, y, s, layer=layer, **kwargs)
|
|
524
|
+
|
|
525
|
+
# ------------------------------------------------------------------
|
|
526
|
+
# Multi-subplot helpers
|
|
527
|
+
# ------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
def subplot(self, row: int = 0, col: int = 0) -> LinePlot:
|
|
530
|
+
"""
|
|
531
|
+
Return the LinePlot at position (row, col).
|
|
532
|
+
|
|
533
|
+
Raises ValueError if no subplot has been created there yet.
|
|
534
|
+
"""
|
|
535
|
+
sp = self._subplot_matrix[row][col]
|
|
536
|
+
if sp is None:
|
|
537
|
+
raise ValueError(
|
|
538
|
+
f"No subplot at ({row}, {col}). "
|
|
539
|
+
"Call add_subplot() or use Canvas.subplots() first."
|
|
540
|
+
)
|
|
541
|
+
return sp
|
|
542
|
+
|
|
543
|
+
def iter_subplots(self):
|
|
544
|
+
"""Yield (row, col, subplot) for every initialized subplot, row-major."""
|
|
545
|
+
for r in range(self.nrows):
|
|
546
|
+
for c in range(self.ncols):
|
|
547
|
+
sp = self._subplot_matrix[r][c]
|
|
548
|
+
if sp is not None:
|
|
549
|
+
yield r, c, sp
|
|
550
|
+
|
|
551
|
+
def suptitle(self, title: str, **kwargs):
|
|
552
|
+
"""
|
|
553
|
+
Set a figure-level title (rendered above all subplots).
|
|
554
|
+
|
|
555
|
+
Parameters:
|
|
556
|
+
title (str): Title text.
|
|
557
|
+
**kwargs: Forwarded to matplotlib's fig.suptitle (e.g., fontsize, y).
|
|
558
|
+
"""
|
|
559
|
+
self._suptitle = title
|
|
560
|
+
self._suptitle_kwargs = kwargs
|
|
561
|
+
|
|
133
562
|
def add_tikzfigure(
|
|
134
563
|
self,
|
|
135
564
|
col=None,
|
|
@@ -280,14 +709,15 @@ class Canvas:
|
|
|
280
709
|
)
|
|
281
710
|
elif backend == "plotly":
|
|
282
711
|
return self.plot_plotly(savefig=savefig)
|
|
283
|
-
elif backend == "
|
|
284
|
-
return self.
|
|
712
|
+
elif backend == "tikzfigure":
|
|
713
|
+
return self.plot_tikzfigure(savefig=savefig)
|
|
285
714
|
else:
|
|
286
715
|
raise ValueError(f"Invalid backend: {backend}")
|
|
287
716
|
|
|
288
717
|
def show(
|
|
289
718
|
self,
|
|
290
719
|
backend: Backends = "matplotlib",
|
|
720
|
+
layers: list | None = None,
|
|
291
721
|
verbose: bool = False,
|
|
292
722
|
):
|
|
293
723
|
if verbose:
|
|
@@ -297,14 +727,14 @@ class Canvas:
|
|
|
297
727
|
self.plot(
|
|
298
728
|
backend="matplotlib",
|
|
299
729
|
savefig=False,
|
|
300
|
-
layers=
|
|
730
|
+
layers=layers,
|
|
301
731
|
verbose=verbose,
|
|
302
732
|
)
|
|
303
733
|
# self._matplotlib_fig.show()
|
|
304
734
|
elif backend == "plotly":
|
|
305
735
|
self.plot_plotly(savefig=False)
|
|
306
|
-
elif backend == "
|
|
307
|
-
fig = self.
|
|
736
|
+
elif backend == "tikzfigure":
|
|
737
|
+
fig = self.plot_tikzfigure(savefig=False, verbose=verbose)
|
|
308
738
|
fig.show()
|
|
309
739
|
else:
|
|
310
740
|
raise ValueError("Invalid backend")
|
|
@@ -357,7 +787,7 @@ class Canvas:
|
|
|
357
787
|
dpi=self.dpi,
|
|
358
788
|
)
|
|
359
789
|
|
|
360
|
-
for (row, col), subplot in self.
|
|
790
|
+
for (row, col), subplot in self._subplot_dict.items():
|
|
361
791
|
ax = axes[row][col]
|
|
362
792
|
if isinstance(subplot, TikzFigure):
|
|
363
793
|
plot_matplotlib(subplot, ax, layers=layers)
|
|
@@ -366,26 +796,29 @@ class Canvas:
|
|
|
366
796
|
# ax.set_title(f"Subplot ({row}, {col})")
|
|
367
797
|
ax.grid()
|
|
368
798
|
|
|
799
|
+
if self._suptitle:
|
|
800
|
+
fig.suptitle(self._suptitle, **self._suptitle_kwargs)
|
|
801
|
+
|
|
369
802
|
# Set caption, labels, etc., if needed
|
|
370
803
|
self._plotted = True
|
|
371
804
|
self._matplotlib_fig = fig
|
|
372
805
|
self._matplotlib_axes = axes
|
|
373
806
|
return fig, axes
|
|
374
807
|
|
|
375
|
-
def
|
|
808
|
+
def plot_tikzfigure(
|
|
376
809
|
self,
|
|
377
|
-
savefig=None,
|
|
378
|
-
verbose=False,
|
|
810
|
+
savefig: str | None = None,
|
|
811
|
+
verbose: bool = False,
|
|
379
812
|
) -> TikzFigure:
|
|
380
|
-
if len(self.
|
|
813
|
+
if len(self._subplot_dict) > 1:
|
|
381
814
|
raise NotImplementedError(
|
|
382
|
-
"Only one subplot is supported for
|
|
815
|
+
"Only one subplot is supported for tikzfigure backend."
|
|
383
816
|
)
|
|
384
|
-
for (row, col), line_plot in self.
|
|
817
|
+
for (row, col), line_plot in self._subplot_dict.items():
|
|
385
818
|
if verbose:
|
|
386
819
|
print(f"Plotting subplot at row {row}, col {col}")
|
|
387
820
|
print(f"{line_plot = }")
|
|
388
|
-
tikz_subplot = line_plot.
|
|
821
|
+
tikz_subplot = line_plot.plot_tikzfigure(verbose=verbose)
|
|
389
822
|
return tikz_subplot
|
|
390
823
|
|
|
391
824
|
def plot_plotly(self, show=True, savefig=None, usetex=False):
|
|
@@ -416,31 +849,45 @@ class Canvas:
|
|
|
416
849
|
rows=self.nrows,
|
|
417
850
|
cols=self.ncols,
|
|
418
851
|
subplot_titles=[
|
|
419
|
-
f"
|
|
852
|
+
sp._title or f"({row}, {col})"
|
|
853
|
+
for (row, col), sp in self._subplot_dict.items()
|
|
420
854
|
],
|
|
421
855
|
)
|
|
422
856
|
|
|
423
|
-
# Plot each subplot
|
|
424
|
-
|
|
425
|
-
|
|
857
|
+
# Plot each subplot and propagate axis labels/scale
|
|
858
|
+
axis_index = 1
|
|
859
|
+
for (row, col), line_plot in self._subplot_dict.items():
|
|
860
|
+
traces = line_plot.plot_plotly()
|
|
426
861
|
for trace in traces:
|
|
427
862
|
fig.add_trace(trace, row=row + 1, col=col + 1)
|
|
428
863
|
|
|
864
|
+
# Axis label keys are "xaxis", "xaxis2", "xaxis3", ...
|
|
865
|
+
xkey = "xaxis" if axis_index == 1 else f"xaxis{axis_index}"
|
|
866
|
+
ykey = "yaxis" if axis_index == 1 else f"yaxis{axis_index}"
|
|
867
|
+
layout_patch = {}
|
|
868
|
+
if line_plot._xlabel:
|
|
869
|
+
layout_patch[xkey] = {"title": {"text": line_plot._xlabel}}
|
|
870
|
+
if line_plot._ylabel:
|
|
871
|
+
layout_patch[ykey] = {"title": {"text": line_plot._ylabel}}
|
|
872
|
+
if line_plot._xaxis_scale == "log":
|
|
873
|
+
layout_patch.setdefault(xkey, {})["type"] = "log"
|
|
874
|
+
if line_plot._yaxis_scale == "log":
|
|
875
|
+
layout_patch.setdefault(ykey, {})["type"] = "log"
|
|
876
|
+
if layout_patch:
|
|
877
|
+
fig.update_layout(**layout_patch)
|
|
878
|
+
axis_index += 1
|
|
879
|
+
|
|
429
880
|
# Update layout settings
|
|
430
881
|
fig.update_layout(
|
|
431
|
-
# width=fig_width,
|
|
432
|
-
# height=fig_height,
|
|
433
882
|
font=dict(size=self.fontsize),
|
|
434
|
-
margin=dict(l=10, r=10, t=40, b=10),
|
|
883
|
+
margin=dict(l=10, r=10, t=40, b=10),
|
|
435
884
|
)
|
|
885
|
+
if self._suptitle:
|
|
886
|
+
fig.update_layout(title=dict(text=self._suptitle, x=0.5))
|
|
436
887
|
|
|
437
|
-
# Optionally save the figure
|
|
438
888
|
if savefig:
|
|
439
889
|
fig.write_image(savefig)
|
|
440
890
|
|
|
441
|
-
# Show or return the figure
|
|
442
|
-
# if show:
|
|
443
|
-
# fig.show()
|
|
444
891
|
return fig
|
|
445
892
|
|
|
446
893
|
# Property getters
|
|
@@ -507,13 +954,6 @@ class Canvas:
|
|
|
507
954
|
def figsize(self, value):
|
|
508
955
|
self._figsize = value
|
|
509
956
|
|
|
510
|
-
# Magic methods
|
|
511
|
-
def __str__(self):
|
|
512
|
-
return f"Canvas(nrows={self.nrows}, ncols={self.ncols}, figsize={self.figsize})"
|
|
513
|
-
|
|
514
|
-
def __repr__(self):
|
|
515
|
-
return f"Canvas(nrows={self.nrows}, ncols={self.ncols}, caption={self.caption}, label={self.label})"
|
|
516
|
-
|
|
517
957
|
def __getitem__(self, key):
|
|
518
958
|
"""Allows accessing subplots by tuple index."""
|
|
519
959
|
row, col = key
|
|
@@ -528,145 +968,17 @@ class Canvas:
|
|
|
528
968
|
raise IndexError("Subplot index out of range")
|
|
529
969
|
self._subplot_matrix[row][col] = value
|
|
530
970
|
|
|
971
|
+
def __repr__(self):
|
|
972
|
+
return f"Canvas(nrows={self.nrows}, ncols={self.ncols}, caption={self.caption}, label={self.label})"
|
|
531
973
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
Parameters:
|
|
537
|
-
- ax (matplotlib.axes.Axes): Axis on which to plot the figure.
|
|
538
|
-
"""
|
|
539
|
-
|
|
540
|
-
# TODO: Specify which layers to retreive nodes from with layers=layers
|
|
541
|
-
nodes = tikzfigure.layers.get_nodes()
|
|
542
|
-
paths = tikzfigure.layers.get_paths()
|
|
543
|
-
|
|
544
|
-
for path in paths:
|
|
545
|
-
x_coords = [node.x for node in path.nodes]
|
|
546
|
-
y_coords = [node.y for node in path.nodes]
|
|
547
|
-
|
|
548
|
-
# Parse path color
|
|
549
|
-
path_color_spec = path.kwargs.get("color", "black")
|
|
550
|
-
try:
|
|
551
|
-
color = Color(path_color_spec).to_rgb()
|
|
552
|
-
except ValueError as e:
|
|
553
|
-
print(e)
|
|
554
|
-
color = "black"
|
|
555
|
-
|
|
556
|
-
# Parse line width
|
|
557
|
-
line_width_spec = path.kwargs.get("line_width", 1)
|
|
558
|
-
if isinstance(line_width_spec, str):
|
|
559
|
-
match = re.match(r"([\d.]+)(pt)?", line_width_spec)
|
|
560
|
-
if match:
|
|
561
|
-
line_width = float(match.group(1))
|
|
562
|
-
else:
|
|
563
|
-
print(
|
|
564
|
-
f"Invalid line width specification: '{line_width_spec}', defaulting to 1",
|
|
565
|
-
)
|
|
566
|
-
line_width = 1
|
|
567
|
-
else:
|
|
568
|
-
line_width = float(line_width_spec)
|
|
569
|
-
|
|
570
|
-
# Parse line style using Linestyle class
|
|
571
|
-
style_spec = path.kwargs.get("style", "solid")
|
|
572
|
-
linestyle = Linestyle(style_spec).to_matplotlib()
|
|
573
|
-
|
|
574
|
-
ax.plot(
|
|
575
|
-
x_coords,
|
|
576
|
-
y_coords,
|
|
577
|
-
color=color,
|
|
578
|
-
linewidth=line_width,
|
|
579
|
-
linestyle=linestyle,
|
|
580
|
-
zorder=1, # Lower z-order to place behind nodes
|
|
581
|
-
)
|
|
582
|
-
|
|
583
|
-
# Plot nodes after paths so they appear on top
|
|
584
|
-
for node in nodes:
|
|
585
|
-
# Determine shape and size
|
|
586
|
-
shape = node.kwargs.get("shape", "circle")
|
|
587
|
-
fill_color_spec = node.kwargs.get("fill", "white")
|
|
588
|
-
edge_color_spec = node.kwargs.get("draw", "black")
|
|
589
|
-
linewidth = float(node.kwargs.get("line_width", 1))
|
|
590
|
-
size = float(node.kwargs.get("size", 1))
|
|
591
|
-
|
|
592
|
-
# Parse colors using the Color class
|
|
593
|
-
try:
|
|
594
|
-
facecolor = Color(fill_color_spec).to_rgb()
|
|
595
|
-
except ValueError as e:
|
|
596
|
-
print(e)
|
|
597
|
-
facecolor = "white"
|
|
598
|
-
|
|
599
|
-
try:
|
|
600
|
-
edgecolor = Color(edge_color_spec).to_rgb()
|
|
601
|
-
except ValueError as e:
|
|
602
|
-
print(e)
|
|
603
|
-
edgecolor = "black"
|
|
604
|
-
|
|
605
|
-
# Plot shapes
|
|
606
|
-
if shape == "circle":
|
|
607
|
-
radius = size / 2
|
|
608
|
-
circle = patches.Circle(
|
|
609
|
-
(node.x, node.y),
|
|
610
|
-
radius,
|
|
611
|
-
facecolor=facecolor,
|
|
612
|
-
edgecolor=edgecolor,
|
|
613
|
-
linewidth=linewidth,
|
|
614
|
-
zorder=2, # Higher z-order to place on top of paths
|
|
615
|
-
)
|
|
616
|
-
ax.add_patch(circle)
|
|
617
|
-
elif shape == "rectangle":
|
|
618
|
-
width = height = size
|
|
619
|
-
rect = patches.Rectangle(
|
|
620
|
-
(node.x - width / 2, node.y - height / 2),
|
|
621
|
-
width,
|
|
622
|
-
height,
|
|
623
|
-
facecolor=facecolor,
|
|
624
|
-
edgecolor=edgecolor,
|
|
625
|
-
linewidth=linewidth,
|
|
626
|
-
zorder=2, # Higher z-order
|
|
627
|
-
)
|
|
628
|
-
ax.add_patch(rect)
|
|
629
|
-
else:
|
|
630
|
-
# Default to circle if shape is unknown
|
|
631
|
-
radius = size / 2
|
|
632
|
-
circle = patches.Circle(
|
|
633
|
-
(node.x, node.y),
|
|
634
|
-
radius,
|
|
635
|
-
facecolor=facecolor,
|
|
636
|
-
edgecolor=edgecolor,
|
|
637
|
-
linewidth=linewidth,
|
|
638
|
-
zorder=2,
|
|
639
|
-
)
|
|
640
|
-
ax.add_patch(circle)
|
|
641
|
-
|
|
642
|
-
# Add text inside the shape
|
|
643
|
-
if node.content:
|
|
644
|
-
ax.text(
|
|
645
|
-
node.x,
|
|
646
|
-
node.y,
|
|
647
|
-
node.content,
|
|
648
|
-
fontsize=10,
|
|
649
|
-
ha="center",
|
|
650
|
-
va="center",
|
|
651
|
-
wrap=True,
|
|
652
|
-
zorder=3, # Even higher z-order for text
|
|
653
|
-
)
|
|
654
|
-
|
|
655
|
-
# Remove axes, ticks, and legend
|
|
656
|
-
ax.axis("off")
|
|
657
|
-
|
|
658
|
-
# Adjust plot limits
|
|
659
|
-
all_x = [node.x for node in nodes]
|
|
660
|
-
all_y = [node.y for node in nodes]
|
|
661
|
-
padding = 1 # Adjust padding as needed
|
|
662
|
-
ax.set_xlim(min(all_x) - padding, max(all_x) + padding)
|
|
663
|
-
ax.set_ylim(min(all_y) - padding, max(all_y) + padding)
|
|
664
|
-
ax.set_aspect("equal", adjustable="datalim")
|
|
974
|
+
# Magic methods
|
|
975
|
+
def __str__(self):
|
|
976
|
+
return f"Canvas(nrows={self.nrows}, ncols={self.ncols}, figsize={self.figsize})"
|
|
665
977
|
|
|
666
978
|
|
|
667
979
|
if __name__ == "__main__":
|
|
668
980
|
c = Canvas(ncols=2, nrows=2)
|
|
669
981
|
sp = c.add_subplot()
|
|
670
|
-
sp.
|
|
671
|
-
c.plot()
|
|
982
|
+
sp.plot([0, 1, 2, 3], [0, 1, 4, 9], label="Line 1")
|
|
983
|
+
c.plot(backend="matplotlib")
|
|
672
984
|
print("done")
|