-
-
Notifications
You must be signed in to change notification settings - Fork 245
Expand file tree
/
Copy path_path.py
More file actions
501 lines (445 loc) · 18 KB
/
_path.py
File metadata and controls
501 lines (445 loc) · 18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
import matplotlib as mpl
import numpy as np
from matplotlib.dates import DateConverter, num2date
from matplotlib.markers import MarkerStyle
from . import _color, _files
from ._axes import _mpl_cmap2pgf_cmap
from ._hatches import _mpl_hatch2pgfp_pattern
from ._markers import _mpl_marker2pgfp_marker
from ._util import get_legend_text, has_legend
def draw_path(data, path, draw_options=None, simplify=None):
"""Adds code for drawing an ordinary path in PGFPlots (TikZ)."""
# For some reasons, matplotlib sometimes adds void paths which consist of
# only one point and have 0 fill opacity. To not let those clutter the
# output TeX file, bail out here.
if (
len(path.vertices) == 2
and all(path.vertices[0] == path.vertices[1])
and "fill opacity=0" in draw_options
):
return data, "", None, False
x_is_date = isinstance(data["current mpl axes obj"].xaxis.converter, DateConverter)
nodes = []
ff = data["float format"]
xformat = "" if x_is_date else ff
prev = None
is_area = None
for vert, code in path.iter_segments(simplify=simplify):
# nschloe, Oct 2, 2015:
# The transform call yields warnings and it is unclear why. Perhaps
# the input data is not suitable? Anyhow, this should not happen.
# Comment out for now.
# vert = np.asarray(
# _transform_to_data_coordinates(obj, [vert[0]], [vert[1]])
# )
# For path codes see: http://matplotlib.org/api/path_api.html
#
# if code == mpl.path.Path.STOP: pass
is_area = False
if code == mpl.path.Path.MOVETO:
if x_is_date:
vert = [num2date(vert[0]), vert[1]]
nodes.append(f"(axis cs:{vert[0]:{xformat}},{vert[1]:{ff}})")
elif code == mpl.path.Path.LINETO:
if x_is_date:
vert = [num2date(vert[0]), vert[1]]
nodes.append(f"--(axis cs:{vert[0]:{xformat}},{vert[1]:{ff}})")
elif code == mpl.path.Path.CURVE3:
# Quadratic Bezier curves aren't natively supported in TikZ, but
# can be emulated as cubic Beziers.
# From
# http://www.latex-community.org/forum/viewtopic.php?t=4424&f=45:
# If you really need a quadratic Bézier curve on the points P0, P1
# and P2, then a process called 'degree elevation' yields the cubic
# control points (Q0, Q1, Q2 and Q3) as follows:
# CODE: SELECT ALL
# Q0 = P0
# Q1 = 1/3 P0 + 2/3 P1
# Q2 = 2/3 P1 + 1/3 P2
# Q3 = P2
#
# P0 is the point of the previous step which is needed to compute
# Q1.
#
# Cannot draw quadratic Bezier curves as the beginning of of a path
assert prev is not None
Q1 = 1.0 / 3.0 * prev + 2.0 / 3.0 * vert[0:2]
Q2 = 2.0 / 3.0 * vert[0:2] + 1.0 / 3.0 * vert[2:4]
Q3 = vert[2:4]
if x_is_date:
Q1 = [num2date(Q1[0]), Q1[1]]
Q2 = [num2date(Q2[0]), Q2[1]]
Q3 = [num2date(Q3[0]), Q3[1]]
nodes.append(
".. controls "
f"(axis cs:{Q1[0]:{xformat}},{Q1[1]:{ff}}) and "
f"(axis cs:{Q2[0]:{xformat}},{Q2[1]:{ff}}) .. "
f"(axis cs:{Q3[0]:{xformat}},{Q3[1]:{ff}})"
)
elif code == mpl.path.Path.CURVE4:
# Cubic Bezier curves.
if x_is_date:
vert = [
num2date(vert[0]),
vert[1],
num2date(vert[2]),
vert[3],
num2date(vert[4]),
vert[5],
]
nodes.append(
".. controls "
f"(axis cs:{vert[0]:{xformat}},{vert[1]:{ff}}) and "
f"(axis cs:{vert[2]:{xformat}},{vert[3]:{ff}}) .. "
f"(axis cs:{vert[4]:{xformat}},{vert[5]:{ff}})"
)
else:
assert code == mpl.path.Path.CLOSEPOLY
nodes.append("--cycle")
is_area = True
# Store the previous point for quadratic Beziers.
prev = vert[0:2]
do = "[{}]".format(", ".join(draw_options)) if draw_options else ""
path_command = "\\path {}\n{};\n".format(do, "\n".join(nodes))
return data, path_command, draw_options, is_area
def draw_pathcollection(data, obj):
"""Returns PGFPlots code for a number of patch objects."""
content = []
# gather data
assert obj.get_offsets() is not None
labels = ["x", "y"]
dd = obj.get_offsets()
fmt = "{:" + data["float format"] + "}"
dd_strings = np.array([[fmt.format(val) for val in row] for row in dd])
draw_options = ["only marks"]
table_options = []
is_filled = False
if obj.get_array() is not None:
dd_strings = np.column_stack([dd_strings, obj.get_array()])
labels.append("colordata")
draw_options.append("scatter src=explicit")
table_options.extend(["x=x", "y=y", "meta=colordata"])
ec = None
fc = None
ls = None
marker0 = None
if obj.get_cmap():
mycolormap, is_custom_cmap = _mpl_cmap2pgf_cmap(obj.get_cmap(), data)
draw_options.append("scatter")
draw_options.append(
"colormap" + ("=" if is_custom_cmap else "/") + mycolormap
)
else:
# gather the draw options
add_individual_color_code = False
try:
ec = obj.get_edgecolors()
except TypeError:
ec = None
else:
if len(ec) == 0:
ec = None
elif len(ec) == 1:
ec = ec[0]
else:
assert len(ec) == len(dd)
labels.append("draw")
ec_strings = [
",".join(fmt.format(item) for item in row)
for row in ec[:, :3] * 255
]
dd_strings = np.column_stack([dd_strings, ec_strings])
add_individual_color_code = True
ec = None
try:
fc = obj.get_facecolors()
except TypeError:
fc = None
else:
if len(fc) == 0:
fc = None
elif len(fc) == 1:
fc = fc[0]
is_filled = True
else:
assert len(fc) == len(dd)
labels.append("fill")
fc_strings = [
",".join(fmt.format(item) for item in row)
for row in fc[:, :3] * 255
]
dd_strings = np.column_stack([dd_strings, fc_strings])
add_individual_color_code = True
fc = None
is_filled = True
try:
ls = obj.get_linestyle()[0]
except (TypeError, IndexError):
ls = None
if add_individual_color_code:
draw_options.extend(
[
"scatter",
"visualization depends on={value \\thisrow{draw} \\as \\drawcolor}",
"visualization depends on={value \\thisrow{fill} \\as \\fillcolor}",
"scatter/@pre marker code/.code={%\n"
+ " \\expanded{%\n"
+ " \\noexpand\\definecolor{thispointdrawcolor}{RGB}{\\drawcolor}%\n"
+ " \\noexpand\\definecolor{thispointfillcolor}{RGB}{\\fillcolor}%\n"
+ " }%\n"
+ " \\scope[draw=thispointdrawcolor, fill=thispointfillcolor]%\n"
+ "}",
"scatter/@post marker code/.code={%\n \\endscope\n}",
]
)
# "solution" from
# <https://github.com/matplotlib/matplotlib/issues/4672#issuecomment-378702670>
marker0 = None
if obj.get_paths():
p = obj.get_paths()[0]
ms = {style: MarkerStyle(style) for style in MarkerStyle.markers}
paths = {
style: marker.get_path().transformed(marker.get_transform())
for style, marker in ms.items()
}
for marker, path in paths.items():
if (
np.array_equal(path.codes, p.codes)
and (path.vertices.shape == p.vertices.shape)
and np.max(np.abs(path.vertices - p.vertices)) < 1.0e-10
):
marker0 = marker
break
is_contour = len(dd) == 1
if is_contour:
draw_options = ["draw=none"]
if marker0 is not None:
data, pgfplots_marker, marker_options = _mpl_marker2pgfp_marker(
data, marker0, is_filled
)
draw_options += [f"mark={pgfplots_marker}"]
if marker_options:
draw_options += ["mark options={{{}}}".format(",".join(marker_options))]
# `only mark` plots don't need linewidth
data, extra_draw_options = get_draw_options(data, obj, ec, fc, ls, None)
draw_options += extra_draw_options
legend_text = get_legend_text(obj)
if legend_text is None and has_legend(obj.axes):
draw_options.append("forget plot")
for path in obj.get_paths():
if is_contour:
dd = path.vertices
# https://matplotlib.org/stable/api/path_api.html
codes = (
path.codes
if path.codes is not None
else np.array([1] + [2] * (len(dd) - 1))
)
dd_strings = []
for row, code in zip(dd, codes):
if code == 1: # MOVETO
# Inserts a newline to trigger "move to" in pgfplots
dd_strings.append([])
dd_strings.append([fmt.format(val) for val in row])
dd_strings = np.array(dd_strings[1:], dtype=object)
if len(obj.get_sizes()) == len(dd):
# See Pgfplots manual, chapter 4.25.
# In Pgfplots, \mark size specifies radii, in matplotlib circle areas.
radii = np.sqrt(obj.get_sizes() / np.pi)
dd_strings = np.column_stack([dd_strings, radii])
labels.append("sizedata")
draw_options.extend(
[
"visualization depends on="
+ "{\\thisrow{sizedata} \\as\\perpointmarksize}",
"scatter",
"scatter/@pre marker code/.append style="
+ "{/tikz/mark size=\\perpointmarksize}",
# "scatter/@post marker code/.style={}"
]
)
# remove duplicates
draw_options = sorted(list(set(draw_options)))
len_row = sum(len(item) for item in draw_options)
j0, j1, j2 = ("", ", ", "") if len_row < 80 else ("\n ", ",\n ", "\n")
do = f" [{j0}{{}}{j2}]".format(j1.join(draw_options)) if draw_options else ""
content.append(f"\\addplot{do}\n")
if data["externals search path"] is not None:
esp = data["externals search path"]
table_options.append(f"search path={{{esp}}}")
if len(table_options) > 0:
table_options_str = ", ".join(table_options)
content.append(f"table [{table_options_str}]{{")
else:
content.append("table{")
plot_table = []
plot_table.append(" ".join(labels) + "\n")
for row in dd_strings:
plot_table.append(" ".join(row) + "\n")
if data["externalize tables"]:
filepath, rel_filepath = _files.new_filepath(data, "table", ".dat")
with open(filepath, "w") as f:
# No encoding handling required: plot_table is only ASCII
f.write("".join(plot_table))
content.append(str(rel_filepath))
else:
content.append("%\n")
content.extend(plot_table)
content.append("};\n")
if legend_text is not None:
content.append(f"\\addlegendentry{{{legend_text}}}\n")
return data, content
def get_draw_options(data, obj, ec, fc, ls, lw, hatch=None):
"""Get the draw options for a given (patch) object.
Get the draw options for a given (patch) object.
Input:
data -
obj -
ec - edge color
fc - face color
ls - linestyle
lw - linewidth
hatch=None - hatch, i.e., pattern within closed path
Output:
draw_options - list
"""
draw_options = []
if ec is not None:
data, ec_col, ec_rgba = _color.mpl_color2xcolor(data, ec)
if ec_rgba[3] > 0:
draw_options.append(f"draw={ec_col}")
else:
draw_options.append("draw=none")
if fc is not None:
data, fc_col, fc_rgba = _color.mpl_color2xcolor(data, fc)
if fc_rgba[3] > 0.0:
# Don't draw if it's invisible anyways.
draw_options.append(f"fill={fc_col}")
# handle transparency
ff = data["float format"]
if (
ec is not None
and fc is not None
and ec_rgba[3] != 1.0
and ec_rgba[3] == fc_rgba[3]
):
draw_options.append(f"opacity={ec[3]:{ff}}")
else:
if ec is not None and 0 < ec_rgba[3] < 1.0:
draw_options.append(f"draw opacity={ec_rgba[3]:{ff}}")
if fc is not None and 0 < fc_rgba[3] < 1.0:
draw_options.append(f"fill opacity={fc_rgba[3]:{ff}}")
if lw is not None:
lw_ = mpl_linewidth2pgfp_linewidth(data, lw)
if lw_:
draw_options.append(lw_)
if ls is not None:
ls_ = mpl_linestyle2pgfplots_linestyle(data, ls)
if ls_ is not None and ls_ != "solid":
draw_options.append(ls_)
if hatch is not None:
# In matplotlib hatches are rendered with edge color and linewidth
# In PGFPlots patterns are rendered in 'pattern color' which defaults to
# black and according to opacity fill.
# No 'pattern line width' option exist.
# This can be achieved with custom patterns, see _hatches.py
# There exist an obj.get_hatch_color() method in the mpl API,
# but it seems to be unused
try:
hc = obj._hatch_color
except AttributeError: # Fallback to edge color
if ec is None or ec_rgba[3] == 0.0:
# Assuming that a hatch marker indicates that hatches are wanted, also
# when the edge color is (0, 0, 0, 0), i.e., the edge is invisible
h_col, h_rgba = "black", np.array([0, 0, 0, 1])
else:
h_col, h_rgba = ec_col, ec_rgba
else:
data, h_col, h_rgba = _color.mpl_color2xcolor(data, hc)
finally:
if h_rgba[3] > 0:
data, pattern = _mpl_hatch2pgfp_pattern(data, hatch, h_col, h_rgba)
draw_options += pattern
return data, draw_options
def mpl_linewidth2pgfp_linewidth(data, line_width):
# PGFplots gives line widths in pt, matplotlib in axes space. Translate.
# Scale such that the default mpl line width (1.5) is mapped to the PGFplots
# line with semithick, 0.6. From a visual comparison, semithick or even thick
# matches best with the default mpl style.
# Keep the line with in units of decipoint to make sure we stay in integers.
line_width_decipoint = line_width * 4 # 4 = 10 * 0.6 / 1.5
try:
# https://github.com/pgf-tikz/pgf/blob/e9c22dc9fe48f975b7fdb32181f03090b3747499/tex/generic/pgf/frontendlayer/tikz/tikz.code.tex#L1574
return {
1: "ultra thin",
2: "very thin",
4: None, # "thin",
6: "semithick",
8: "thick",
12: "very thick",
16: "ultra thick",
}[line_width_decipoint]
except KeyError:
# explicit line width
ff = data["float format"]
return f"line width={line_width_decipoint / 10:{ff}}pt"
def mpl_linestyle2pgfplots_linestyle(data, line_style, line=None):
"""Translates a line style of matplotlib to the corresponding style
in PGFPlots.
"""
# linestyle is a string or dash tuple. Legal string values are
# solid|dashed|dashdot|dotted. The dash tuple is (offset, onoffseq) where onoffseq
# is an even length tuple of on and off ink in points.
#
# solid: [(None, None), (None, None), ..., (None, None)]
# dashed: (0, (6.0, 6.0))
# dotted: (0, (1.0, 3.0))
# dashdot: (0, (3.0, 5.0, 1.0, 5.0))
ff = data["float format"]
if isinstance(line_style, tuple):
if line_style[0] is None or line_style[1] is None:
return None
if len(line_style[1]) == 2:
return (
"dash pattern="
f"on {line_style[1][0]:{ff}}pt off {line_style[1][1]:{ff}}pt"
)
assert len(line_style[1]) == 4
return (
"dash pattern="
f"on {line_style[1][0]:{ff}}pt "
f"off {line_style[1][1]:{ff}}pt "
f"on {line_style[1][2]:{ff}}pt "
f"off {line_style[1][3]:{ff}}pt"
)
if isinstance(line, mpl.lines.Line2D) and line.is_dashed():
# see matplotlib.lines.Line2D.set_dashes
# get defaults
default_dashOffset, default_dashSeq = mpl.lines._get_dash_pattern(line_style)
# get dash format of line under test
dashOffset = line._dash_pattern[0]
dashSeq = line._dash_pattern[1]
lst = list()
if dashSeq != default_dashSeq:
# generate own dash sequence
lst.append(
"dash pattern="
+ " ".join(
f"on {a:{ff}}pt off {b:{ff}}pt"
for a, b in zip(dashSeq[0::2], dashSeq[1::2])
)
)
if dashOffset != default_dashOffset:
lst.append(f"dash phase={dashOffset}pt")
if len(lst) > 0:
return ", ".join(lst)
return {
"": None,
"None": None,
"none": None, # happens when using plt.boxplot()
"-": "solid",
"solid": "solid",
":": "dotted",
"--": "dashed",
"-.": "dash pattern=on 1pt off 3pt on 3pt off 3pt",
}[line_style]