'])
65 | """
66 | HTML template used by `Picture.demo` for notebook display
67 |
68 | The template must contain two placeholders: `{0}` is replaced by a
69 | Base64-encoded PNG-format rendering of the graphic, `{1}`by the output of
70 | `Picture.code`.
71 | """
72 |
73 |
74 | # helper functions and helper-helper functions
75 |
76 |
77 | def _option_code(key, val):
78 | """
79 | returns TikZ code for single option
80 |
81 | helper function for `_options`
82 | """
83 | # replace underscores by spaces
84 | key = str(key).replace('_', ' ')
85 | if val is True:
86 | # omit `=True`
87 | return key
88 | else:
89 | return f'{key}={str(val)}'
90 |
91 |
92 | def _options_code(opt=None, **kwoptions):
93 | """
94 | returns TikZ code for options
95 |
96 | helper function to format `opt=None, **kwoptions` in various functions
97 | """
98 | # use `_option_code` to transform individual options
99 | o = [_option_code(key, val) for key, val in kwoptions.items()
100 | if val is not None]
101 | # insert raw string
102 | if opt is not None:
103 | o.insert(0, opt)
104 | # create TikZ code
105 | code = '[' + ','.join(o) + ']'
106 | # suppress empty options
107 | if code == '[]':
108 | code = ''
109 | return code
110 |
111 |
112 | # check types
113 | def _str(obj): return isinstance(obj, str)
114 | def _tuple(obj): return isinstance(obj, tuple)
115 | def _numeric(obj): return isinstance(obj, numbers.Real)
116 | def _str_or_numeric(obj): return _str(obj) or _numeric(obj)
117 | def _ndarray(obj): return isinstance(obj, np.ndarray)
118 | def _list(obj): return isinstance(obj, list) # noqa E302
119 |
120 |
121 | def _coordinate(coord):
122 | """
123 | check and normalize coordinate
124 | """
125 | # A coordinate can be a string with enclosing parentheses, possibly
126 | # prefixed by `+` or `++`, or the string 'cycle'.
127 | if _str(coord) and (
128 | (coord.startswith(('(', '+(', '++(')) and coord.endswith(')'))
129 | or coord == 'cycle'):
130 | return coord
131 | # A coordinate can be a 2/3-element tuple containing strings or numbers:
132 | if (_tuple(coord) and len(coord) in [2, 3]
133 | and all(_str_or_numeric(x) for x in coord)):
134 | # If all strings, normalize to string.
135 | if all(_str(x) for x in coord):
136 | return '(' + ','.join(coord) + ')'
137 | # If all numbers, normalize to ndarray.
138 | if all(_numeric(x) for x in coord):
139 | return np.array(coord)
140 | # If mixed, keep.
141 | return coord
142 | # A coordinate can be a 2/3-element 1d-ndarray.
143 | if (_ndarray(coord) and coord.ndim == 1 and coord.size in [2, 3]
144 | and all(_numeric(x) for x in coord)):
145 | return coord
146 | # Otherwise, report error.
147 | raise TypeError(f'{coord} is not a coordinate')
148 |
149 |
150 | def _sequence(seq, accept_coordinate=True):
151 | """
152 | check and normalize sequence of coordinates
153 |
154 | accept_coordinate: whether to accept a single coordinate
155 | """
156 | # A sequence can be a list.
157 | if _list(seq):
158 | # Normalize contained coordinates.
159 | seq = [_coordinate(coord) for coord in seq]
160 | # If all coordinates are 1d-ndarrays, make the sequence a 2d-ndarray.
161 | if (all(_ndarray(coord) for coord in seq)
162 | and all(coord.size == seq[0].size for coord in seq)):
163 | return np.array(seq)
164 | return seq
165 | # A sequence can be a numeric 2d-ndarray with 2 or 3 columns.
166 | if (_ndarray(seq) and seq.ndim == 2 and seq.shape[1] in [2, 3]
167 | and all(_numeric(x) for x in seq.flat)):
168 | return seq
169 | # Optionally accept a coordinate and turn it into a 1-element sequence.
170 | if accept_coordinate:
171 | return _sequence([seq])
172 | # Otherwise, report error.
173 | raise TypeError(f'{seq} is not a sequence of coordinates')
174 |
175 |
176 | def _str_or_numeric_code(x):
177 | """
178 | transform element of coordinate into TikZ representation
179 |
180 | Leaves string elements as is, and converts numeric elements to a
181 | fixed-point representation with 5 decimals precision (TikZ: ±16383.99999)
182 | without trailing '0's or '.'
183 | """
184 | if _str(x):
185 | # leave string as-is
186 | return x
187 | else:
188 | # convert numeric elements to a fixed-point representation with 5
189 | # decimals precision (TikZ: ±16383.99999) without trailing '0's or '.'
190 | return '{:.5f}'.format(x).rstrip('0').rstrip('.')
191 |
192 |
193 | def _coordinate_code(coord, trans=None):
194 | "returns TikZ code for coordinate"
195 | # assumes the argument has already been normalized
196 | if _str(coord):
197 | # leave string as-is
198 | return coord
199 | else:
200 | if trans is not None:
201 | coord = trans(coord)
202 | return '(' + ','.join(map(_str_or_numeric_code, coord)) + ')'
203 |
204 |
205 | # coordinates
206 |
207 |
208 | def cycle():
209 | "cycle coordinate"
210 | return 'cycle'
211 |
212 |
213 | # raw object
214 |
215 | class Raw:
216 | """
217 | raw TikZ code object
218 |
219 | In order to support TikZ features that are not explicitly modelled, objects
220 | of this class encapsulate a string which is copied as-is into the TikZ
221 | code. `Raw` objects can be used in place of `Operation` and `Action`
222 | objects. Normally it is not necessary to explicily instantiate this class,
223 | because the respective methods accept strings and convert them into `Raw`
224 | objects internally.
225 | """
226 | def __init__(self, string):
227 | self.string = string
228 |
229 | def _code(self, trans=None):
230 | """
231 | returns TikZ code
232 |
233 | Returns the stored string.
234 | """
235 | return self.string
236 |
237 |
238 | # path operations (§14)
239 |
240 |
241 | class Operation:
242 | """
243 | path operation
244 |
245 | Path operations are modelled as `Operation` objects.
246 |
247 | Names for `Operation` subclasses are lowercase, because from a user
248 | perspective they act like functions; no method call or field access should
249 | be performed on their instances.
250 |
251 | This is an abstract superclass that is not to be instantiated.
252 | """
253 | def _code(self):
254 | "returns TikZ code"
255 | pass
256 |
257 |
258 | class moveto(Operation):
259 | """
260 | one or several move-to operations
261 |
262 | `coords` can be a coordinate or a sequence of coordinates.
263 |
264 | See [§14.1](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.1)
265 | """
266 | def __init__(self, coords):
267 | # normalize coordinates
268 | self.coords = _sequence(coords, accept_coordinate=True)
269 |
270 | def _code(self, trans=None):
271 | # put move-to operation before each coordinate,
272 | # for the first one implicitly
273 | return ' '.join(_coordinate_code(coord, trans)
274 | for coord in self.coords)
275 |
276 |
277 | class lineto(Operation):
278 | """
279 | one or several line-to operations of the same type
280 |
281 | `coords` can be a coordinate or a sequence of coordinates.
282 |
283 | `op` can be `'--'` for straight lines (default), `'-|'` for first
284 | horizontal, then vertical, or `'|-'` for first vertical, then horizontal.
285 |
286 | see [§14.2](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.2)
287 | """
288 | def __init__(self, coords, op='--'):
289 | # normalize coordinates
290 | self.coords = _sequence(coords, accept_coordinate=True)
291 | self.op = op
292 |
293 | def _code(self, trans=None):
294 | # put line-to operation before each coordinate
295 | return f'{self.op} ' + f' {self.op} '.join(
296 | _coordinate_code(coord, trans) for coord in self.coords)
297 |
298 |
299 | class line(Operation):
300 | """
301 | convenience version of `lineto`
302 |
303 | Starts with move-to instead of line-to operation.
304 | """
305 | def __init__(self, coords, op='--'):
306 | # normalize coordinates
307 | self.coords = _sequence(coords)
308 | self.op = op
309 |
310 | def _code(self, trans=None):
311 | # put line-to operation between coordinates
312 | # (implicit move-to before first)
313 | return f' {self.op} '.join(
314 | _coordinate_code(coord, trans) for coord in self.coords)
315 |
316 |
317 | class curveto(Operation):
318 | """
319 | curve-to operation
320 |
321 | `coord`, `control1`, and the optional `control2` must be coordinates.
322 |
323 | see [§14.3](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.3)
324 | """
325 | def __init__(self, coord, control1, control2=None):
326 | # normalize coordinates
327 | self.coord = _coordinate(coord)
328 | self.control1 = _coordinate(control1)
329 | if control2 is not None:
330 | self.control2 = _coordinate(control2)
331 | else:
332 | self.control2 = None
333 |
334 | def _code(self, trans=None):
335 | code = '.. controls ' + _coordinate_code(self.control1, trans)
336 | if self.control2 is not None:
337 | code += ' and ' + _coordinate_code(self.control2, trans)
338 | code += ' ..' + ' ' + _coordinate_code(self.coord, trans)
339 | return code
340 |
341 |
342 | class rectangle(Operation):
343 | """
344 | rectangle operation
345 |
346 | `coord` must be a coordinate
347 |
348 | see [§14.4](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.4)
349 | """
350 | def __init__(self, coord):
351 | # normalize coordinate
352 | self.coord = _coordinate(coord)
353 |
354 | def _code(self, trans=None):
355 | return ('rectangle ' + _coordinate_code(self.coord, trans))
356 |
357 |
358 | class circle(Operation):
359 | """
360 | circle operation
361 |
362 | Either `radius` or `x_radius` and `y_radius` (for an ellipse) must be
363 | given. If all are specified, `radius` overrides the other two options. They
364 | can be numbers or a string containing a number and a dimension.
365 |
366 | The circle is centered at the current coordinate, unless another coordinate
367 | is given as `at`.
368 |
369 | see [§14.6](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.6)
370 | """
371 | def __init__(self, radius=None, x_radius=None, y_radius=None, at=None,
372 | opt=None, **kwoptions):
373 | # overriding logic
374 | # Information is stored as separate radii to enable scaling.
375 | if radius is not None:
376 | self.x_radius = radius
377 | self.y_radius = radius
378 | else:
379 | self.x_radius = x_radius
380 | self.y_radius = y_radius
381 | # normalize coordinate
382 | if at is not None:
383 | self.at = _coordinate(at)
384 | else:
385 | self.at = None
386 | self.opt = opt
387 | self.kwoptions = kwoptions
388 |
389 | def _code(self, trans=None):
390 | kwoptions = self.kwoptions
391 | x_radius, y_radius = self.x_radius, self.y_radius
392 | if trans is not None:
393 | x_radius, y_radius = trans(x_radius, y_radius)
394 | if x_radius == y_radius:
395 | kwoptions['radius'] = x_radius
396 | else:
397 | kwoptions['x_radius'] = x_radius
398 | kwoptions['y_radius'] = y_radius
399 | if self.at is not None:
400 | kwoptions['at'] = _coordinate_code(self.at, None)
401 | return 'circle' + _options_code(opt=self.opt, **self.kwoptions)
402 |
403 |
404 | class arc(Operation):
405 | """
406 | arc operation
407 |
408 | Either `radius` or `x_radius` and `y_radius` (for an elliptical arc) must
409 | be given. If all are specified, `radius` overrides the other two options.
410 | They can be numbers or a string containing a number and a dimension.
411 |
412 | see [§14.7](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.7)
413 | """
414 | def __init__(self, radius=None, x_radius=None, y_radius=None,
415 | opt=None, **kwoptions):
416 | # overriding logic
417 | # Information is stored as separate radii to enable scaling.
418 | if radius is not None:
419 | self.x_radius = radius
420 | self.y_radius = radius
421 | else:
422 | self.x_radius = x_radius
423 | self.y_radius = y_radius
424 | self.opt = opt
425 | self.kwoptions = kwoptions
426 |
427 | def _code(self, trans=None):
428 | kwoptions = self.kwoptions
429 | x_radius, y_radius = self.x_radius, self.y_radius
430 | if trans is not None:
431 | x_radius, y_radius = trans(x_radius, y_radius)
432 | if x_radius == y_radius:
433 | kwoptions['radius'] = x_radius
434 | else:
435 | kwoptions['x_radius'] = x_radius
436 | kwoptions['y_radius'] = y_radius
437 | return 'arc' + _options_code(opt=self.opt, **kwoptions)
438 |
439 |
440 | class grid(Operation):
441 | """
442 | grid operation
443 |
444 | Either `step` or `xstep` and `ystep` must be given. If all are specified,
445 | `step` overrides the other two options. They can be numbers or a string
446 | containing a number and a dimension. Specifying `step` as a coordinate is
447 | not supported, use `xstep` and `ystep` instead.
448 |
449 | see [§14.8](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.8)
450 | """
451 | def __init__(self, coord, step=None, xstep=None, ystep=None,
452 | opt=None, **kwoptions):
453 | # normalize coordinate
454 | self.coord = _coordinate(coord)
455 | # overriding logic
456 | # Information is stored as separate radii to enable scaling.
457 | if step is not None:
458 | self.xstep = step
459 | self.ystep = step
460 | else:
461 | self.xstep = xstep
462 | self.ystep = ystep
463 | self.opt = opt
464 | self.kwoptions = kwoptions
465 |
466 | def _code(self, trans=None):
467 | kwoptions = self.kwoptions
468 | xstep, ystep = self.xstep, self.ystep
469 | if trans is not None:
470 | xstep, ystep = trans(xstep, ystep)
471 | if xstep == ystep:
472 | kwoptions['step'] = xstep
473 | else:
474 | kwoptions['xstep'] = xstep
475 | kwoptions['ystep'] = ystep
476 | return ('grid' + _options_code(opt=self.opt, **kwoptions)
477 | + ' ' + _coordinate_code(self.coord, trans))
478 |
479 |
480 | class parabola(Operation):
481 | """
482 | parabola operation
483 |
484 | `coord` and the optional `bend` must be coordinates.
485 |
486 | see [§14.9](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.9)
487 | """
488 | def __init__(self, coord, bend=None, opt=None, **kwoptions):
489 | # normalize coordinates
490 | self.coord = _coordinate(coord)
491 | if bend is not None:
492 | self.bend = _coordinate(bend)
493 | else:
494 | self.bend = None
495 | self.opt = opt
496 | self.kwoptions = kwoptions
497 |
498 | def _code(self, trans=None):
499 | code = 'parabola' + _options_code(opt=self.opt, **self.kwoptions)
500 | if self.bend is not None:
501 | code += ' bend ' + _coordinate_code(self.bend, trans)
502 | code += ' ' + _coordinate_code(self.coord, trans)
503 | return code
504 |
505 |
506 | class sin(Operation):
507 | """
508 | sine operation
509 |
510 | `coord` must be a coordinate.
511 |
512 | see [§14.10](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.10)
513 | """
514 | def __init__(self, coord, opt=None, **kwoptions):
515 | # normalize coordinate
516 | self.coord = _coordinate(coord)
517 | self.opt = opt
518 | self.kwoptions = kwoptions
519 |
520 | def _code(self, trans=None):
521 | return ('sin' + _options_code(opt=self.opt, **self.kwoptions)
522 | + ' ' + _coordinate_code(self.coord, trans))
523 |
524 |
525 | class cos(Operation):
526 | """
527 | cosine operation
528 |
529 | `coord` must be a coordinate.
530 |
531 | see [§14.10](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.10)
532 | """
533 | def __init__(self, coord, opt=None, **kwoptions):
534 | # normalize coordinate
535 | self.coord = _coordinate(coord)
536 | self.opt = opt
537 | self.kwoptions = kwoptions
538 |
539 | def _code(self, trans=None):
540 | return ('cos' + _options_code(opt=self.opt, **self.kwoptions)
541 | + ' ' + _coordinate_code(self.coord, trans))
542 |
543 |
544 | class topath(Operation):
545 | """
546 | to-path operation
547 |
548 | `coord` must be a coordinate.
549 |
550 | see [§14.13](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.14.13)
551 | """
552 | def __init__(self, coord, opt=None, **kwoptions):
553 | # normalize coordinate
554 | self.coord = _coordinate(coord)
555 | self.opt = opt
556 | self.kwoptions = kwoptions
557 |
558 | def _code(self, trans=None):
559 | return ('to' + _options_code(opt=self.opt, **self.kwoptions)
560 | + ' ' + _coordinate_code(self.coord, trans))
561 |
562 |
563 | class node(Operation):
564 | """
565 | node operation
566 |
567 | `contents` must be a string containing the node text, and may be LaTeX
568 | code.
569 |
570 | The optional `name` must be a string, which allows later references to the
571 | coordinate `(`name`)` in TikZ' node coordinate system.
572 |
573 | The node is positioned relative to the current coordinate, unless the
574 | optional coordinate `at` is given.
575 |
576 | Animation is not supported because it does not make sense for static
577 | image generation. The foreach statement for nodes is not supported because
578 | it can be replaced by a Python loop.
579 |
580 | see [§17](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#section.17)
581 | """
582 | # Provides 'headless' mode for `Scope.node` and `Scope.coordinate`
583 | def __init__(self, contents, name=None, at=None, _headless=False,
584 | opt=None, **kwoptions):
585 | self.name = name
586 | self.contents = contents
587 | # normalize coordinate
588 | if at is not None:
589 | self.at = _coordinate(at)
590 | else:
591 | self.at = None
592 | self.headless = _headless
593 | self.opt = opt
594 | self.kwoptions = kwoptions
595 |
596 | def _code(self, trans=None):
597 | if not self.headless:
598 | code = 'node'
599 | else:
600 | code = ''
601 | code += _options_code(opt=self.opt, **self.kwoptions)
602 | if self.name is not None:
603 | code += f' ({self.name})'
604 | if self.at is not None:
605 | code += ' at ' + _coordinate_code(self.at, trans)
606 | code += ' {' + self.contents + '}'
607 | if self.headless:
608 | code = code.lstrip()
609 | return code
610 |
611 |
612 | class coordinate(Operation):
613 | """
614 | coordinate operation
615 |
616 | `name` must be a string, which allows later references to the coordinate
617 | `(`name`)` in TikZ' node coordinate system.
618 |
619 | The node is positioned relative to the current coordinate, unless the
620 | optional coordinate `at` is given.
621 |
622 | Animation is not supported because it does not make sense for static
623 | image generation. The foreach statement for nodes is not supported because
624 | it can be replaced by a Python loop.
625 |
626 | see
627 | [§17.2.1](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.17.2.1)
628 | """
629 | def __init__(self, name, at=None, _headless=False, opt=None, **kwoptions):
630 | self.name = name
631 | # normalize coordinate
632 | if at is not None:
633 | self.at = _coordinate(at)
634 | else:
635 | self.at = None
636 | self.headless = _headless
637 | self.opt = opt
638 | self.kwoptions = kwoptions
639 |
640 | def _code(self, trans=None):
641 | if not self.headless:
642 | code = 'coordinate'
643 | else:
644 | code = ''
645 | code += _options_code(opt=self.opt, **self.kwoptions)
646 | code += f' ({self.name})'
647 | if self.at is not None:
648 | code += ' at ' + _coordinate_code(self.at, trans)
649 | if self.headless:
650 | code = code.lstrip()
651 | return code
652 |
653 |
654 | class plot(Operation):
655 | """
656 | plot operation
657 |
658 | `coords` can be a coordinate or a sequence of coordinates.
659 |
660 | The optional `to` determines whether a line-to operation is included before
661 | the plot operation.
662 |
663 | The difference between `plot coordinates` and `plot file` is not exposed;
664 | the decision whether to specify coordinates inline in the TikZ code or
665 | provide them through a file is made internally. Coordinate expressions and
666 | gnuplot formulas are not supported.
667 |
668 | see [§22](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#section.22)
669 | """
670 | def __init__(self, coords, to=False, opt=None, **kwoptions):
671 | # normalize coordinates
672 | self.coords = _sequence(coords, accept_coordinate=True)
673 | self.to = to
674 | self.opt = opt
675 | self.kwoptions = kwoptions
676 |
677 | def _code(self, trans=None):
678 | # TODO: Use the 'file' variant as an alternative to 'coordinates' when
679 | # there are many points.
680 | if self.to:
681 | code = '--plot'
682 | else:
683 | code = 'plot'
684 | code += _options_code(opt=self.opt, **self.kwoptions)
685 | code += ' coordinates {' + ' '.join(
686 | _coordinate_code(coord, trans) for coord in self.coords) + '}'
687 | return code
688 |
689 |
690 | def options(opt=None, **kwoptions):
691 | """
692 | in-path options
693 |
694 | Though this is not a path operation, it can be specified at an arbitrary
695 | position within a path specification. It sets options for the rest of the
696 | path (unless they are path-global).
697 | """
698 | # just a wrapper around _options_code
699 | return _options_code(opt=opt, **kwoptions)
700 |
701 |
702 | def fontsize(size, skip=None):
703 | """
704 | code for LaTeX command to change the font size
705 |
706 | Can be specified e.g. as the value of a `font=` option.
707 | """
708 | if skip is None:
709 | # 20% leading
710 | skip = round(1.2 * size, 2)
711 | return f'\\fontsize{{{size}}}{{{skip}}}\\selectfont'
712 |
713 |
714 | # actions on paths
715 |
716 | def _operation(op):
717 | """
718 | check and normalize path specification elements
719 |
720 | The elements of a path specification argument (`*spec`) can be `Operation`
721 | objects (left as is), (lists of) coordinates (converted to `moveto`
722 | objects), and strings (converted to `Raw` objects).
723 |
724 | helper function for `Action`
725 | """
726 | if isinstance(op, Operation):
727 | # leave `Operation` as is
728 | return op
729 | if _str(op):
730 | # convert string to `Raw` object
731 | return Raw(op)
732 | return moveto(op)
733 |
734 |
735 | class Action:
736 | """
737 | action on path
738 |
739 | Objects of this class are used to represent path actions. It is not
740 | normally necessary to instantiate this class, because `Action` objects are
741 | created and added implicitly by environment methods like
742 | [Picture.path()](#tikz.Scope.path).
743 |
744 | see [§15](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#section.15)
745 | """
746 | def __init__(self, action_name, *spec, opt=None, **kwoptions):
747 | self.action_name = action_name
748 | # normalize path specification
749 | self.spec = [_operation(op) for op in spec]
750 | self.opt = opt
751 | self.kwoptions = kwoptions
752 |
753 | def _code(self, trans=None):
754 | "returns TikZ code"
755 | return ('\\' + self.action_name
756 | + _options_code(opt=self.opt, **self.kwoptions)
757 | + ' ' + ' '.join(op._code(trans) for op in self.spec) + ';')
758 |
759 |
760 | # environments
761 |
762 |
763 | class Scope:
764 | """
765 | scope environment
766 |
767 | A scope can be used to group path actions and other commands together, so
768 | that options can be applied to them in total.
769 |
770 | Do not instantiate this class, but use the
771 | [scope()](#tikz.Scope.addscope) method of `Picture` or
772 | another environment.
773 |
774 | see
775 | [§12.3.1](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.12.3.1)
776 | """
777 |
778 | def __init__(self, opt=None, **kwoptions):
779 | self.elements = []
780 | self.opt = _options_code(opt=opt, **kwoptions)
781 |
782 | def _append(self, el):
783 | """
784 | append element
785 |
786 | Elements of an environment object can be `Action` objects (for path
787 | actions), `Raw` objects (for other commands), or other environment
788 | objects.
789 | """
790 | self.elements.append(el)
791 |
792 | def scope(self, opt=None, **kwoptions):
793 | """
794 | create and add scope to the current environment
795 |
796 | A `Scope` object is created, added, and returned.
797 | """
798 | s = Scope(opt=opt, **kwoptions)
799 | self._append(s)
800 | return s
801 |
802 | def _code(self, trans=None):
803 | "returns TikZ code"
804 | code = r'\begin{scope}' + self.opt + '\n'
805 | code += '\n'.join(el._code(trans) for el in self.elements) + '\n'
806 | code += r'\end{scope}'
807 | return code
808 |
809 | # add actions on paths (§15)
810 |
811 | def path(self, *spec, opt=None, **kwoptions):
812 | """
813 | path action
814 |
815 | The `path` path action is the prototype of all path actions. It
816 | represents a pure path, one that is not used for drawing, filling or
817 | other creation of visible elements, unless instructed to do so by
818 | options.
819 |
820 | `*spec` is one or more arguments giving the path specification,
821 | `opt=None, **kwoptions` can be used to specify options.
822 |
823 | see [§14](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#section.14)
824 | """
825 | self._append(Action('path', *spec, opt=opt, **kwoptions))
826 |
827 | def draw(self, *spec, opt=None, **kwoptions):
828 | """
829 | draw action
830 |
831 | Abbreviation for [path(…, draw=True)](#tikz.Scope.path).
832 |
833 | see
834 | [§15.3](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.15.3)
835 | """
836 | self._append(Action('draw', *spec, opt=opt, **kwoptions))
837 |
838 | def fill(self, *spec, opt=None, **kwoptions):
839 | """
840 | fill action
841 |
842 | Abbreviation for [path(…, fill=True)](#tikz.Scope.path).
843 |
844 | see
845 | [§15.5](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.15.5)
846 | """
847 | self._append(Action('fill', *spec, opt=opt, **kwoptions))
848 |
849 | def filldraw(self, *spec, opt=None, **kwoptions):
850 | """
851 | filldraw action
852 |
853 | Abbreviation for
854 | [path(…, fill=True, draw=True)](#tikz.Scope.path).
855 | """
856 | self._append(Action('filldraw', *spec, opt=opt, **kwoptions))
857 |
858 | def pattern(self, *spec, opt=None, **kwoptions):
859 | """
860 | pattern action
861 |
862 | Abbreviation
863 | for [path(…, pattern=True)](#tikz.Scope.path).
864 |
865 | see
866 | [§15.5.1](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.15.5.1)
867 | """
868 | self._append(Action('pattern', *spec, opt=opt, **kwoptions))
869 |
870 | def shade(self, *spec, opt=None, **kwoptions):
871 | """
872 | shade action
873 |
874 | Abbreviation for [path(…, shade=True)](#tikz.Scope.path).
875 |
876 | see
877 | [§15.7](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.15.7)
878 | """
879 | self._append(Action('shade', *spec, opt=opt, **kwoptions))
880 |
881 | def shadedraw(self, *spec, opt=None, **kwoptions):
882 | """
883 | shadedraw action
884 |
885 | Abbreviation for
886 | [path(…, shade=True, draw=True)](#tikz.Scope.path).
887 | """
888 | self._append(Action('shadedraw', *spec, opt=opt, **kwoptions))
889 |
890 | def clip(self, *spec, opt=None, **kwoptions):
891 | """
892 | clip action
893 |
894 | Abbreviation for [path(…, clip=True)](#tikz.Scope.path).
895 |
896 | see
897 | [§15.9](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.15.9)
898 | """
899 | self._append(Action('clip', *spec, opt=opt, **kwoptions))
900 |
901 | def useasboundingbox(self, *spec, opt=None, **kwoptions):
902 | """
903 | useasboundingbox action
904 |
905 | Abbreviation for
906 | [path(…, use_as_bounding_box=True)](#tikz.Scope.path).
907 |
908 | see
909 | [§15.8](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.15.8)
910 | """
911 | self._append(Action('useasboundingbox', *spec, opt=opt, **kwoptions))
912 |
913 | def node(self, contents, name=None, at=None, opt=None, **kwoptions):
914 | """
915 | node action
916 |
917 | Abbreviation for
918 | [path(node(…))](#tikz.node).
919 |
920 | see
921 | [§17.2.1](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.17.2.1)
922 | """
923 | self._append(Action(
924 | 'node', node(contents, name=name, at=at, _headless=True),
925 | opt=opt, **kwoptions))
926 |
927 | def coordinate(self, name, at=None, opt=None, **kwoptions):
928 | """
929 | coordinate action
930 |
931 | Abbreviation for
932 | [path(coordinate(…))](#tikz.coordinate).
933 |
934 | see
935 | [§17.2.1](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.17.2.1)
936 | """
937 | "coordinate action"
938 | self._append(Action(
939 | 'coordinate', coordinate(name=name, at=at, _headless=True),
940 | opt=opt, **kwoptions))
941 |
942 | # other commands
943 |
944 | def definecolor(self, name, colormodel, colorspec):
945 | """
946 | define a new color from a color specification
947 |
948 | Define a new color `name` from a color model `colormodel` and a color
949 | specification `colorspec`. All arguments are strings.
950 |
951 | see
952 | [xcolor
953 | §2.5.2](https://mirrors.nxthost.com/ctan/macros/latex/contrib/xcolor/xcolor.pdf#subsubsection.2.5.2)
954 | """
955 | if not isinstance(colorspec, str):
956 | colorspec = ','.join(colorspec)
957 | self._append(Raw(r'\definecolor' + '{' + name + '}{'
958 | + colormodel + '}{' + colorspec + '}'))
959 |
960 | def colorlet(self, name, colorexpr):
961 | """
962 | define a new color from a color expression
963 |
964 | Define a new color `name` from color expression `colorexpr`. All
965 | arguments are strings.
966 |
967 | see
968 | [xcolor
969 | §2.5.2](https://mirrors.nxthost.com/ctan/macros/latex/contrib/xcolor/xcolor.pdf#subsubsection.2.5.2)
970 | """
971 | self._append(Raw(r'\colorlet' + '{' + name + '}{' + colorexpr + '}'))
972 |
973 | def tikzset(self, opt=None, **kwoptions):
974 | """
975 | set options that apply for the rest of the current environment
976 |
977 | see
978 | [§12.4.1](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.12.4.1)
979 | """
980 | # create options string without brackets
981 | opt = _options_code(opt=opt, **kwoptions)
982 | if opt.startswith('[') and opt.endswith(']'):
983 | opt = opt[1:-1]
984 | # because braces are needed
985 | self._append(Raw(r'\tikzset{' + opt + '}'))
986 |
987 | def style(self, name, opt=None, **kwoptions):
988 | """
989 | define style
990 |
991 | Defines a new style `name` by the given options. In the following, this
992 | style can be used whereever options are accepted, and acts as if these
993 | options had been given directly. It can also be used to override
994 | TikZ' default styles like the default draw style.
995 |
996 | see
997 | [§12.4.2](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.12.4.2)
998 | """
999 | # create options string without brackets
1000 | opt = _options_code(opt=opt, **kwoptions)
1001 | if opt.startswith('[') and opt.endswith(']'):
1002 | opt = opt[1:-1]
1003 | # because braces are needed
1004 | self._append(Raw(r'\tikzset{' + name + '/.style={' + opt + '}}'))
1005 |
1006 |
1007 | class Picture(Scope):
1008 | """
1009 | tikzpicture environment
1010 |
1011 | This is the central class of the module. A picture is created by
1012 | instantiating `Picture` and calling its methods. The object represents both
1013 | the whole LaTeX document and its single `tikzpicture` environment.
1014 |
1015 | Set `tempdir` to use a specific directory for temporary files instead of an
1016 | automatically created one. Set `cache` to `False` if the picture should be
1017 | generated even though the TikZ code has not changed.
1018 |
1019 | see
1020 | [§12.2.1](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.12.2.1)
1021 | """
1022 |
1023 | def __init__(self, tempdir=None, cache=True, opt=None, **kwoptions):
1024 | super().__init__(opt=opt, **kwoptions)
1025 | # additional preamble entries
1026 | self.preamble = []
1027 | # should the created PDF be cached?
1028 | self.cache = cache
1029 | # create temporary directory for pdflatex etc.
1030 | if tempdir is None:
1031 | self.tempdir = tempfile.mkdtemp(prefix='tikz-')
1032 | # make sure it gets deleted
1033 | atexit.register(shutil.rmtree, self.tempdir, ignore_errors=True)
1034 | else:
1035 | self.tempdir = tempdir
1036 |
1037 | def add_preamble(self, code):
1038 | """
1039 | add code to preamble
1040 |
1041 | Adds arbitrary LaTeX code to the document preamble. Since the code will
1042 | typically contain backslash characters, use of a Python 'raw' string is
1043 | recommended.
1044 |
1045 | If the method is called multiple times with the same arguments, the
1046 | code is only added once.
1047 | """
1048 | if code not in self.preamble:
1049 | self.preamble.append(code)
1050 |
1051 | def usetikzlibrary(self, name):
1052 | """
1053 | use TikZ library
1054 |
1055 | Makes the functionality of the TikZ library `name` available.
1056 |
1057 | This adds a `\\usetikzlibrary` command to the preamble of the LaTeX
1058 | document. If the method is called multiple times with the same
1059 | arguments, only one such command is added.
1060 |
1061 | see
1062 | [Part V](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#part.5)
1063 | """
1064 | self.add_preamble(r'\usetikzlibrary{' + name + '}')
1065 |
1066 | def usepackage(self, name, options=None):
1067 | """
1068 | use LaTeX package
1069 |
1070 | Makes the functionality of the LaTeX package `name` available. If
1071 | specified, package options are set.
1072 |
1073 | This adds a `\\usepackage` command to the preamble of the LaTeX
1074 | document. If the method is called multiple times with the same
1075 | arguments, only one such command is added.
1076 | """
1077 | code = r'\usepackage'
1078 | if options is not None:
1079 | code += '[' + options + ']'
1080 | code += '{' + name + '}'
1081 | self.add_preamble(code)
1082 |
1083 | def fira(self):
1084 | """
1085 | set font to Fira, also for math
1086 |
1087 | Warning: Fira Math works only with xelatex and lualatex!
1088 | """
1089 | self.usepackage('FiraSans', 'sfdefault')
1090 | self.usepackage('unicode-math', 'mathrm=sym')
1091 | self.add_preamble(r'\setmathfont{Fira Math}[math-style=ISO,'
1092 | 'bold-style=ISO,nabla=upright,partial=upright]')
1093 |
1094 | # code / pdf creation: private
1095 | # private functions assume that code / pdf has already been created
1096 |
1097 | def _update(self, build=True):
1098 | "ensure that up-to-date code & PDF file exists"
1099 |
1100 | sep = os.path.sep
1101 |
1102 | # create tikzpicture code
1103 | code = (r'\begin{tikzpicture}' + self.opt + '\n'
1104 | + '\n'.join(el._code() for el in self.elements) + '\n'
1105 | + r'\end{tikzpicture}')
1106 | self._code = code
1107 |
1108 | # create document code
1109 | # standard preamble
1110 | codelines = [
1111 | r'\documentclass{article}',
1112 | r'\usepackage{tikz}',
1113 | r'\usetikzlibrary{external}',
1114 | r'\tikzexternalize']
1115 | # user-added preamble
1116 | codelines += self.preamble
1117 | # document body
1118 | codelines += [
1119 | r'\begin{document}',
1120 | self._code,
1121 | r'\end{document}']
1122 | code = '\n'.join(codelines)
1123 | self._document_code = code
1124 | if not build:
1125 | return
1126 |
1127 | # We don't want a PDF file of the whole LaTeX document, but only of the
1128 | # contents of the `tikzpicture` environment. This is achieved using
1129 | # TikZ' `external` library, which makes TikZ write out pictures as
1130 | # individual PDF files. To do so, in a normal pdflatex run TikZ calls
1131 | # pdflatex again with special arguments. We use these special
1132 | # arguments directly. See section 53 of the PGF/TikZ manual.
1133 |
1134 | # does the PDF file have to be created?
1135 | # This check is implemented by using the SHA1 digest of the LaTeX code
1136 | # in the PDF filename, and to skip creation if that file exists.
1137 | hash = hashlib.sha1(code.encode()).hexdigest()
1138 | self.temp_pdf = self.tempdir + sep + 'tikz-' + hash + '.pdf'
1139 | if self.cache and os.path.isfile(self.temp_pdf):
1140 | return
1141 |
1142 | # create LaTeX file
1143 | temp_tex = self.tempdir + sep + 'tikz.tex'
1144 | with open(temp_tex, 'w') as f:
1145 | f.write(code + '\n')
1146 |
1147 | # process LaTeX file into PDF
1148 | completed = subprocess.run(
1149 | [cfg.latex,
1150 | '-jobname',
1151 | 'tikz-figure0',
1152 | r'\def\tikzexternalrealjob{tikz}\input{tikz}'],
1153 | cwd=self.tempdir,
1154 | capture_output=True,
1155 | text=True)
1156 | self.latex_completed = completed
1157 | if completed.returncode != 0:
1158 | raise LatexError('LaTeX has failed\n' + completed.stdout)
1159 |
1160 | # rename created PDF file
1161 | os.rename(self.tempdir + sep + 'tikz-figure0.pdf', self.temp_pdf)
1162 |
1163 | def _get_SVG(self):
1164 | "return SVG data of `Picture`"
1165 | # convert PDF to SVG using PyMuPDF
1166 | doc = fitz.open(self.temp_pdf)
1167 | page = doc.load_page(0)
1168 | svg = page.get_svg_image()
1169 | return svg
1170 |
1171 | def _get_PNG(self, dpi=None):
1172 | "return PNG data of `Picture`"
1173 | if dpi is None:
1174 | dpi = cfg.display_dpi
1175 | # convert PDF to PNG using PyMuPDF
1176 | zoom = dpi / 72
1177 | doc = fitz.open(self.temp_pdf)
1178 | page = doc.load_page(0)
1179 | pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
1180 | return pix.tobytes()
1181 |
1182 | # code / pdf creation: public
1183 | # public functions make sure that code / pdf is created via `_update`
1184 |
1185 | def code(self):
1186 | "returns TikZ code"
1187 | self._update(build=False)
1188 | return self._code
1189 |
1190 | def document_code(self):
1191 | "returns LaTeX/TikZ code for a complete compilable document"
1192 | self._update(build=False)
1193 | return self._document_code
1194 |
1195 | def write_image(self, filename, dpi=None):
1196 | """
1197 | write picture to image file
1198 |
1199 | The file type is determined from the file extension, and can be PDF,
1200 | PNG, or SVG. For PDF, the file created by LaTeX is copied to
1201 | `filename`. For PNG, the PDF is rendered to a bitmap. If the
1202 | resolution `dpi` is not specified, `cfg.file_dpi` is used. For
1203 | SVG, the PDF is converted to SVG.
1204 |
1205 | Rendering and conversion are performed by the
1206 | [MuPDF library](https://mupdf.com/) through the Python binding
1207 | [PyMuPDF](https://pymupdf.readthedocs.io/en/latest/).
1208 | """
1209 | if dpi is None:
1210 | dpi = cfg.file_dpi
1211 | self._update()
1212 | # determine extension
1213 | _, ext = os.path.splitext(filename)
1214 | # if a PDF is requested,
1215 | if ext.lower() == '.pdf':
1216 | # just copy the file
1217 | shutil.copyfile(self.temp_pdf, filename)
1218 | elif ext.lower() == '.png':
1219 | # render PDF as PNG using PyMuPDF
1220 | zoom = dpi / 72
1221 | doc = fitz.open(self.temp_pdf)
1222 | page = doc.load_page(0)
1223 | pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=True)
1224 | pix.save(filename)
1225 | elif ext.lower() == '.svg':
1226 | # convert PDF to SVG using PyMuPDF
1227 | svg = self._get_SVG()
1228 | with open(filename, 'w') as f:
1229 | f.write(svg)
1230 | else:
1231 | raise ValueError(f'format {ext[1:]} is not supported')
1232 |
1233 | def _repr_mimebundle_(self, include, exclude, **kwargs):
1234 | "display image in notebook"
1235 | # For the "plot viewer" of vscode-python to be activated, apparently it
1236 | # is necessary to provide both a PNG and an SVG.
1237 | # Note that SVG rendering in the "plot viewer" is not entirely
1238 | # accurate, see https://github.com/microsoft/vscode-python/issues/13080
1239 | self._update()
1240 | data = {
1241 | 'image/png': self._get_PNG(),
1242 | 'image/svg+xml': self._get_SVG()
1243 | }
1244 | return data
1245 |
1246 | def demo(self, dpi=None):
1247 | """
1248 | show picture and code in the notebook
1249 |
1250 | This is a convenience function meant to aid development and debugging
1251 | of a picture in a Jupyter notebook. It creates an output cell that (by
1252 | default) contains the rendered picture on the left and the
1253 | corresponding TikZ code on the right. This layout can be modified via
1254 | `cfg.demo_template`. The optional argument `dpi` can be used to
1255 | override the default `cfg.display_dpi`.
1256 | """
1257 | self._update()
1258 | png_base64 = ''
1259 | try:
1260 | png_base64 = base64.b64encode(
1261 | self._get_PNG(dpi=dpi)).decode('ascii')
1262 | except LatexError as le:
1263 | message = le.args[0]
1264 | tikz_error = message.find('! ')
1265 | if tikz_error != -1:
1266 | message = message[tikz_error:]
1267 | print('LatexError: LaTeX has failed')
1268 | print(message)
1269 | code_escaped = html.escape(self._code)
1270 | IPython.display.display(
1271 | IPython.display.HTML(
1272 | cfg.demo_template.format(png_base64, code_escaped)))
1273 |
1274 |
1275 | class LatexError(Exception):
1276 | """
1277 | error in the external LaTeX process
1278 | """
1279 | pass
1280 |
--------------------------------------------------------------------------------
/tikz/extended_wilkinson.py:
--------------------------------------------------------------------------------
1 | """
2 | Extended-Wilkinson algorithm for tick values and labels
3 |
4 | Implementation of J. Talbot, S. Lin, & P. Hanrahan, An Extension of
5 | Wilkinson’s Algorithm for Positioning Tick Labels on Axes, *IEEE Trans.
6 | Vis. Comput. Graph.*, 16(6), 1036-1043, 2010.
7 | [doi:10.1109/TVCG.2010.130](https://doi.org/10.1109/tvcg.2010.130)
8 |
9 | Based on the
10 | [R implementation](https://rdrr.io/rforge/labeling/src/R/labeling.R)
11 | and [additional information](https://github.com/jtalbot/Labeling/issues/1)
12 | by Justin Talbot.
13 |
14 | Other than the R code, this implementation includes the *legibility* score,
15 | and the result provides detailed information about the optimal presentation of
16 | tick values as labels. In line with that, the parameter target number of ticks
17 | `m` has been replaced by a target physical tick density (ρt in the
18 | paper).
19 |
20 | Compared to the paper, there are two limitations: Of the eight label formats,
21 | only 'Decimal' and 'Factored scientific' are implemented; and tick labels are
22 | always '0-extended', i.e. if stripping of trailing zeros is desired, it must be
23 | performed by the user.
24 | """
25 |
26 | # Copyright (C) 2020 Carsten Allefeld
27 |
28 | from math import log10, ceil, floor
29 | from itertools import count
30 | from decimal import Decimal as D
31 |
32 |
33 | class cfg:
34 | "`tikz.extended_wilkinson` configuration variables"
35 |
36 | Q = [D(1), D(5), D(2), D('2.5'), D(4), D(3)]
37 | """
38 | preference-ordered list of nice step sizes
39 |
40 | Values must be of type `decimal.Decimal`. The default step sizes are 1, 5,
41 | 2, 2.5, 4, and 3.
42 | """
43 |
44 | w = [0.25, 0.2, 0.5, 0.05]
45 | """
46 | list of weights of score components
47 |
48 | The default weights are 0.25 for *simplicity*, 0.2 for *coverage*, 0.5 for
49 | *density*, and 0.05 for *legibility*.
50 | """
51 |
52 | font_metrics = {'offset': 0.1, '-': 0.678, '1': 0.5, '2': 0.5, '3': 0.5,
53 | '4': 0.5, '5': 0.5, '6': 0.5, '7': 0.5, '8': 0.5, '9': 0.5,
54 | '0': 0.5, '.': 0.278, 'height': 0.728}
55 | """
56 | default font metrics
57 |
58 | Font metrics are used to calculate the width and height of tick labels.
59 | They are specified as a `dict`, where each character that can occur in a
60 | tick label (`'-'`, `'0'`–`'9'`, `'.'`) is a key associated with the
61 | character's width; it also contains an `'offset'` to be added to the total
62 | width, and a `'height'`. All numbers are in units of the font size.
63 |
64 | The default values are for La/TeX's standard math font (Computer Modern
65 | Roman).
66 | """
67 |
68 |
69 | class TicksGenerator:
70 | """
71 | generator of tick values and labels
72 |
73 | This class stores parameters of tick generation that are likely to stay the
74 | same across several axes:
75 |
76 | `font_sizes`
77 | : admissible font sizes, in TeX pt (2.54 cm / 72.27)
78 |
79 | `density`
80 | : target density of ticks, in cm–1
81 |
82 | `font_metrics`
83 | : used to calculate the width and height of tick labels,
84 | see `cfg.font_metrics` (default)
85 |
86 | `only_loose`
87 | : whether the range of tick values is forced to encompass the
88 | range of data values
89 |
90 | Ticks for a specific axis are generated by calling the
91 | [`ticks()`](#tikz.extended_wilkinson.TicksGenerator.ticks) method on an
92 | instance.
93 | """
94 |
95 | def __init__(self, font_sizes, density,
96 | font_metrics=None, only_loose=True):
97 | if font_metrics is None:
98 | font_metrics = cfg.font_metrics
99 | self.font_sizes = sorted(font_sizes)
100 | self.rt = density
101 | self.font_metrics = font_metrics
102 | self.only_loose = only_loose
103 |
104 | # scoring functions, including the approximations for limiting the search
105 |
106 | def _simplicity(self, i, start, j, k):
107 | # v: is zero included in the ticks?
108 | # modifications
109 | # - (lmin % lstep < eps or lstep - (lmin % lstep) < eps),
110 | # means lmin / lstep = start / j is an integer
111 | # - lmin <= 0 means start <=0
112 | # - lmax >= 0 means start + j * (k - 1) >= 0
113 | v = (start % j == 0 and start <= 0 and start + j * (k - 1) >= 0) * 1
114 | return 1 - (i - 1) / (len(cfg.Q) - 1) - j + v
115 |
116 | def _simplicity_max(self, i, j):
117 | # upper bound on _simplicity w.r.t. k, z, start
118 | # = w.r.t. v
119 | return 1 - (i - 1) / (len(cfg.Q) - 1) - j + 1
120 |
121 | def _coverage(self, dmin, dmax, lmin, lmax):
122 | return (1 - 0.5 * ((dmax - lmax)**2 + (dmin - lmin)**2)
123 | / (0.1 * (dmax - dmin))**2)
124 |
125 | def _coverage_max(self, dmin, dmax, span):
126 | # upper bound on _coverage w.r.t. start
127 | range = dmax - dmin
128 | # The original code has a branching which I don't think is necessary.
129 | # if span > range:
130 | # half = (span - range) / 2
131 | # return 1 - 0.5 * (2 * half ** 2) / (0.1 * range)**2
132 | # else:
133 | # return 1
134 | half = (span - range) / 2
135 | return 1 - 0.5 * (2 * half ** 2) / (0.1 * range)**2
136 |
137 | def _density(self, k, m, dmin, dmax, lmin, lmax):
138 | r = (k - 1) / (lmax - lmin)
139 | rt = (m - 1) / (max(lmax, dmax) - min(dmin, lmin))
140 | return 2 - max((r / rt, rt / r))
141 |
142 | def _density_max(self, k, m):
143 | # From original code, which I don't understand.
144 | if k >= m:
145 | return 2 - (k - 1) / (m - 1)
146 | else:
147 | # Probably just the trivial upper bound.
148 | return 1
149 |
150 | def _score(self, s, c, d, l):
151 | # combined score
152 | return cfg.w[0] * s + cfg.w[1] * c + cfg.w[2] * d + cfg.w[3] * l
153 |
154 | # optimization algorithm
155 |
156 | def ticks(self, dmin, dmax, axis_length, axis_horizontal):
157 | """
158 | generate tick values and labels for a specific axis
159 |
160 | `dmin`
161 | : data values lower bound
162 |
163 | `dmax`
164 | : data values upper bound
165 |
166 | `axis_length`
167 | : physical length of the axis, in cm
168 |
169 | `axis_horizontal`
170 | : whether the axis is oriented horizontally (rather than vertically)
171 |
172 | Returns a `Ticks` object.
173 | """
174 |
175 | # The implementation here is based on the R code, which is defined
176 | # in terms of `m`, the target number of ticks. It optimizes w.r.t.
177 | # the ratio between the two quantities
178 | # r = (k - 1) / (lmax - lmin)
179 | # rt = (m - 1) / (max(lmax, dmax) - min(dmin, lmin))
180 | # We want to instead specify the physical density (e.g. in 1/cm),
181 | # stored as a class attribute `self.rt`, and the parameter `length`
182 | # (e.g. in cm). Assuming that the axis spans `min(dmin, lmin)` to
183 | # `max(lmax, dmax)`, while the ticks span lmin to lmax, the
184 | # optimization should use the ratio of
185 | # r = (k - 1) / (length * (lmax - lmin))
186 | # * (max(lmax, dmax) - min(dmin, lmin))
187 | # to `self.rt`.
188 | # It turns out that the two ratios are equivalent if one sets
189 | m = self.rt * axis_length + 1
190 |
191 | if dmin > dmax:
192 | dmin, dmax = dmax, dmin
193 |
194 | # threshold for optimization
195 | ticks = None
196 | best_score = -2
197 |
198 | # We combine the j and q loops into one to enable breaking out of both
199 | # simultaneously, by iterating over a generator; and we create an
200 | # index i corresponding to q at the same time. i is `match(q, Q)[1]`
201 | # and replaces `q, Q` in function calls.
202 | JIQ = ((j, i, q)
203 | for j in count(start=1)
204 | for i, q in enumerate(cfg.Q, start=1))
205 | for j, i, q in JIQ:
206 | sm = self._simplicity_max(i, j)
207 |
208 | if self._score(sm, 1, 1, 1) < best_score:
209 | break
210 |
211 | for k in count(start=2): # loop over tick counts
212 | dm = self._density_max(k, m)
213 |
214 | if self._score(sm, 1, dm, 1) < best_score:
215 | break
216 |
217 | delta = (dmax - dmin) / (k + 1) / (j * float(q))
218 |
219 | for z in count(start=ceil(log10(delta))):
220 | step = float(q) * j * 10**z
221 |
222 | cm = self._coverage_max(dmin, dmax, step * (k - 1))
223 |
224 | if self._score(sm, cm, dm, 1) < best_score:
225 | break
226 |
227 | min_start = floor(dmax / step) * j - (k - 1) * j
228 | max_start = ceil(dmin / step) * j
229 |
230 | if min_start > max_start:
231 | continue
232 |
233 | for start in range(min_start, max_start + 1):
234 | lmin = start * step / j
235 | lmax = lmin + step * (k - 1)
236 | # lstep = step
237 |
238 | if self.only_loose:
239 | if lmin > dmin or lmax < dmax:
240 | continue
241 |
242 | s = self._simplicity(i, start, j, k)
243 | c = self._coverage(dmin, dmax, lmin, lmax)
244 | d = self._density(k, m, dmin, dmax, lmin, lmax)
245 |
246 | score = self._score(s, c, d, 1)
247 |
248 | if score < best_score:
249 | continue
250 |
251 | # Exact tick values in terms of loop variables:
252 | # lmin = q * start * 10**z
253 | # lmax = q * (start + j * (k - 1)) * 10 ** z
254 | # lstep = float(q) * j * 10**z
255 | decimal_values = [q * (start + j * ind)
256 | * D('1E1') ** z
257 | for ind in range(k)]
258 |
259 | # Create `Ticks` object
260 | ticks = Ticks(
261 | amin=min(lmin, dmin),
262 | amax=max(lmax, dmax),
263 | decimal_values=decimal_values)
264 | # and initiate internal optimization for label
265 | # legibility.
266 | ticks._optimize(
267 | self.font_sizes,
268 | self.font_metrics,
269 | axis_length,
270 | axis_horizontal)
271 |
272 | l = ticks.opt_legibility # noqa E741
273 |
274 | score = self._score(s, c, d, l)
275 |
276 | if score > best_score:
277 | best_score = score
278 |
279 | if ticks is None:
280 | # no solution found: no ticks
281 | print('Warning: Could not determine ticks.')
282 | ticks = Ticks(
283 | amin=dmin,
284 | amax=dmax,
285 | decimal_values=[],
286 | labels=[])
287 | return ticks
288 |
289 |
290 | class Ticks:
291 | """
292 | represents tick values and labels
293 |
294 | This class is not intended to be instantiated by the user, but
295 | `Ticks` objects are obtained via `TicksGenerator.ticks`.
296 | """
297 |
298 | def __init__(self, amin, amax, decimal_values,
299 | labels=None, plabel=None, font_size=None, horizontal=None):
300 | self.amin = amin
301 | "axis lower bound"
302 |
303 | self.amax = amax
304 | "axis upper bound"
305 |
306 | self.values = [float(dv) for dv in decimal_values]
307 | "list of tick values"
308 |
309 | self.decimal_values = decimal_values
310 | "list of exact tick values, as `decimal.Decimal`s"
311 |
312 | self.labels = labels
313 | "list of tick labels strings"
314 |
315 | self.plabel = plabel
316 | """
317 | power label string
318 |
319 | If `plabel` is not `None`, it represents a decadic power factored from
320 | the tick values. It is intended to be displayed in the form
321 | 10`plabel` either at the side of the axis or as/with a unit
322 | in the axis label.
323 | """
324 |
325 | self.font_size = font_size
326 | "tick label font size, in TeX pt (2.54 cm / 72.27)"
327 |
328 | self.horizontal = horizontal
329 | """
330 | whether the tick label is to be displayed in horizontal orientation
331 | (rather than vertical)
332 | """
333 |
334 | def _optimize(self, font_sizes, font_metrics,
335 | axis_length, axis_horizontal):
336 | """
337 | optimize label legibility in terms of format, font size, and
338 | orientation
339 | """
340 |
341 | # tick values
342 | values = self.values
343 | # minimum font size
344 | fs_min = min(font_sizes)
345 | # target font size
346 | fs_t = max(font_sizes)
347 |
348 | # optimization
349 | self.opt_legibility = float('-inf')
350 | # format
351 | for f in range(2):
352 | # legibility score for format
353 | if f == 0:
354 | # format 'Decimal'
355 | vls = [(1e-4 < abs(v) < 1e6) * 1 for v in values]
356 | leg_f = sum(vls) / len(vls)
357 | else:
358 | # format 'Factored scientific'
359 | leg_f = 0.3
360 |
361 | # tick labels
362 | if f == 0:
363 | # format 'Decimal'
364 | labels = self._labels_Decimal()
365 | plabel = None
366 | else:
367 | # format 'Factored scientific'
368 | labels, plabel = self._labels_Scientific()
369 |
370 | # widths and heights of tick labels, in units of font size
371 | widths = [self._label_width(l, font_metrics) for l in labels]
372 | heights = [self._label_height(l, font_metrics) for l in labels]
373 |
374 | # font size
375 | for fs in font_sizes:
376 | # legibility score for font size
377 | if fs == fs_t:
378 | leg_fs = 1
379 | else:
380 | leg_fs = 0.2 * (fs - fs_min + 1) / (fs_t - fs_min)
381 |
382 | # distance between ticks, in units of font size
383 | step = (
384 | (values[1] - values[0]) # numerical
385 | / (self.amax - self.amin) # relative to axis
386 | * axis_length # physical, in cm
387 | /
388 | (fs / 72.27 * 2.54) # font size, in cm
389 | )
390 |
391 | # orientation
392 | for o in range(2):
393 | # legibility score for orientation
394 | if o == 0: # horizontal orientation
395 | leg_or = 1
396 | else: # vertical orientation
397 | leg_or = -0.5
398 |
399 | # legibility score for overlap
400 | # extents of labels along the axis, in units of font size
401 | if (o == 0) == axis_horizontal:
402 | # label and axis have the same orientation
403 | extents = widths
404 | else:
405 | # label and axis have different orientations
406 | extents = heights
407 | # minimum distance between neighboring labels
408 | # We can apply the minimum here, since overlap legibility
409 | # is an increasing function of distance.
410 | dist = min(step - (extents[i] + extents[i + 1]) / 2
411 | for i in range(len(extents) - 1))
412 | # score; we interpret em as font size
413 | if dist >= 1.5:
414 | leg_ov = 1
415 | elif dist > 0:
416 | leg_ov = 2 - 1.5 / dist
417 | else:
418 | leg_ov = float('-inf')
419 |
420 | # total legibility score
421 | leg = (leg_f + leg_fs + leg_or + leg_ov) / 4
422 |
423 | # aggregate
424 | if leg > self.opt_legibility:
425 | self.opt_legibility = leg
426 | self.labels = labels
427 | self.plabel = plabel
428 | self.font_size = fs
429 | self.horizontal = (o == 0)
430 |
431 | def _label_width(self, label, font_metrics):
432 | "get width of tick label"
433 |
434 | w = sum(map(font_metrics.get, label)) + font_metrics['offset']
435 | return w
436 |
437 | def _label_height(self, label, font_metrics):
438 | "get height of tick label"
439 |
440 | h = font_metrics['height']
441 | return h
442 |
443 | def _labels_Decimal(self):
444 | "get tick labels in 'Decimal' format"
445 |
446 | # get values
447 | dvs = self.decimal_values
448 | # create labels
449 | labels = ['{:f}'.format(dv) for dv in dvs]
450 | return labels
451 |
452 | def _labels_Scientific(self):
453 | "get tick labels in 'Scientific format'"
454 |
455 | # get values
456 | dvs = self.decimal_values
457 | # get largest power of 10 than can be factored out
458 | z0 = min([floor(log10(abs(dv))) for dv in dvs if dv != 0])
459 | # get values adjusted to that power
460 | dvs = [dv * D('1E1') ** (-z0) for dv in dvs]
461 | # create labels
462 | labels = ['{:f}'.format(dv) for dv in dvs]
463 | plabel = '{:d}'.format(z0)
464 | return labels, plabel
465 |
--------------------------------------------------------------------------------
/tikz/figure.py:
--------------------------------------------------------------------------------
1 | """
2 | specific parameters:
3 | - width
4 | - rows/columns for each view
5 | - aspect ratio for each view
6 |
7 | implicit:
8 | - numbers of rows and columns
9 | - numbers of rows and columns
10 |
11 | generic parameters:
12 | - horizontal margin, vertical margin
13 | - horizontal gap, vertical gap
14 | - left padding, right padding
15 | - below padding, above padding
16 |
17 | results:
18 | - column widths
19 | - row heights
20 | - ...
21 |
22 | all lengths in cm
23 | """
24 |
25 | # Copyright (C) 2020 Carsten Allefeld
26 |
27 | import numpy as np
28 | import collections
29 | from tikz import Picture, Scope, rectangle, options, lineto, node, fontsize
30 | from tikz.extended_wilkinson import TicksGenerator
31 |
32 | tex_maxdimen = (2**30 - 1) / 65536 / 72.27 * 2.54
33 | "maximum length that can be processed by TeX"
34 | # theoretical: 575.8317415420323
35 | # TODO: -575.831685 is too large!
36 |
37 |
38 | class cfg:
39 | "tikz.figure configuration variables"
40 |
41 | width = 16
42 | "width of figure, default 16"
43 | margin_horizontal = 0.5
44 | "horizontal margin of figure, default 0.5"
45 | margin_vertical = 0.5
46 | "vertical margin of figure, default 0.5"
47 | gap_horizontal = 0.5
48 | "horizontal gap between views, default 0.5"
49 | gap_vertical = 0.5
50 | "vertical gap between views, default 0.5"
51 | padding_left = 1
52 | "left view padding, default 1"
53 | padding_right = 0.5
54 | "right view padding, default 1"
55 | padding_bottom = 1
56 | "bottom view padding, default 1"
57 | padding_top = 0.5
58 | "top view padding, default 0.5"
59 |
60 | figure_fontsize = 10
61 | """
62 | default font size within figure, default 10 pt
63 |
64 | This font size applies to the figure title, axes titles and labels, as well
65 | as to user-created text nodes unless overridden.
66 | """
67 | decorations_fontsize = 9
68 | "font size for axes decorations, default 9 pt"
69 | ticks_fontsizes = [8, 9]
70 | """
71 | list of font sizes for tick labels
72 |
73 | The largest font size is the default, smaller sizes are used if there is
74 | not enough space.
75 | """
76 | tick_density = 0.75
77 | "target number of ticks per cm, default 0.75"
78 | clip_margin = 0.8 / 72.27 * 2.54
79 | "width by which the clip region is larger than the axes, default 0.8 pt"
80 | axis_offset = 0.1
81 | "offset of axis lines from axes region, default 1 mm"
82 | tick_length = 0.1
83 | "length of tick lines, default 1 mm"
84 |
85 |
86 | class Box:
87 | def __init__(self, x, y, w, h):
88 | self.x = x
89 | self.y = y
90 | self.w = w
91 | self.h = h
92 |
93 | def _draw(self, env, label=None, opt=None, **kwoptions):
94 | "draw Box into environment"
95 | env.draw((self.x, self.y),
96 | rectangle((self.x + self.w, self.y + self.h)),
97 | opt=opt, **kwoptions)
98 | if label is not None:
99 | env.node(label, at=(self.x, self.y + self.h),
100 | anchor='north west', font=r'\tiny')
101 |
102 |
103 | class View:
104 | def __init__(self, outer=None, inner=None):
105 | self.outer = outer
106 | self.inner = inner
107 |
108 | def locate(self, outer, inner):
109 | self.outer = outer
110 | self.inner = inner
111 |
112 | def _draw(self, env, label=None):
113 | "draw View into environment"
114 | self.outer._draw(env, label, opacity=0.5)
115 | self.inner._draw(env)
116 |
117 |
118 | class Layout:
119 | """
120 | superclass for layout classes
121 |
122 | Every subclass has to ensure that
123 | - there is a member `views` that contains a list of `View` objects,
124 | - there are members `width` and `height` which specify the dimensions,
125 | - if computations are necessary to ensure these members are up-to-date,
126 | they are implemented in a method overriding `_compute`.
127 | """
128 | def _compute(self):
129 | pass
130 |
131 | def get_views(self):
132 | self._compute()
133 | return self.views
134 |
135 | def get_dimensions(self):
136 | self._compute()
137 | return self.width, self.height
138 |
139 | def _draw(self, env):
140 | "draw Layout into environment"
141 | env.draw((0, 0), rectangle((self.width, self.height)))
142 | env.node('Layout', at=(0, self.height),
143 | anchor='north west', font=r'\tiny')
144 | for i in range(len(self.views)):
145 | self.views[i]._draw(env, f'View {i}')
146 |
147 | def _repr_png_(self, dpi=None):
148 | "represent Layout as PNG for notebook"
149 | self._compute()
150 | pic = Picture()
151 | self._draw(pic)
152 | return pic._get_PNG(dpi=dpi)
153 |
154 |
155 | class SimpleLayout(Layout):
156 | "layout with a single view"
157 | def __init__(self, **parameters):
158 | # process Layout parameters and get defaults
159 | self.width = parameters.get('width', cfg.width)
160 | mh = parameters.get('margin_horizontal', parameters.get(
161 | 'margin', cfg.margin_horizontal))
162 | mv = parameters.get('margin_vertical', parameters.get(
163 | 'margin', cfg.margin_vertical))
164 | pl = parameters.get('padding_left', cfg.padding_left)
165 | pr = parameters.get('padding_right', cfg.padding_right)
166 | pb = parameters.get('padding_bottom', cfg.padding_bottom)
167 | pt = parameters.get('padding_top', cfg.padding_top)
168 | ar = parameters.get('aspect_ratio', 4/3)
169 | # check width
170 | if self.width <= 2 * mh + pl + pr:
171 | raise LayoutError(f'width {self.width} is too small')
172 | # compute
173 | iw = self.width - 2 * mh - pl - pr
174 | ih = iw / ar
175 | ow = iw + pl + pr
176 | oh = ih + pb + pt
177 | ox = mh
178 | oy = mv
179 | ix = ox + pl
180 | iy = oy + pb
181 | self.height = oh + 2 * mv
182 | # create boxes and view
183 | outer = Box(ox, oy, ow, oh)
184 | inner = Box(ix, iy, iw, ih)
185 | self.views = [View(outer, inner)]
186 |
187 |
188 | class FlexibleGridLayout(Layout):
189 | "layout where views encompass one or more of the cells of a flexible grid"
190 | def __init__(self, **parameters):
191 | # process Layout parameters and get defaults
192 | self.width = parameters.get('width', cfg.width)
193 | self.mh = parameters.get('margin_horizontal', parameters.get(
194 | 'margin', cfg.margin_horizontal))
195 | self.mv = parameters.get('margin_vertical', parameters.get(
196 | 'margin', cfg.margin_vertical))
197 | self.gh = parameters.get('gap_horizontal', parameters.get(
198 | 'gap', cfg.gap_horizontal))
199 | self.gv = parameters.get('gap_vertical', parameters.get(
200 | 'gap', cfg.gap_vertical))
201 | self.pl = parameters.get('padding_left', cfg.padding_left)
202 | self.pr = parameters.get('padding_right', cfg.padding_right)
203 | self.pb = parameters.get('padding_bottom', cfg.padding_bottom)
204 | self.pt = parameters.get('padding_top', cfg.padding_top)
205 | # initialize list of Views and view parameters
206 | self.views = []
207 | self.rf = [] # rows from
208 | self.rt = [] # rows to
209 | self.cf = [] # columns from
210 | self.ct = [] # columns to
211 | self.ar = [] # aspect ratio
212 |
213 | def add_view(self, rows, cols, aspect_ratio=None):
214 | # support specification of single row/col as scalar
215 | if not isinstance(rows, collections.abc.Iterable):
216 | rows = [rows]
217 | if not isinstance(cols, collections.abc.Iterable):
218 | cols = [cols]
219 | # store extent w.r.t. grid & aspect ratio
220 | self.rf.append(min(rows))
221 | self.rt.append(max(rows))
222 | self.cf.append(min(cols))
223 | self.ct.append(max(cols))
224 | self.ar.append(aspect_ratio)
225 | # create & store empty View object
226 | v = View()
227 | self.views.append(v)
228 | # check width
229 | if self.width <= (2 * self.mh + self.pl + self.pr
230 | + self.gh * max(self.ct)):
231 | raise LayoutError(f'width {self.width} too small')
232 |
233 | def _compute(self):
234 | # What we have to compute are the outer and inner box of each view. To
235 | # do so, we need to know the height of each row and the width of each
236 | # column. The constraints that allow to compute these unknowns u are
237 | # linear, which means they can be expressed by a matrix equation,
238 | # A u = b. The rows of u / columns of A correspond to first the the
239 | # row heights and then the column widths, and the rows of A / rows of
240 | # b correspond to the constraints.
241 |
242 | # compute number of rows/cols from maximal view row/col index
243 | nr = max(self.rt) + 1
244 | nc = max(self.ct) + 1
245 |
246 | # constraints: one global, and one per view
247 | n = 1 + len(self.views)
248 | A = np.zeros(shape=(n, nr + nc))
249 | b = np.zeros(shape=(n, 1))
250 | # global constraint
251 | # The column widths, margins and gaps have to add up to the width.
252 | A[0, :] = np.hstack((np.zeros(nr), np.ones(nc)))
253 | b[0] = self.width - 2 * self.mh - (nc - 1) * self.gh
254 | # per-view constraints
255 | for i in range(len(self.views)):
256 | # unpack for shorter code
257 | rf = self.rf[i]
258 | rt = self.rt[i]
259 | cf = self.cf[i]
260 | ct = self.ct[i]
261 | ar = self.ar[i]
262 | # ignore views with unspecified aspect ratio
263 | if ar is None:
264 | continue
265 | # row heights included in view
266 | h = np.zeros(nr)
267 | h[rf: rt + 1] = 1
268 | nvr = sum(h)
269 | # column widths included in view
270 | w = np.zeros(nc)
271 | w[cf: ct + 1] = 1
272 | nvc = sum(w)
273 | # constraint
274 | A[i + 1, :] = np.hstack((-ar * h, w))
275 | b[i + 1] = ((self.pl + self.pr - (nvc - 1) * self.gh)
276 | - ar * (self.pt + self.pb - (nvr - 1) * self.gv))
277 |
278 | # check constraints
279 | rank = np.linalg.matrix_rank(A)
280 | if rank < nr + nc:
281 | print('Warning: The Layout is underdetermined.')
282 |
283 | # solve expression
284 | u = np.linalg.pinv(A) @ b
285 |
286 | # extract row heights and column widths
287 | rh = list(u[:nr].flat)
288 | cw = list(u[nr:].flat)
289 |
290 | # height of figure
291 | self.height = sum(rh) + 2 * self.mh + (nr - 1) * self.gv
292 |
293 | # check fulfillment of global constraint
294 | # Tolerance: We choose TeX's internal unit, the scaled point "sp", see
295 | # The TeXbook, p. 57.
296 | tol = 2.54 / 72.27 / 65536
297 | actual_width = sum(cw) + 2 * self.mh + (nc - 1) * self.gh
298 | if abs(actual_width - self.width) > tol:
299 | print(f'Warning: Layout width is {actual_width}.')
300 |
301 | # compute position of view boxes
302 | for i in range(len(self.views)):
303 | # unpack for shorter code
304 | rf = self.rf[i]
305 | rt = self.rt[i]
306 | cf = self.cf[i]
307 | ct = self.ct[i]
308 | ar = self.ar[i]
309 | # outer box
310 | ox = self.mh + sum(cw[:cf]) + cf * self.gh
311 | oy = self.mv + sum(rh[:rf]) + rf * self.gv
312 | ow = sum(cw[cf: ct + 1]) + (ct - cf) * self.gh
313 | oh = sum(rh[rf: rt + 1]) + (rt - rf) * self.gv
314 | oy = self.height - oy - oh
315 | outer = Box(ox, oy, ow, oh)
316 | # inner box
317 | ix = ox + self.pl
318 | iy = oy + self.pb
319 | iw = ow - self.pl - self.pr
320 | ih = oh - self.pt - self.pb
321 | inner = Box(ix, iy, iw, ih)
322 | # assign Boxes to View
323 | self.views[i].locate(outer, inner)
324 |
325 | # check fulfillment of per-view constraint
326 | if ar is None:
327 | continue
328 | if abs(iw - ar * ih) > tol:
329 | print(f'Warning: View {i} aspect ratio is {iw / ih}.')
330 |
331 |
332 | class LayoutError(Exception):
333 | """
334 | error in computing Layout
335 | """
336 | pass
337 |
338 |
339 | class Figure(Picture):
340 | def __init__(self, layout=None, tempdir=None, cache=True, font=None,
341 | opt=None, **layout_parameters):
342 | if font is None:
343 | font = fontsize(cfg.figure_fontsize)
344 | else:
345 | font = fontsize(cfg.figure_fontsize) + font
346 | super().__init__(
347 | tempdir=tempdir,
348 | cache=cache,
349 | opt=opt,
350 | font=font)
351 | # process layout
352 | if layout is None:
353 | layout = SimpleLayout(**layout_parameters)
354 | self.layout = layout
355 | self.width, self.height = layout.get_dimensions()
356 | self.views = layout.get_views()
357 | # ensure bounding box of figure
358 | self.clip((0, 0), rectangle((self.width, self.height)))
359 | # use font Fira
360 | self.fira()
361 | font_metrics = {
362 | 'offset': 0.1, '-': 0.4, '1': 0.56, '2': 0.56, '3': 0.56,
363 | '4': 0.56, '5': 0.56, '6': 0.56, '7': 0.56, '8': 0.56, '9': 0.56,
364 | '0': 0.56, '.': 0.24, 'height': 0.723}
365 | # TODO: general mechanism to register fonts?
366 | # or at least keep font activation code and font_metrics together?
367 | # create `TicksGenerator`
368 | self.ticks_generator = TicksGenerator(
369 | cfg.ticks_fontsizes,
370 | cfg.tick_density,
371 | font_metrics=font_metrics)
372 |
373 | def draw_layout(self):
374 | "draw layout"
375 | scope = self.scope(color='red')
376 | self.layout._draw(scope)
377 |
378 | def title(self, label, margin_vertical=None):
379 | # TODO: use another parameter name, and corresponding cfg?
380 | # spaces are off as they are
381 | # Make this a layout option, overridden when creating the layout,
382 | # and read from the layout here.
383 | if margin_vertical is None:
384 | margin_vertical = cfg.margin_vertical
385 | scope = self.scope()
386 | # position title such that descenders touch Layout
387 | scope.node(label, at=(self.width / 2, self.height),
388 | anchor='base', yshift='depth("gjpqy")', name='title',
389 | outer_sep=0, inner_sep=0)
390 | # extend bounding box such that there is space above capital letters
391 | # and ascenders
392 | scope.path('(title.base)', options(yshift='height("HAbdfhk")'),
393 | f'+(0,{margin_vertical})')
394 | # Alternatively, one could set the height and depth of the node,
395 | # see https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.17.4.4
396 | # Also, predefine this height and depth for ease of use? – No, because
397 | # it depends on the font size. But maybe define macros.
398 |
399 | def axes(self, xlim, ylim, view_no=0, xaxis=True, yaxis=True):
400 | a = Axes(self.views[view_no], xlim, ylim, self.ticks_generator,
401 | xaxis=xaxis, yaxis=yaxis)
402 | self._append(a)
403 | return a
404 |
405 |
406 | class Axes(Scope):
407 | def __init__(self, view, xlim, ylim, ticks_generator, xaxis, yaxis):
408 | super().__init__()
409 | self.inner = view.inner
410 | self.outer = view.outer
411 | self.xticks = ticks_generator.ticks(*xlim, self.inner.w, True)
412 | self.yticks = ticks_generator.ticks(*ylim, self.inner.h, False)
413 |
414 | xmin = self.xticks.amin
415 | xrange = self.xticks.amax - xmin
416 | ymin = self.yticks.amin
417 | yrange = self.yticks.amax - ymin
418 |
419 | # Sub-scope for axes decorations in which the origin remains in the
420 | # lower left corner of the figure, and xy remains at its 1 cm default.
421 | # Moreover, coordinates are not subject to transformation and drawing
422 | # is not clipped.
423 | self.decorations = Scope(font=fontsize(cfg.decorations_fontsize))
424 |
425 | # convenience
426 | x, y, w, h = self.inner.x, self.inner.y, self.inner.w, self.inner.h
427 |
428 | # Drawing is clipped to the inner box, with a bit of padding.
429 | pad = cfg.clip_margin
430 | self.clip(f'({x - pad}cm,{y - pad}cm)',
431 | rectangle(f'({x + w + pad}cm, {y + h + pad}cm)'))
432 |
433 | # The Axes scope itself sets an origin in the left bottom corner of
434 | # the inner box, and xy set up such that [0, 1] covers the whole inner
435 | # width / height.
436 | self.tikzset(xshift=f'{x}cm',
437 | yshift=f'{y}cm',
438 | x=f'{w}cm',
439 | y=f'{h}cm')
440 |
441 | # coordinate limits from tex_maxdimen
442 | cxmin = (-tex_maxdimen - x) / w * xrange + xmin
443 | cxmax = (tex_maxdimen - x) / w * xrange + xmin
444 | cymin = (-tex_maxdimen - y) / h * yrange + ymin
445 | cymax = (tex_maxdimen - y) / h * yrange + ymin
446 |
447 | # Transformation which maps coordinates from the axis' ranges
448 | # onto [0, 1], to be passed to *`.code()` and `_coordinate_code`.
449 | def transformation(coord):
450 | cx, cy = coord
451 | if not isinstance(cx, str):
452 | # check too large
453 | if cx < cxmin:
454 | print(f'Warning: x coordinate {cx} clipped to {cxmin}.')
455 | cx = cxmin
456 | if cx > cxmax:
457 | print(f'Warning: x coordinate {cx} clipped to {cxmax}.')
458 | cx = cxmax
459 | # transform x
460 | cx = (cx - xmin) / xrange
461 | if not isinstance(cy, str):
462 | # check too large
463 | if cy < cymin:
464 | print(f'Warning: y coordinate {cy} clipped to {cymin}.')
465 | cy = cymin
466 | if cy > cymax:
467 | print(f'Warning: x coordinate {cy} clipped to {cymax}.')
468 | cy = cymax
469 | # transform y
470 | cy = (cy - ymin) / yrange
471 | return cx, cy
472 | self.trans = transformation
473 |
474 | # TODO: postpone to allow modification?
475 | # or let specify Ticks instead of xlim, ylim?
476 | if xaxis:
477 | self.xaxis()
478 | if yaxis:
479 | self.yaxis()
480 |
481 | def xaxis(self):
482 | d = self.decorations
483 | i = self.inner
484 | o = cfg.axis_offset
485 | tl = cfg.tick_length
486 | t = self.xticks
487 | if t.font_size != cfg.decorations_fontsize:
488 | font = fontsize(t.font_size)
489 | else:
490 | font = None
491 | rotate = None if t.horizontal else 90
492 | d.draw((i.x, i.y - o), lineto((i.x + i.w, i.y - o)),
493 | line_cap='round')
494 | for v, l in zip(t.values, t.labels):
495 | x = i.x + (v - t.amin) / (t.amax - t.amin) * i.w
496 | if t.horizontal:
497 | n = node(f'${l}$', font=font, rotate=rotate,
498 | anchor='north')
499 | else:
500 | n = node(f'${l}$', font=font, rotate=rotate,
501 | anchor='east')
502 | d.draw((x, i.y - o), lineto((x, i.y - o - tl)), n)
503 | if t.plabel is not None:
504 | d.draw((i.x + i.w, i.y), node(f'$10^{{{t.plabel}}}$'),
505 | anchor='west')
506 | # TODO: standardize / `cfg`urize label and plabel padding
507 |
508 | def yaxis(self):
509 | d = self.decorations
510 | i = self.inner
511 | o = cfg.axis_offset
512 | tl = cfg.tick_length
513 | t = self.yticks
514 | if t.font_size != cfg.decorations_fontsize:
515 | font = fontsize(t.font_size)
516 | else:
517 | font = None
518 | rotate = None if t.horizontal else 90
519 | d.draw((i.x - o, i.y), lineto((i.x - o, i.y + i.h)),
520 | line_cap='round')
521 | for v, l in zip(t.values, t.labels):
522 | y = i.y + (v - t.amin) / (t.amax - t.amin) * i.h
523 | if t.horizontal:
524 | n = node(f'${l}$', font=font, rotate=rotate,
525 | anchor='east')
526 | else:
527 | n = node(f'${l}$', font=font, rotate=rotate,
528 | anchor='south')
529 | d.draw((i.x - o, y), lineto((i.x - o - tl, y)), n)
530 | if t.plabel is not None:
531 | d.draw((i.x, i.y + i.h), node(f'$10^{{{t.plabel}}}$'),
532 | anchor='south')
533 |
534 | # TODO: yaxis_right, maybe xaxis_top
535 | # Axes options yaxis= 'left', 'right', None
536 | # privatize xaxis, yaxis?
537 |
538 | def _code(self):
539 | "returns TikZ code"
540 | code = (self.decorations._code() + '\n'
541 | + super()._code(self.trans))
542 | return code
543 |
--------------------------------------------------------------------------------
/tikz/tikz.md:
--------------------------------------------------------------------------------
1 | # Module `tikz`
2 |
3 | This module provides a way to create, compile, view, and save graphics based on the LaTeX package [TikZ & PGF](https://ctan.org/pkg/pgf). It makes the creation of TikZ graphics easier when (part of) the underlying data is computed, and makes the preview and debugging of graphics within a Jupyter notebook seamless.
4 |
5 | This documentation explains only how to access TikZ' functionality from Python. To understand it, the [TikZ & PGF manual](https://pgf-tikz.github.io/pgf/pgfmanual.pdf) needs to be consulted in parallel. A [notebook](https://nbviewer.jupyter.org/github/allefeld/pytikz/blob/master/pytikz.ipynb) contains examples to get you started.
6 |
7 |
8 | ## Function
9 |
10 | The module exposes the basic graphics functionality of TikZ, as described in [Part III](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#part.3) of the manual, except for some specialized functions with complex syntax (pics, graphs, matrices, trees).
11 |
12 | At its center is the class `Picture`. It primarily represents a [tikzpicture environment](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.12.2.1), but also provides methods to create a complete LaTeX document and compile it in the background. Methods of the class serve mainly to insert TikZ commands into this environment, but also allow to load necessary TikZ libraries and LaTeX packages.
13 |
14 | LaTeX documents created by this package always contain a single `tikzpicture` environment, and the document is compiled in such a way that a PDF containing only that picture's bounding box is created. The picture can be directly displayed in a notebook, saved as a PDF, converted to PNG or SVG, and the resulting image file used in another application or again in LaTeX. It is also possible to show the TikZ code corresponding to the picture and copy & paste it into a LaTeX document of your own.
15 |
16 |
17 | ## Design
18 |
19 | TikZ' basic design comprises
20 |
21 | - (sequences of) coordinates,
22 | - path operations,
23 | - path specifications created from the combination of the first two, and
24 | - path actions.
25 |
26 | Path actions and other commands are grouped in [scope environments](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.12.3.1). In addition, there are options which can be attached to a path action, path operation, or environment but can also be embedded in a path specification. In the following it is explained how these TikZ stuctures are mapped to Python in this module.
27 |
28 | {width=100%}
29 |
30 | Coordinate
31 |
32 | : A [coordinate](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsection.13.2) can be specified as a `tuple` or a NumPy 1d-`ndarray` with 2 or 3 elements, or as a string.
33 |
34 | Elements of `tuple`s can be numbers or strings. If all elements are numeric, it specifies coordinates in TikZ' `xyz` coordinate system. If all are strings (normally a number plus a unit like `'2pt'`) it specifies coordinates in TikZ' `canvas` coordinate system. Otherwise it specifies a [mixed](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#subsubsection.13.2.1) `xyz`/`canvas` coordinate.
35 |
36 | `ndarray`s must be numeric and represent coordinates in TikZ' `xyz` coordinate system.
37 |
38 | Strings can be used to specify coordinates in TikZ' other coordinate systems, e.g. `polar`, `perpendicular`, and node. Coordinate-specifying strings are enclosed in parentheses `()`, possibly prefixed by `+` or `++` (relative / incremental coordinates). A special case is the coordinate `'cycle'`, which can be created by the function `cycle`.
39 |
40 | If an argument is intended to be a coordinate, it is normally named `coord`.
41 |
42 | Sequence of coordinates
43 |
44 | : A sequence of coordinates is specified as a `list` of coordinates as described above, or as a numeric 2d-`ndarray` with 2 or 3 columns, representing `xyz` coordinates.
45 |
46 | If an argument is expected to be a sequence of coordinates, it is normally named `coords`. Often, a single coordinate can be given in place of a sequence.
47 |
48 | Path operation
49 |
50 | : A path operation is specified as an object of a subclass of `Operation`. The subclass names are lowercase, because in practical use these classes act similar to functions, i.e. they are only instantiated, not manipulated.
51 |
52 | A path operation is normally not used as a single argument, but as part of a path specification. Some path operations accept options.
53 |
54 | Path specification
55 |
56 | : A [path specification](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#section.14) is specified as a sequence of path operations and (sequences of) coordinates (shorthand for `moveto` operations). It can also include options and strings.
57 |
58 | A path specification is normally passed as a sequence of arguments named `**spec` to a path action method.
59 |
60 | Path action
61 |
62 | : A [path action](https://pgf-tikz.github.io/pgf/pgfmanual.pdf#section.15) is specified as a method of `Picture` and other environments. Several method calls in sequence create a sequence of path actions.
63 |
64 | A path action method typically accepts a path specification as well as options as arguments.
65 |
66 | Scope
67 |
68 | : A scope environment can be added to a `Picture` or another environment using the method [environment.add_scope()](#tikz.Scope.addscope). This creates a `Scope` object, adds it to the environment and returns it. To add path actions and other commands to the environment, call the methods on the returned object.
69 |
70 |
71 | Option
72 |
73 | : An option is specified as a keyword argument (`**kwoptions`) and/or as a string (`opt`); the string is included as-is in the TikZ-formatted option string. TikZ keys that contain spaces are specified with an underscore `_` in their place. TikZ keys that do not take a value are specified with the value `True`. Keys with the value `None` are not passed to TikZ.
74 |
75 | For embedding options within a path specification, the function `options` can be used.
76 |
77 | Classes, methods or functions that accept options contain `opt=None, **kwoptions` in their signature.
78 |
79 |
80 | ## Color
81 |
82 | TikZ automatically loads the [LaTeX package xcolor](https://mirrors.nxthost.com/ctan/macros/latex/contrib/xcolor/xcolor.pdf), which means that a large number of [named colors](https://mirrors.nxthost.com/ctan/macros/latex/contrib/xcolor/xcolor.pdf#section.4) can be used within pictures. The package also allows to define new colors based on a variety of color models as well as through mixture of known colors, exposed through the
83 | [environment.definecolor()](#tikz.Scope.definecolor) and
84 | [environment.colorlet()](#tikz.Scope.colorlet)
85 | methods of `Picture` and other environments.
86 |
87 |
88 | ***
89 |
--------------------------------------------------------------------------------
/tikz/tikz.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allefeld/pytikz/5d56d61f29d5ee0f7ea861a2836b4c222ff0fc54/tikz/tikz.png
--------------------------------------------------------------------------------