rosetree 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rosetree/__init__.py +19 -0
- rosetree/draw.py +372 -0
- rosetree/tree.py +482 -0
- rosetree/trie.py +78 -0
- rosetree/utils.py +20 -0
- rosetree/weighted.py +80 -0
- rosetree-0.1.0.dist-info/METADATA +352 -0
- rosetree-0.1.0.dist-info/RECORD +10 -0
- rosetree-0.1.0.dist-info/WHEEL +4 -0
- rosetree-0.1.0.dist-info/licenses/LICENSE +21 -0
rosetree/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Library implementing the "rose tree" data structure."""
|
|
2
|
+
|
|
3
|
+
from .draw import TreeDrawOptions, TreeLayoutOptions
|
|
4
|
+
from .tree import FrozenTree, MemoTree, Tree, zip_trees, zip_trees_with
|
|
5
|
+
from .trie import Trie
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__version__ = '0.1.0'
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'FrozenTree',
|
|
12
|
+
'MemoTree',
|
|
13
|
+
'Tree',
|
|
14
|
+
'TreeDrawOptions',
|
|
15
|
+
'TreeLayoutOptions',
|
|
16
|
+
'Trie',
|
|
17
|
+
'zip_trees',
|
|
18
|
+
'zip_trees_with',
|
|
19
|
+
]
|
rosetree/draw.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""This module contains algorithms for drawing trees prettily."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from math import ceil
|
|
8
|
+
import re
|
|
9
|
+
from typing import TYPE_CHECKING, Callable, NamedTuple, Optional, TypeVar, Union
|
|
10
|
+
|
|
11
|
+
from typing_extensions import Self
|
|
12
|
+
|
|
13
|
+
from .utils import cumsums
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .tree import BaseTree
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
T = TypeVar('T')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# CONSTANTS
|
|
24
|
+
|
|
25
|
+
_PARTITION_REGEX = re.compile(r'^(\s*)([^\s](.*[^\s])?)(\s*)$')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# TYPES
|
|
29
|
+
|
|
30
|
+
# color as string or RGB(A) tuple
|
|
31
|
+
Color = Union[str, tuple[float, ...]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Box(NamedTuple):
|
|
35
|
+
"""Class representing a box (rectangle)."""
|
|
36
|
+
x: float
|
|
37
|
+
y: float
|
|
38
|
+
width: float
|
|
39
|
+
height: float
|
|
40
|
+
|
|
41
|
+
def shift(self, dx: float, dy: float) -> Self:
|
|
42
|
+
"""Returns a new box, shifted by the given (dx, dy)."""
|
|
43
|
+
return type(self)(self.x + dx, self.y + dy, self.width, self.height)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
BoxPair = tuple[Box, Box]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# LONG FORMAT
|
|
50
|
+
|
|
51
|
+
def pretty_tree_long(tree: BaseTree[T]) -> str:
|
|
52
|
+
"""Given a tree whose nows can be converted to strings via `str`, produces a pretty rendering of that tree in "long format."
|
|
53
|
+
Each node is printed on its own line.
|
|
54
|
+
This format is analogous to the Linux `tree` command."""
|
|
55
|
+
def _pretty_lines(node: T, children: Sequence[list[str]]) -> list[str]:
|
|
56
|
+
lines = [str(node)]
|
|
57
|
+
num_children = len(children)
|
|
58
|
+
for (i, child_lines) in enumerate(children):
|
|
59
|
+
assert child_lines
|
|
60
|
+
if i < num_children - 1:
|
|
61
|
+
prefix1 = '├── '
|
|
62
|
+
prefix2 = '│ '
|
|
63
|
+
else:
|
|
64
|
+
prefix1 = '└── '
|
|
65
|
+
prefix2 = ' '
|
|
66
|
+
lines.append(prefix1 + child_lines[0])
|
|
67
|
+
lines.extend([prefix2 + line for line in child_lines[1:]])
|
|
68
|
+
return lines
|
|
69
|
+
lines = tree.fold(_pretty_lines)
|
|
70
|
+
return '\n'.join(lines)
|
|
71
|
+
|
|
72
|
+
# WIDE FORMAT
|
|
73
|
+
|
|
74
|
+
def _center_index(n: int) -> int:
|
|
75
|
+
"""Gets the (integer) midpoint index for the given distance n."""
|
|
76
|
+
return max(0, n - 1) // 2
|
|
77
|
+
|
|
78
|
+
def _partition_line(line: str) -> tuple[str, str, str]:
|
|
79
|
+
"""Giving a line with leading and/or trailing whitespace, splits it into these three parts, returning a tuple (leading whitespace, text, trailing whitespace)."""
|
|
80
|
+
(leading, text, _, trailing) = _PARTITION_REGEX.match(line).groups() # type: ignore[union-attr]
|
|
81
|
+
return (leading, text, trailing)
|
|
82
|
+
|
|
83
|
+
def _place_line(line: str, width: int, center: int) -> str:
|
|
84
|
+
"""Given a text line, total width, and center index, pads the line on the left and right so that the total width and text center match the target quantities."""
|
|
85
|
+
line_length = len(line)
|
|
86
|
+
line_center = _center_index(line_length)
|
|
87
|
+
lpad = center - line_center
|
|
88
|
+
rpad = (width - center) - (line_length - line_center)
|
|
89
|
+
if (lpad < 0) or (rpad < 0):
|
|
90
|
+
raise ValueError(f'cannot center line of length {line_length} at index {center} in a width of {width}')
|
|
91
|
+
return (' ' * lpad) + line + (' ' * rpad)
|
|
92
|
+
|
|
93
|
+
def _pad_lines(lines: list[str]) -> list[str]:
|
|
94
|
+
# ensure the tree is padded on the left & right
|
|
95
|
+
if any(not line.startswith(' ') for line in lines):
|
|
96
|
+
lines = [' ' + line for line in lines]
|
|
97
|
+
if any(not line.endswith(' ') for line in lines):
|
|
98
|
+
lines = [line + ' ' for line in lines]
|
|
99
|
+
return lines
|
|
100
|
+
|
|
101
|
+
def _get_box_char(j: int, lcenter: int, midpoint: int, rcenter: int) -> str:
|
|
102
|
+
if j == midpoint:
|
|
103
|
+
return '┴'
|
|
104
|
+
if (j < lcenter) or (j > rcenter):
|
|
105
|
+
return ' '
|
|
106
|
+
if j == lcenter:
|
|
107
|
+
return '┌'
|
|
108
|
+
if j == rcenter:
|
|
109
|
+
return '┐'
|
|
110
|
+
return '─'
|
|
111
|
+
|
|
112
|
+
def _extend_box_char_down(c: str) -> str:
|
|
113
|
+
if c == '─':
|
|
114
|
+
return '┬'
|
|
115
|
+
if c == '┴':
|
|
116
|
+
return '┼'
|
|
117
|
+
return c
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Column(NamedTuple):
|
|
121
|
+
"""Tuple of elements for a fixed-width column of text, which may consist of multiple rows."""
|
|
122
|
+
width: int # width of column
|
|
123
|
+
center: int # center index
|
|
124
|
+
rows: list[str] # rows of text
|
|
125
|
+
|
|
126
|
+
def pad_to(self, width: int) -> Self:
|
|
127
|
+
"""Pads the column to the given width."""
|
|
128
|
+
n = width - self.width
|
|
129
|
+
if n <= 0:
|
|
130
|
+
return self
|
|
131
|
+
lpad = ' ' * (n // 2)
|
|
132
|
+
rpad = ' ' * (n - n // 2)
|
|
133
|
+
rows = [lpad + row + rpad for row in self.rows]
|
|
134
|
+
return type(self)(width, _center_index(width), rows)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def conjoin(cls, columns: Sequence[Self], spacing: int, top_down: bool) -> Self:
|
|
138
|
+
"""Conjoint multiple columns horizontally into one.
|
|
139
|
+
An integer, spacing, specifies how many spaces to insert between each column.
|
|
140
|
+
If top_down = True, aligns conjoined columns from the top, otherwise from the bottom."""
|
|
141
|
+
assert len(columns) > 1
|
|
142
|
+
(widths, _, cols) = zip(*columns)
|
|
143
|
+
heights = [len(col) for col in cols]
|
|
144
|
+
max_height = max(heights)
|
|
145
|
+
empty_rows = [[' ' * width] * (max_height - height) for (width, height) in zip(widths, heights)]
|
|
146
|
+
if top_down:
|
|
147
|
+
cols = tuple(col + empty for (empty, col) in zip(empty_rows, cols))
|
|
148
|
+
else:
|
|
149
|
+
cols = tuple(empty + col for (empty, col) in zip(empty_rows, cols))
|
|
150
|
+
width = sum(widths) + (spacing * (len(cols) - 1))
|
|
151
|
+
delim = ' ' * spacing
|
|
152
|
+
col = [delim.join(row) for row in zip(*cols)]
|
|
153
|
+
return cls(width, _center_index(width), col)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def pretty_tree_wide(tree: BaseTree[T], *, top_down: bool = False, spacing: int = 2) -> str:
|
|
157
|
+
"""Given a tree whose nows can be converted to strings via `str`, produces a pretty rendering of that tree in "wide format."
|
|
158
|
+
This presents the tree's root node at the top, with branches cascading down.
|
|
159
|
+
If top_down = True, positions nodes of the same depth on the same vertical level.
|
|
160
|
+
Otherwise, positions leaf nodes on the same vertical level.
|
|
161
|
+
spacing is an integer indicating the minimum number of spaces between each column."""
|
|
162
|
+
def conjoin_subtrees(node: T, children: Sequence[Column]) -> Column:
|
|
163
|
+
node_str = str(node)
|
|
164
|
+
node_lines = node_str.splitlines() if node_str else ['']
|
|
165
|
+
node_width = max(map(len, node_lines))
|
|
166
|
+
node_lines = [line.ljust(node_width) for line in node_lines]
|
|
167
|
+
if (num_children := len(children)) == 0: # leaf
|
|
168
|
+
return Column(node_width, _center_index(node_width), node_lines)
|
|
169
|
+
(child_widths, child_centers, _) = zip(*children)
|
|
170
|
+
if num_children == 1:
|
|
171
|
+
(width, center, rows) = children[0].pad_to(node_width)
|
|
172
|
+
spans = [(0, width)]
|
|
173
|
+
centers = [center]
|
|
174
|
+
midpoint = center
|
|
175
|
+
edges = ['│' if (j == center) else ' ' for j in range(width)]
|
|
176
|
+
else:
|
|
177
|
+
# calculate the smallest spacing required for the child width to exceed the parent width
|
|
178
|
+
num_spaces = max(spacing, ceil((node_width - sum(child_widths)) / (num_children - 1)))
|
|
179
|
+
(width, center, rows) = Column.conjoin(children, num_spaces, top_down)
|
|
180
|
+
# place parent at the midpoint of the leftmost and rightmost child's centers
|
|
181
|
+
spans = [(0, child_widths[0])]
|
|
182
|
+
for child_width in child_widths[1:]:
|
|
183
|
+
start = spans[-1][1]
|
|
184
|
+
spans.append((start + num_spaces, start + num_spaces + child_width))
|
|
185
|
+
centers = [start + child_center for ((start, _), child_center) in zip(spans, child_centers)]
|
|
186
|
+
(lcenter, rcenter) = (centers[0], centers[-1])
|
|
187
|
+
midpoint = lcenter + _center_index(rcenter - lcenter + 1)
|
|
188
|
+
edges = [_get_box_char(j, lcenter, midpoint, rcenter) for j in range(width)]
|
|
189
|
+
for j in centers:
|
|
190
|
+
edges[j] = _extend_box_char_down(edges[j])
|
|
191
|
+
node_lines = [_place_line(line, width, midpoint) for line in node_lines]
|
|
192
|
+
node_lines.append(''.join(edges))
|
|
193
|
+
# get mapping from center indices to column spans
|
|
194
|
+
column_spans = {center: (start, stop) for (center, (start, stop)) in zip(centers, spans) if (start <= center < stop)}
|
|
195
|
+
# insert '|' downward to each child
|
|
196
|
+
text_cols = [list(col) for col in zip(*rows)]
|
|
197
|
+
# extension_indices = set() # indices at which to branch downward
|
|
198
|
+
for (j, col) in enumerate(text_cols):
|
|
199
|
+
if (span := column_spans.get(j)) is None:
|
|
200
|
+
continue
|
|
201
|
+
for (i, row) in enumerate(rows):
|
|
202
|
+
if row[span[0]:span[1]].isspace():
|
|
203
|
+
col[i] = '│'
|
|
204
|
+
else:
|
|
205
|
+
break
|
|
206
|
+
rows = node_lines + [''.join(row) for row in zip(*text_cols)]
|
|
207
|
+
top_line = node_lines[0]
|
|
208
|
+
if top_line.isspace():
|
|
209
|
+
new_center = center
|
|
210
|
+
else:
|
|
211
|
+
(leading, top_text, _) = _partition_line(node_lines[0])
|
|
212
|
+
new_center = len(leading) + _center_index(len(top_text))
|
|
213
|
+
return Column(width, new_center, rows)
|
|
214
|
+
lines = tree.fold(conjoin_subtrees).rows
|
|
215
|
+
# ensure the tree is padded on the left & right
|
|
216
|
+
lines = _pad_lines(lines)
|
|
217
|
+
return '\n'.join(lines)
|
|
218
|
+
|
|
219
|
+
# PLANAR DRAWING
|
|
220
|
+
|
|
221
|
+
@dataclass
|
|
222
|
+
class TreeLayoutOptions:
|
|
223
|
+
"""Options for laying out a tree diagram in 2D coordinates."""
|
|
224
|
+
xchar: float = 0.16 # x width of characters
|
|
225
|
+
ychar: float = 0.21 # y width of characters
|
|
226
|
+
xgap: float = 0.7 # horizontal gap dimension
|
|
227
|
+
ygap: float = 0.7 # vertical gap dimension
|
|
228
|
+
ylead: float = 0.06 # space between lines of text
|
|
229
|
+
|
|
230
|
+
def text_size(self, s: str) -> tuple[float, float]:
|
|
231
|
+
"""Gets the width and height of a block of text."""
|
|
232
|
+
lines = s.splitlines()
|
|
233
|
+
num_lines = len(lines)
|
|
234
|
+
if num_lines == 0:
|
|
235
|
+
width = 0.0
|
|
236
|
+
else:
|
|
237
|
+
width = self.xchar * max(map(len, lines))
|
|
238
|
+
height = self.ychar * num_lines + self.ylead * max(0, num_lines - 1)
|
|
239
|
+
return (width, height)
|
|
240
|
+
|
|
241
|
+
def _shift_coord_node_pair(self, dx: float, dy: float) -> Callable[[tuple[BoxPair, T]], tuple[BoxPair, T]]:
|
|
242
|
+
def _shift(pair: tuple[BoxPair, T]) -> tuple[BoxPair, T]:
|
|
243
|
+
((box1, box2), node) = pair
|
|
244
|
+
return ((box1.shift(dx, dy), box2.shift(dx, dy)), node)
|
|
245
|
+
return _shift
|
|
246
|
+
|
|
247
|
+
def tree_with_boxes(self, tree: BaseTree[T], *, top_down: bool = True) -> BaseTree[tuple[BoxPair, T]]:
|
|
248
|
+
"""Computes bounding boxes for each node of the tree for the given drawing style.
|
|
249
|
+
Returns a new tree of ((parent box, full box), node) pairs."""
|
|
250
|
+
cls = type(tree)
|
|
251
|
+
# recursively compute (parent node box, full subtree box) for each node
|
|
252
|
+
def get_boxes(node: T, children: Sequence[BaseTree[tuple[BoxPair, T]]]) -> BaseTree[tuple[BoxPair, T]]:
|
|
253
|
+
# compute parent dimensions from text size
|
|
254
|
+
(parent_width, parent_height) = self.text_size(str(node))
|
|
255
|
+
num_children = len(children)
|
|
256
|
+
if num_children == 0:
|
|
257
|
+
full_box = parent_box = Box(0.0, 0.0, parent_width, parent_height)
|
|
258
|
+
else:
|
|
259
|
+
child_widths = [child.node[0][1].width for child in children]
|
|
260
|
+
# get x offsets for the child boxes, inserting gaps
|
|
261
|
+
dxs = cumsums([self.xgap + child_width for child_width in child_widths[:-1]] + [child_widths[-1]])
|
|
262
|
+
children_width = dxs[-1] # width of all children together
|
|
263
|
+
dy = -(parent_height + self.ygap)
|
|
264
|
+
child_heights = [child.node[0][1].height for child in children]
|
|
265
|
+
max_child_height = max(child_heights)
|
|
266
|
+
if top_down: # same vertical offset for each child
|
|
267
|
+
dys = [dy] * num_children
|
|
268
|
+
else: # adjust vertical offset for each child's height
|
|
269
|
+
dys = [dy - (max_child_height - height) for height in child_heights]
|
|
270
|
+
if parent_width > children_width: # shift children under parent
|
|
271
|
+
full_width = parent_width
|
|
272
|
+
diff = (parent_width - children_width) / 2.0
|
|
273
|
+
# shift each child's box to the right by the appropriate offset
|
|
274
|
+
children = [child.map(self._shift_coord_node_pair(dx + diff, dy)) for (child, dx, dy) in zip(children, dxs, dys)]
|
|
275
|
+
parent_x = 0.0
|
|
276
|
+
else: # center parent over its children
|
|
277
|
+
full_width = children_width
|
|
278
|
+
children = [child.map(self._shift_coord_node_pair(dx, dy)) for (child, dx, dy) in zip(children, dxs, dys)]
|
|
279
|
+
(lbox, rbox) = (children[0].node[0][0], children[-1].node[0][0])
|
|
280
|
+
(left, right) = (lbox.x, rbox.x + rbox.width)
|
|
281
|
+
parent_x = (left + right - parent_width) / 2.0
|
|
282
|
+
parent_box = Box(parent_x, 0.0, parent_width, parent_height)
|
|
283
|
+
full_height = parent_height + self.ygap + max_child_height
|
|
284
|
+
full_box = Box(0.0, 0.0, full_width, full_height)
|
|
285
|
+
return cls(((parent_box, full_box), node), children) # type: ignore
|
|
286
|
+
return tree.fold(get_boxes)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass
|
|
290
|
+
class TreeDrawOptions:
|
|
291
|
+
"""Options for drawing a tree diagram on a 2D canvas."""
|
|
292
|
+
layout_options: TreeLayoutOptions = field(default_factory=TreeLayoutOptions)
|
|
293
|
+
text_color: Color = 'black' # node text color
|
|
294
|
+
leaf_text_color: Optional[Color] = None # leaf node text color (default: same as text_color)
|
|
295
|
+
edge_color: Color = 'black'
|
|
296
|
+
node_bgcolor: Color = 'white' # background color for node rectangles
|
|
297
|
+
fontsize: float = 22.0
|
|
298
|
+
fontweight: str = 'bold'
|
|
299
|
+
fontfamily: str = 'monospace'
|
|
300
|
+
linewidth: float = 1.0 # edge line thickness
|
|
301
|
+
ypad_top: float = 0.05 # space between node text and arrow above
|
|
302
|
+
ypad_bottom: float = 0.09 # space between node text and arrow below (may exceed ypad_top because some characters descend below baseline)
|
|
303
|
+
axis_scale: float = 1.3 # converts data coordinates to inches
|
|
304
|
+
margin: float = 0.05 # outer margin of figure
|
|
305
|
+
dpi: int = 100 # dots per inch of figure
|
|
306
|
+
|
|
307
|
+
def draw(self, tree: BaseTree[tuple[BoxPair, T]], filename: Optional[str] = None) -> None:
|
|
308
|
+
"""Given a tree of ((parent box, full box), node) pairs, draws the tree with matplotlib using the given settings.
|
|
309
|
+
If a filename is provided, saves the plot to this file; otherwise, displays the plot."""
|
|
310
|
+
# TODO: 'bottom-up' mode can result in lines that cross, which is ugly.
|
|
311
|
+
# see: https://github.com/jeremander/rosetree/issues/2
|
|
312
|
+
from matplotlib.patches import Rectangle # type: ignore[import-not-found]
|
|
313
|
+
import matplotlib.pyplot as plt # type: ignore[import-not-found]
|
|
314
|
+
((_, full_box), _) = tree.node
|
|
315
|
+
# convert box dimensions from characters to inches
|
|
316
|
+
(axis_width, axis_height) = (self.axis_scale * full_box.width, self.axis_scale * full_box.height)
|
|
317
|
+
(fig_width, fig_height) = (axis_width + 2 * self.margin, axis_height + 2 * self.margin)
|
|
318
|
+
plt.close(0)
|
|
319
|
+
fig = plt.figure(0, figsize=(fig_width, fig_height), dpi=self.dpi)
|
|
320
|
+
(xmargin, ymargin) = (self.margin / fig_width, self.margin / fig_height)
|
|
321
|
+
ax = fig.add_axes((xmargin, ymargin, 1.0 - 2 * xmargin, 1.0 - 2 * ymargin))
|
|
322
|
+
def _draw(node: tuple[BoxPair, T], children: Sequence[tuple[BoxPair, T]]) -> tuple[BoxPair, T]:
|
|
323
|
+
((box, _), label) = node
|
|
324
|
+
# draw node bounding box
|
|
325
|
+
rect = Rectangle((box.x, box.y - box.height), box.width, box.height, facecolor=self.node_bgcolor)
|
|
326
|
+
ax.add_patch(rect)
|
|
327
|
+
# draw text
|
|
328
|
+
if (len(children) == 0) and (self.leaf_text_color is not None):
|
|
329
|
+
text_color = self.leaf_text_color
|
|
330
|
+
else:
|
|
331
|
+
text_color = self.text_color
|
|
332
|
+
ax.text(
|
|
333
|
+
box.x,
|
|
334
|
+
box.y - box.height,
|
|
335
|
+
str(label),
|
|
336
|
+
color=text_color,
|
|
337
|
+
fontsize=self.fontsize,
|
|
338
|
+
fontweight=self.fontweight,
|
|
339
|
+
fontfamily=self.fontfamily,
|
|
340
|
+
)
|
|
341
|
+
if self.linewidth > 0.0: # draw edges
|
|
342
|
+
(x1, y1) = (box.x + box.width / 2.0, box.y - box.height - self.ypad_bottom)
|
|
343
|
+
for ((child_box, _), _) in children:
|
|
344
|
+
(x2, y2) = (child_box.x + child_box.width / 2.0, child_box.y + self.ypad_top)
|
|
345
|
+
ax.arrow(
|
|
346
|
+
x1,
|
|
347
|
+
y1,
|
|
348
|
+
x2 - x1,
|
|
349
|
+
y2 - y1,
|
|
350
|
+
color=self.edge_color,
|
|
351
|
+
linewidth=self.linewidth,
|
|
352
|
+
head_width=0.0,
|
|
353
|
+
head_length=0.0,
|
|
354
|
+
)
|
|
355
|
+
return node
|
|
356
|
+
tree.fold(_draw)
|
|
357
|
+
xchar = self.layout_options.xchar
|
|
358
|
+
ychar = self.layout_options.ychar
|
|
359
|
+
ax.set_xlim((full_box.x - xchar, full_box.x + full_box.width + xchar))
|
|
360
|
+
ax.set_ylim((full_box.y - full_box.height - ychar, full_box.y + ychar))
|
|
361
|
+
# remove spines
|
|
362
|
+
for spine in ax.spines.values():
|
|
363
|
+
spine.set_visible(False)
|
|
364
|
+
# remove ticks
|
|
365
|
+
ax.set_xticks([])
|
|
366
|
+
ax.set_yticks([])
|
|
367
|
+
# set equal aspect ratio, adjust axis limits to fit the data tightly
|
|
368
|
+
ax.axis('image')
|
|
369
|
+
if filename is None: # display plot
|
|
370
|
+
plt.show()
|
|
371
|
+
else: # save plot to file
|
|
372
|
+
plt.savefig(filename)
|