prefig 0.2.15.dev20250514053750__py3-none-any.whl → 0.5.6.dev20260130060411__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prefig/cli.py +46 -26
- prefig/core/CTM.py +18 -3
- prefig/core/__init__.py +2 -0
- prefig/core/annotations.py +115 -2
- prefig/core/area.py +20 -4
- prefig/core/arrow.py +9 -3
- prefig/core/axes.py +909 -0
- prefig/core/circle.py +13 -4
- prefig/core/clip.py +1 -1
- prefig/core/coordinates.py +31 -4
- prefig/core/diagram.py +129 -6
- prefig/core/graph.py +236 -23
- prefig/core/grid_axes.py +181 -495
- prefig/core/image.py +127 -0
- prefig/core/label.py +14 -4
- prefig/core/legend.py +4 -4
- prefig/core/line.py +92 -1
- prefig/core/math_utilities.py +155 -0
- prefig/core/network.py +2 -2
- prefig/core/parametric_curve.py +15 -3
- prefig/core/path.py +25 -0
- prefig/core/point.py +18 -2
- prefig/core/polygon.py +7 -1
- prefig/core/read.py +6 -3
- prefig/core/rectangle.py +10 -4
- prefig/core/repeat.py +37 -3
- prefig/core/shape.py +12 -1
- prefig/core/slope_field.py +142 -0
- prefig/core/tags.py +8 -2
- prefig/core/tangent_line.py +36 -12
- prefig/core/user_namespace.py +8 -0
- prefig/core/utilities.py +9 -1
- prefig/engine.py +73 -28
- prefig/resources/diagcess/diagcess.js +7 -1
- prefig/resources/schema/pf_schema.rnc +89 -6
- prefig/resources/schema/pf_schema.rng +321 -5
- prefig/scripts/install_mj.py +10 -11
- {prefig-0.2.15.dev20250514053750.dist-info → prefig-0.5.6.dev20260130060411.dist-info}/METADATA +12 -16
- prefig-0.5.6.dev20260130060411.dist-info/RECORD +68 -0
- prefig-0.2.15.dev20250514053750.dist-info/RECORD +0 -66
- {prefig-0.2.15.dev20250514053750.dist-info → prefig-0.5.6.dev20260130060411.dist-info}/LICENSE +0 -0
- {prefig-0.2.15.dev20250514053750.dist-info → prefig-0.5.6.dev20260130060411.dist-info}/WHEEL +0 -0
- {prefig-0.2.15.dev20250514053750.dist-info → prefig-0.5.6.dev20260130060411.dist-info}/entry_points.txt +0 -0
prefig/core/circle.py
CHANGED
|
@@ -47,9 +47,12 @@ def circle(element, diagram, parent, outline_status):
|
|
|
47
47
|
if element.get('stroke') is not None:
|
|
48
48
|
element.set('stroke', 'black')
|
|
49
49
|
if element.get('fill') is not None:
|
|
50
|
-
element.
|
|
50
|
+
if element.get('fill').strip().lower() != 'none':
|
|
51
|
+
element.set('fill', 'lightgray')
|
|
52
|
+
else:
|
|
53
|
+
element.set('fill', 'none')
|
|
51
54
|
else:
|
|
52
|
-
element.set('stroke', element.get('stroke', '
|
|
55
|
+
element.set('stroke', element.get('stroke', 'black'))
|
|
53
56
|
element.set('fill', element.get('fill', 'none'))
|
|
54
57
|
element.set('thickness', element.get('thickness', '2'))
|
|
55
58
|
util.add_attr(circle, util.get_2d_attr(element))
|
|
@@ -120,7 +123,10 @@ def ellipse(element, diagram, parent, outline_status):
|
|
|
120
123
|
if element.get('stroke') is not None:
|
|
121
124
|
element.set('stroke', 'black')
|
|
122
125
|
if element.get('fill') is not None:
|
|
123
|
-
|
|
126
|
+
if elementl.get('fill').strip().lower() != 'none':
|
|
127
|
+
element.set('fill', 'lightgray')
|
|
128
|
+
else:
|
|
129
|
+
element.set('fill', 'none')
|
|
124
130
|
else:
|
|
125
131
|
element.set('stroke', element.get('stroke', 'none'))
|
|
126
132
|
element.set('fill', element.get('fill', 'none'))
|
|
@@ -283,7 +289,10 @@ def angle(element, diagram, parent, outline_status):
|
|
|
283
289
|
element.set('stroke', element.get('stroke', 'black'))
|
|
284
290
|
if diagram.output_format() == 'tactile':
|
|
285
291
|
if element.get('fill') is not None:
|
|
286
|
-
element.
|
|
292
|
+
if element.get('fill').strip().lower() != 'none':
|
|
293
|
+
element.set('fill', 'lightgray')
|
|
294
|
+
else:
|
|
295
|
+
element.set('fill', 'none')
|
|
287
296
|
else:
|
|
288
297
|
element.set('fill', element.get('fill', 'none'))
|
|
289
298
|
element.set('thickness', element.get('thickness','2'))
|
prefig/core/clip.py
CHANGED
prefig/core/coordinates.py
CHANGED
|
@@ -27,6 +27,7 @@ def coordinates(element, diagram, root, outline_status):
|
|
|
27
27
|
dest_dy *= -1
|
|
28
28
|
|
|
29
29
|
bbox = un.valid_eval(element.get('bbox'))
|
|
30
|
+
|
|
30
31
|
if element.get('aspect-ratio', None) is not None:
|
|
31
32
|
ratio = un.valid_eval(element.get('aspect-ratio'))
|
|
32
33
|
if element.get('preserve-y-range', 'no') == 'yes':
|
|
@@ -58,15 +59,41 @@ def coordinates(element, diagram, root, outline_status):
|
|
|
58
59
|
clip_box.set('height', util.float2str(height))
|
|
59
60
|
diagram.push_clippath(clippath)
|
|
60
61
|
|
|
62
|
+
scales = element.get('scales', 'linear')
|
|
63
|
+
if scales == 'linear':
|
|
64
|
+
scales = ['linear', 'linear']
|
|
65
|
+
elif scales == 'semilogx':
|
|
66
|
+
scales = ['log', 'linear']
|
|
67
|
+
elif scales == 'semilogy':
|
|
68
|
+
scales = ['linear', 'log']
|
|
69
|
+
elif scales == 'loglog':
|
|
70
|
+
scales = ['log', 'log']
|
|
71
|
+
else:
|
|
72
|
+
scales = ['linear', 'linear']
|
|
73
|
+
|
|
61
74
|
ctm = ctm.copy()
|
|
75
|
+
diagram.push_scales(scales)
|
|
76
|
+
scaled_bbox = bbox.copy()
|
|
77
|
+
if scales[0] == 'log':
|
|
78
|
+
scaled_bbox[0] = np.log10(scaled_bbox[0])
|
|
79
|
+
scaled_bbox[2] = np.log10(scaled_bbox[2])
|
|
80
|
+
ctm.set_log_x()
|
|
81
|
+
if scales[1] == 'log':
|
|
82
|
+
scaled_bbox[1] = np.log10(scaled_bbox[1])
|
|
83
|
+
scaled_bbox[3] = np.log10(scaled_bbox[3])
|
|
84
|
+
ctm.set_log_y()
|
|
85
|
+
|
|
62
86
|
ctm.translate(destination[0], destination[1])
|
|
63
|
-
ctm.scale( (destination[2]-destination[0])/
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
87
|
+
ctm.scale( (destination[2]-destination[0]) /
|
|
88
|
+
float(scaled_bbox[2]-scaled_bbox[0]),
|
|
89
|
+
(destination[3]-destination[1]) /
|
|
90
|
+
float(scaled_bbox[3]-scaled_bbox[1]) )
|
|
91
|
+
ctm.translate(-scaled_bbox[0], -scaled_bbox[1])
|
|
92
|
+
bbox_str = '['+','.join([str(b) for b in scaled_bbox])+']'
|
|
67
93
|
un.valid_eval(bbox_str, 'bbox')
|
|
68
94
|
|
|
69
95
|
diagram.push_ctm([ctm, bbox])
|
|
70
96
|
diagram.parse(element, root, outline_status)
|
|
71
97
|
diagram.pop_ctm()
|
|
72
98
|
diagram.pop_clippath()
|
|
99
|
+
diagram.pop_scales()
|
prefig/core/diagram.py
CHANGED
|
@@ -3,14 +3,21 @@ import lxml.etree as ET
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
import logging
|
|
5
5
|
import copy
|
|
6
|
+
import re
|
|
6
7
|
from . import tags
|
|
7
8
|
from . import user_namespace as un
|
|
8
9
|
from . import utilities as util
|
|
9
10
|
from . import CTM
|
|
10
11
|
from . import label
|
|
12
|
+
from . import math_utilities as math_util
|
|
13
|
+
from . import annotations
|
|
14
|
+
from . import repeat
|
|
11
15
|
|
|
12
16
|
log = logging.getLogger('prefigure')
|
|
13
17
|
|
|
18
|
+
# regular expression to check if id is EPUB compliant
|
|
19
|
+
epub_id_check = re.compile('^[A-Za-z0-9_-]+$')
|
|
20
|
+
|
|
14
21
|
class Diagram:
|
|
15
22
|
def __init__(self,
|
|
16
23
|
diagram_element,
|
|
@@ -30,6 +37,18 @@ class Diagram:
|
|
|
30
37
|
self.environment = environment
|
|
31
38
|
self.caption = ""
|
|
32
39
|
|
|
40
|
+
self.add_default_annotations = True
|
|
41
|
+
if (self.environment == 'pyodide' and
|
|
42
|
+
len(self.diagram_element.xpath('.//annotations')) == 0
|
|
43
|
+
):
|
|
44
|
+
diagram_annotations = annotations.diagram_to_speech(diagram_element)
|
|
45
|
+
annotations_tree = ET.SubElement(self.diagram_element,
|
|
46
|
+
'annotations')
|
|
47
|
+
annotations_tree.append(diagram_annotations)
|
|
48
|
+
self.add_default_annotations = False
|
|
49
|
+
|
|
50
|
+
math_util.set_diagram(self)
|
|
51
|
+
|
|
33
52
|
label.init(self.format, self.environment)
|
|
34
53
|
|
|
35
54
|
# create the XML tree for the svg output
|
|
@@ -41,7 +60,7 @@ class Diagram:
|
|
|
41
60
|
self.root = ET.Element("svg", nsmap = nsmap)
|
|
42
61
|
|
|
43
62
|
self.id_suffix = ['']
|
|
44
|
-
self.add_id(self.root, diagram_element.get('id', '
|
|
63
|
+
self.add_id(self.root, diagram_element.get('id', 'pf__figure'))
|
|
45
64
|
|
|
46
65
|
# prepare the XML tree for annotations, if there are any
|
|
47
66
|
self.annotations_root = None
|
|
@@ -60,6 +79,9 @@ class Diagram:
|
|
|
60
79
|
# a dictionary for holding shapes
|
|
61
80
|
self.shape_dict = {}
|
|
62
81
|
|
|
82
|
+
# dictionary for saving graphical data
|
|
83
|
+
self.saved_data = {}
|
|
84
|
+
|
|
63
85
|
# each SVG element will have an id, we'll store a count of ids here
|
|
64
86
|
self.ids = {}
|
|
65
87
|
|
|
@@ -72,6 +94,9 @@ class Diagram:
|
|
|
72
94
|
# stack for managing bounding boxes and clipping
|
|
73
95
|
self.clippaths = []
|
|
74
96
|
|
|
97
|
+
# stack for managing scales of coordinate systems
|
|
98
|
+
self.scale_stack = []
|
|
99
|
+
|
|
75
100
|
# list for legends
|
|
76
101
|
self.legends = []
|
|
77
102
|
|
|
@@ -86,6 +111,9 @@ class Diagram:
|
|
|
86
111
|
if publication is not None:
|
|
87
112
|
for subelement in publication:
|
|
88
113
|
if subelement.tag == 'external-root':
|
|
114
|
+
log.warning('<external-root> in publication file is deprecated')
|
|
115
|
+
log.warning('Use <directories> instead')
|
|
116
|
+
log.warning('See "Working with data" in the PreFigure documentation"')
|
|
89
117
|
external = subelement.get('name', None)
|
|
90
118
|
if external is not None:
|
|
91
119
|
self.external = external
|
|
@@ -93,10 +121,42 @@ class Diagram:
|
|
|
93
121
|
log.warning('<external-root> in publication file needs a @name')
|
|
94
122
|
continue
|
|
95
123
|
self.defaults[subelement.tag] = subelement
|
|
124
|
+
directories = publication.xpath('.//directories')
|
|
125
|
+
if len(directories) > 0:
|
|
126
|
+
directories = directories[0]
|
|
127
|
+
data_directory = directories.get('data', None)
|
|
128
|
+
if data_directory is not None:
|
|
129
|
+
self.external = data_directory
|
|
130
|
+
|
|
131
|
+
templates = self.diagram_element.xpath('.//templates')
|
|
132
|
+
if len(templates) > 0:
|
|
133
|
+
templates_element = templates[0]
|
|
134
|
+
for template in templates:
|
|
135
|
+
templates_parent = template.getparent()
|
|
136
|
+
templates_parent.remove(template)
|
|
137
|
+
for child in templates_element:
|
|
138
|
+
self.defaults[child.tag] = child
|
|
139
|
+
|
|
140
|
+
author_annotations = self.diagram_element.xpath('.//annotations')
|
|
141
|
+
self.author_annotations_present = len(author_annotations) > 0
|
|
142
|
+
if self.author_annotations_present:
|
|
143
|
+
self.check_annotation_ref(author_annotations[0])
|
|
96
144
|
|
|
97
145
|
if self.defaults.get('macros', None) is not None:
|
|
98
146
|
label.add_macros(self.defaults.get('macros').text)
|
|
99
147
|
|
|
148
|
+
def check_annotation_ref(self, element):
|
|
149
|
+
ref = element.get('ref', None)
|
|
150
|
+
if ref is not None:
|
|
151
|
+
if not bool(epub_id_check.fullmatch(ref)):
|
|
152
|
+
log.error(f"@ref {ref} in an annotation has characters disallowed by EPUB")
|
|
153
|
+
log.error(" We will replace these characters but there may be unexpected behavior")
|
|
154
|
+
log.error(" Search for EPUB in the PreFigure documentation https://prefigure.org")
|
|
155
|
+
ref = repeat.epub_clean(ref)
|
|
156
|
+
element.set('ref', ref)
|
|
157
|
+
for child in element:
|
|
158
|
+
self.check_annotation_ref(child)
|
|
159
|
+
|
|
100
160
|
def add_legend(self, legend):
|
|
101
161
|
self.legends.append(legend)
|
|
102
162
|
|
|
@@ -135,9 +195,12 @@ class Diagram:
|
|
|
135
195
|
suffix = ''.join(self.id_suffix)
|
|
136
196
|
if id is None:
|
|
137
197
|
self.ids[element.tag] = self.ids.get(element.tag, -1) + 1
|
|
138
|
-
|
|
198
|
+
result_id = element.tag+'-'+str(self.ids[element.tag])+suffix
|
|
139
199
|
else:
|
|
140
|
-
|
|
200
|
+
result_id = id + suffix
|
|
201
|
+
if result_id.startswith('pf__'):
|
|
202
|
+
return result_id
|
|
203
|
+
return 'pf__' + result_id
|
|
141
204
|
|
|
142
205
|
def append_id_suffix(self, element):
|
|
143
206
|
return self.find_id(element, element.get('id', None))
|
|
@@ -148,6 +211,9 @@ class Diagram:
|
|
|
148
211
|
def set_output_format(self, format):
|
|
149
212
|
self.format = format
|
|
150
213
|
|
|
214
|
+
def get_environment(self):
|
|
215
|
+
return self.environment
|
|
216
|
+
|
|
151
217
|
# get the HTML tree so that we can add text for labels
|
|
152
218
|
def label_html(self):
|
|
153
219
|
return self.label_html_body
|
|
@@ -175,6 +241,12 @@ class Diagram:
|
|
|
175
241
|
log.error(f"Unable to apply inverse coordinate transform to {p}")
|
|
176
242
|
return np.array([0,0])
|
|
177
243
|
|
|
244
|
+
def save_data(self, element, data):
|
|
245
|
+
self.saved_data[element] = data
|
|
246
|
+
|
|
247
|
+
def retrieve_data(self, element):
|
|
248
|
+
return self.saved_data.get(element, None)
|
|
249
|
+
|
|
178
250
|
def begin_figure(self):
|
|
179
251
|
# set up the dimensions of the diagram in SVG coordinates
|
|
180
252
|
dims = self.diagram_element.get('dimensions')
|
|
@@ -238,6 +310,7 @@ class Diagram:
|
|
|
238
310
|
h = height + margins[1]+margins[3]
|
|
239
311
|
self.root.set("width", str(w))
|
|
240
312
|
self.root.set("height", str(h))
|
|
313
|
+
self.root.set("viewBox", f"0 0 {w} {h}")
|
|
241
314
|
|
|
242
315
|
# initialize the CTM and push it onto the CTM stack
|
|
243
316
|
ctm.translate(0, height + margins[1] + margins[3])
|
|
@@ -247,6 +320,7 @@ class Diagram:
|
|
|
247
320
|
bbox = [0,0,width,height]
|
|
248
321
|
un.enter_namespace('bbox', bbox)
|
|
249
322
|
self.ctm_stack = [[ctm, bbox]]
|
|
323
|
+
self.scale_stack = [['linear', 'linear']]
|
|
250
324
|
|
|
251
325
|
# initialize the SVG element 'defs' and add the clipping path
|
|
252
326
|
self.defs = ET.SubElement(self.root, 'defs')
|
|
@@ -269,6 +343,15 @@ class Diagram:
|
|
|
269
343
|
def pop_clippath(self):
|
|
270
344
|
self.clippaths.pop(-1)
|
|
271
345
|
|
|
346
|
+
def push_scales(self, scales):
|
|
347
|
+
self.scale_stack.append(scales)
|
|
348
|
+
|
|
349
|
+
def pop_scales(self):
|
|
350
|
+
self.scale_stack.pop(-1)
|
|
351
|
+
|
|
352
|
+
def get_scales(self):
|
|
353
|
+
return self.scale_stack[-1]
|
|
354
|
+
|
|
272
355
|
def get_clippath(self):
|
|
273
356
|
return self.clippaths[-1]
|
|
274
357
|
|
|
@@ -332,6 +415,18 @@ class Diagram:
|
|
|
332
415
|
log.error(f"Unable to write SVG at {out+'.svg'}")
|
|
333
416
|
return
|
|
334
417
|
|
|
418
|
+
if self.author_annotations_present and self.environment == "pretext":
|
|
419
|
+
# we will write out a second version of the diagram
|
|
420
|
+
# without the height and width attributes for diagcess use
|
|
421
|
+
self.root.attrib.pop("height")
|
|
422
|
+
self.root.attrib.pop("width")
|
|
423
|
+
try:
|
|
424
|
+
with ET.xmlfile(out + '-diagcess.svg', encoding='utf-8') as xf:
|
|
425
|
+
xf.write(self.root, pretty_print=True)
|
|
426
|
+
except:
|
|
427
|
+
log.error(f"Unable to write SVG at {out+'-diagcess.svg'}")
|
|
428
|
+
return
|
|
429
|
+
|
|
335
430
|
if self.annotations_root is not None:
|
|
336
431
|
diagram = ET.Element('diagram')
|
|
337
432
|
diagram.append(self.annotations_root)
|
|
@@ -388,6 +483,15 @@ class Diagram:
|
|
|
388
483
|
# we're publicly using 'at' rather than 'id' for handles
|
|
389
484
|
if child.get('at') is not None:
|
|
390
485
|
child.set('id', child.get('at'))
|
|
486
|
+
|
|
487
|
+
child_id = child.get('id', None)
|
|
488
|
+
if child_id is not None:
|
|
489
|
+
if not bool(epub_id_check.fullmatch(child_id)):
|
|
490
|
+
log.error(f"The id {child_id} has characters disallowed by EPUB")
|
|
491
|
+
log.error(" We will substitute disallowed characters to make the id EPUB compliant")
|
|
492
|
+
log.error(" Search for EPUB in the PreFigure documentation at https://prefigure.org")
|
|
493
|
+
child.set('id', repeat.epub_clean(child_id))
|
|
494
|
+
|
|
391
495
|
# see if the publication flie has any defaults
|
|
392
496
|
defaults = self.defaults.get(child.tag, None)
|
|
393
497
|
if defaults is not None:
|
|
@@ -459,6 +563,13 @@ class Diagram:
|
|
|
459
563
|
def get_root(self):
|
|
460
564
|
return self.root
|
|
461
565
|
|
|
566
|
+
def apply_defaults(self, tag, element):
|
|
567
|
+
default = self.defaults.get(tag, None)
|
|
568
|
+
if default is not None:
|
|
569
|
+
for attr, value in default.attrib.items():
|
|
570
|
+
if element.get(attr, None) is None:
|
|
571
|
+
element.set(attr, value)
|
|
572
|
+
|
|
462
573
|
# when a graphical component is outlined, we first add the component's path
|
|
463
574
|
# to <defs> so that it can be reused, then we stroke it with a thick white
|
|
464
575
|
def add_outline(self, element, path, parent, outline_width = None):
|
|
@@ -518,7 +629,11 @@ class Diagram:
|
|
|
518
629
|
# We have to clean up the arrow heads. The references to the
|
|
519
630
|
# arrow heads are in the reusable so we'll retrieve them and
|
|
520
631
|
# and include them with the use element.
|
|
521
|
-
|
|
632
|
+
element_id = element.get('id')
|
|
633
|
+
if element_id.endswith(self.id_suffix[-1]):
|
|
634
|
+
reuse_handle = element_id + '-outline'
|
|
635
|
+
else:
|
|
636
|
+
reuse_handle = element.get('id')+self.id_suffix[-1]+'-outline'
|
|
522
637
|
reusable = self.get_reusable(reuse_handle)
|
|
523
638
|
use.set('href', r'#' + reuse_handle)
|
|
524
639
|
for marker in ['marker-start', 'marker-end', 'marker-mid']:
|
|
@@ -534,6 +649,8 @@ class Diagram:
|
|
|
534
649
|
self.annotations_root = ET.Element('annotations')
|
|
535
650
|
|
|
536
651
|
def add_default_annotation(self, annotation):
|
|
652
|
+
if not self.add_default_annotations:
|
|
653
|
+
return
|
|
537
654
|
self.default_annotations.append(annotation)
|
|
538
655
|
|
|
539
656
|
def get_default_annotations(self):
|
|
@@ -557,10 +674,16 @@ class Diagram:
|
|
|
557
674
|
|
|
558
675
|
def add_annotation_to_branch(self, annotation):
|
|
559
676
|
if len(self.annotation_branch_stack) == 0:
|
|
560
|
-
|
|
677
|
+
id = annotation.get('id')
|
|
678
|
+
if not id.startswith('pf__'):
|
|
679
|
+
id = 'pf__' + id
|
|
680
|
+
self.annotation_branches[id] = annotation
|
|
561
681
|
return
|
|
562
682
|
self.annotation_branch_stack[-1].append(annotation)
|
|
563
|
-
|
|
683
|
+
id = self.append_id_suffix(annotation)
|
|
684
|
+
if not id.startswith('pf__'):
|
|
685
|
+
id = 'pf__' + id
|
|
686
|
+
annotation.set('id', id)
|
|
564
687
|
|
|
565
688
|
def get_annotation_branch(self, id):
|
|
566
689
|
return self.annotation_branches.pop(id, None)
|
prefig/core/graph.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import lxml.etree as ET
|
|
2
2
|
import logging
|
|
3
|
+
import math
|
|
4
|
+
import numpy as np
|
|
5
|
+
from . import math_utilities as math_util
|
|
3
6
|
from . import user_namespace as un
|
|
4
7
|
from . import utilities as util
|
|
8
|
+
from . import arrow
|
|
5
9
|
|
|
6
10
|
log = logging.getLogger('prefigure')
|
|
7
11
|
|
|
@@ -14,13 +18,35 @@ def graph(element, diagram, parent, outline_status = None):
|
|
|
14
18
|
finish_outline(element, diagram, parent)
|
|
15
19
|
return
|
|
16
20
|
|
|
21
|
+
polar = element.get('coordinates', 'cartesian') == 'polar'
|
|
17
22
|
# by default, the domain is the width of the bounding box
|
|
18
23
|
bbox = diagram.bbox()
|
|
19
24
|
domain = element.get('domain')
|
|
20
25
|
if domain is None:
|
|
21
|
-
|
|
26
|
+
if polar:
|
|
27
|
+
domain = [0, 2*math.pi]
|
|
28
|
+
else:
|
|
29
|
+
domain = [bbox[0], bbox[2]]
|
|
22
30
|
else:
|
|
23
31
|
domain = un.valid_eval(domain)
|
|
32
|
+
if domain[0] == -np.inf:
|
|
33
|
+
domain[0] = bbox[0]
|
|
34
|
+
if domain[1] == np.inf:
|
|
35
|
+
domain[1] = bbox[2]
|
|
36
|
+
|
|
37
|
+
# if there are arrows, we need to pull the domain in by two pixels
|
|
38
|
+
# so that the arrows don't go outside the domain
|
|
39
|
+
arrows = int(element.get('arrows', '0'))
|
|
40
|
+
if arrows > 0 and not polar:
|
|
41
|
+
end = diagram.transform((domain[1], 0))
|
|
42
|
+
end[0] -= 2
|
|
43
|
+
new_domain = diagram.inverse_transform(end)
|
|
44
|
+
domain[1] = new_domain[0]
|
|
45
|
+
if arrows == 2 and not polar:
|
|
46
|
+
begin = diagram.transform((domain[0],0))
|
|
47
|
+
begin[0] += 2
|
|
48
|
+
new_domain = diagram.inverse_transform(begin)
|
|
49
|
+
domain[0] = new_domain[0]
|
|
24
50
|
|
|
25
51
|
# retrieve the function from the namespace and generate points
|
|
26
52
|
try:
|
|
@@ -29,28 +55,11 @@ def graph(element, diagram, parent, outline_status = None):
|
|
|
29
55
|
log.error(f"Error retrieving function in graph: {str(e)}")
|
|
30
56
|
return
|
|
31
57
|
|
|
32
|
-
N = int(element.get('N', 100))
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
upper = 2*bbox[3] - bbox[1]
|
|
38
|
-
lower = 2*bbox[1] - bbox[3]
|
|
39
|
-
for _ in range(N+1):
|
|
40
|
-
try:
|
|
41
|
-
y = f(x)
|
|
42
|
-
except:
|
|
43
|
-
next_cmd = 'M'
|
|
44
|
-
x += dx
|
|
45
|
-
continue
|
|
46
|
-
if y > upper or y < lower:
|
|
47
|
-
next_cmd = 'M'
|
|
48
|
-
x += dx
|
|
49
|
-
continue
|
|
50
|
-
p = diagram.transform((x, f(x)))
|
|
51
|
-
cmds += [next_cmd, util.pt2str(p)]
|
|
52
|
-
next_cmd = 'L'
|
|
53
|
-
x += dx
|
|
58
|
+
N = int(element.get('N', '100'))
|
|
59
|
+
if polar:
|
|
60
|
+
cmds = polar_path(element, diagram, f, domain, N)
|
|
61
|
+
else:
|
|
62
|
+
cmds = cartesian_path(element, diagram, f, domain, N)
|
|
54
63
|
|
|
55
64
|
# now set up the attributes
|
|
56
65
|
util.set_attr(element, 'thickness', '2')
|
|
@@ -66,9 +75,33 @@ def graph(element, diagram, parent, outline_status = None):
|
|
|
66
75
|
'fill': 'none'
|
|
67
76
|
}
|
|
68
77
|
)
|
|
78
|
+
if polar and element.get('fill', None) is not None:
|
|
79
|
+
attrib['fill'] = element.get('fill')
|
|
69
80
|
|
|
70
81
|
path = ET.Element('path', attrib = attrib)
|
|
71
82
|
|
|
83
|
+
arrows = int(element.get('arrows', '0'))
|
|
84
|
+
forward = 'marker-end'
|
|
85
|
+
backward = 'marker-start'
|
|
86
|
+
if element.get('reverse', 'no') == 'yes':
|
|
87
|
+
forward, backward = backward, forward
|
|
88
|
+
if arrows > 0:
|
|
89
|
+
arrow.add_arrowhead_to_path(
|
|
90
|
+
diagram,
|
|
91
|
+
forward,
|
|
92
|
+
path,
|
|
93
|
+
arrow_width=element.get('arrow-width', None),
|
|
94
|
+
arrow_angles=element.get('arrow-angles', None)
|
|
95
|
+
)
|
|
96
|
+
if arrows > 1:
|
|
97
|
+
arrow.add_arrowhead_to_path(
|
|
98
|
+
diagram,
|
|
99
|
+
backward,
|
|
100
|
+
path,
|
|
101
|
+
arrow_width=element.get('arrow-width', None),
|
|
102
|
+
arrow_angles=element.get('arrow-angles', None)
|
|
103
|
+
)
|
|
104
|
+
|
|
72
105
|
# By default, we clip the graph to the bounding box
|
|
73
106
|
if element.get('cliptobbox') is None:
|
|
74
107
|
element.set('cliptobbox', 'yes')
|
|
@@ -92,4 +125,184 @@ def finish_outline(element, diagram, parent):
|
|
|
92
125
|
element.get('fill', 'none'),
|
|
93
126
|
parent)
|
|
94
127
|
|
|
128
|
+
def cartesian_path(element, diagram, f, domain, N):
|
|
129
|
+
# Sometimes we encounter a divide by zero when building a graph
|
|
130
|
+
# These are safe to ignore since they return an nan
|
|
131
|
+
np.seterr(divide="ignore")
|
|
132
|
+
|
|
133
|
+
# The graphing routine is relatively straightforward.
|
|
134
|
+
# We just walk across the horizontal axis and connect points with lines
|
|
135
|
+
# We try to detect if the function is not defined or if we have passed
|
|
136
|
+
# a vertical asymptote. One complication is that we try to get close
|
|
137
|
+
# to the singularity or vertical asymptote by subdividing the
|
|
138
|
+
# interval based on the last good point we've seen
|
|
139
|
+
#
|
|
140
|
+
# In the vertical direction, we imagine a buffer (lower, upper)
|
|
141
|
+
# where upper - lower = 3*height and with the viewing box centered inside.
|
|
142
|
+
# We plot anything in the buffer.
|
|
143
|
+
#
|
|
144
|
+
# We maintain a history of sorts using next_cmd, which is either 'M' or 'L'
|
|
145
|
+
# and last_visible, which tells us whether the last point plotted is
|
|
146
|
+
# in the viewing window
|
|
147
|
+
|
|
148
|
+
scales = diagram.get_scales()
|
|
149
|
+
if scales[0] == 'log':
|
|
150
|
+
x_positions = np.logspace(np.log10(domain[0]),
|
|
151
|
+
np.log10(domain[1]),
|
|
152
|
+
N+1)
|
|
153
|
+
else:
|
|
154
|
+
x_positions = np.linspace(domain[0], domain[1], N+1)
|
|
155
|
+
|
|
156
|
+
bbox = diagram.bbox()
|
|
157
|
+
dx = (domain[1] - domain[0])/N
|
|
158
|
+
x = domain[0]
|
|
159
|
+
cmds = []
|
|
160
|
+
next_cmd = 'M'
|
|
161
|
+
if scales[1] == 'log':
|
|
162
|
+
bottom = np.log10(bbox[1])
|
|
163
|
+
top = np.log10(bbox[3])
|
|
164
|
+
lower = 10**(bottom - 3)
|
|
165
|
+
upper = 10**(top + 3)
|
|
166
|
+
else:
|
|
167
|
+
height = (bbox[3] - bbox[1])
|
|
168
|
+
upper = bbox[3] + height
|
|
169
|
+
lower = bbox[1] - height
|
|
170
|
+
last_visible = False
|
|
171
|
+
for i, x in enumerate(x_positions):
|
|
172
|
+
if i > 0:
|
|
173
|
+
dx = x - x_positions[i-1]
|
|
174
|
+
else:
|
|
175
|
+
dx = 0
|
|
176
|
+
try:
|
|
177
|
+
y = f(x)
|
|
178
|
+
except:
|
|
179
|
+
if last_visible:
|
|
180
|
+
# we plotted the last point so let's find the singularity,
|
|
181
|
+
# which is in the interval (x-dx, x). We subdivide 8 times
|
|
182
|
+
# keeping the last valid value in last_good_x
|
|
183
|
+
ddx = dx/2
|
|
184
|
+
xx = x - ddx
|
|
185
|
+
last_good_x = x - dx
|
|
186
|
+
for _ in range(8):
|
|
187
|
+
ddx /= 2
|
|
188
|
+
try:
|
|
189
|
+
y = f(xx)
|
|
190
|
+
except:
|
|
191
|
+
xx -= ddx
|
|
192
|
+
continue
|
|
193
|
+
last_good_x = xx
|
|
194
|
+
xx += ddx
|
|
195
|
+
p = diagram.transform((last_good_x, f(last_good_x)))
|
|
196
|
+
cmds += ['L', util.pt2str(p)]
|
|
197
|
+
|
|
198
|
+
last_visible = False
|
|
199
|
+
next_cmd = 'M'
|
|
200
|
+
x += dx
|
|
201
|
+
continue
|
|
202
|
+
if y > upper or y < lower:
|
|
203
|
+
if last_visible:
|
|
204
|
+
# the last point was visible so this could be a vertical
|
|
205
|
+
# asymptote. We will subdivide until we're in the plotting
|
|
206
|
+
# range
|
|
207
|
+
ddx = dx/2
|
|
208
|
+
xx = x - ddx
|
|
209
|
+
last_good_x = x - dx
|
|
210
|
+
for _ in range(8):
|
|
211
|
+
ddx /= 2
|
|
212
|
+
yy = f(xx)
|
|
213
|
+
if yy > upper or yy < lower:
|
|
214
|
+
xx -= ddx
|
|
215
|
+
else:
|
|
216
|
+
last_good_x = xx
|
|
217
|
+
xx += ddx
|
|
218
|
+
p = diagram.transform((last_good_x, f(last_good_x)))
|
|
219
|
+
cmds += ['L', util.pt2str(p)]
|
|
220
|
+
|
|
221
|
+
last_visible = False
|
|
222
|
+
next_cmd = 'M'
|
|
223
|
+
x += dx
|
|
224
|
+
continue
|
|
225
|
+
if next_cmd == 'M' and x > domain[0]:
|
|
226
|
+
# let's see if we need to back up a bit to find the asymptote
|
|
227
|
+
# or edge of the domain
|
|
228
|
+
ddx = dx/2
|
|
229
|
+
xx = x - ddx
|
|
230
|
+
last_good_x = x
|
|
231
|
+
for _ in range(8):
|
|
232
|
+
ddx /= 2
|
|
233
|
+
try:
|
|
234
|
+
yy = f(xx)
|
|
235
|
+
except:
|
|
236
|
+
xx += ddx
|
|
237
|
+
continue
|
|
238
|
+
if yy > upper or yy < lower:
|
|
239
|
+
xx += ddx
|
|
240
|
+
continue
|
|
241
|
+
last_good_x = xx
|
|
242
|
+
xx -= ddx
|
|
243
|
+
|
|
244
|
+
if last_good_x < x:
|
|
245
|
+
p = diagram.transform((last_good_x, f(last_good_x)))
|
|
246
|
+
cmds += ['M', util.pt2str(p)]
|
|
247
|
+
next_cmd = 'L'
|
|
248
|
+
|
|
249
|
+
p = diagram.transform((x, y))
|
|
250
|
+
cmds += [next_cmd, util.pt2str(p)]
|
|
251
|
+
next_cmd = 'L'
|
|
252
|
+
x += dx
|
|
253
|
+
if y < bbox[3] and y > bbox[1]:
|
|
254
|
+
last_visible = True
|
|
255
|
+
else:
|
|
256
|
+
last_visible = False
|
|
257
|
+
|
|
258
|
+
return cmds
|
|
259
|
+
|
|
260
|
+
def log_path(element, diagram, f, domain, N):
|
|
261
|
+
log_y = diagram.get_scales()[1] == 'log'
|
|
262
|
+
x0 = np.log10(domain[0])
|
|
263
|
+
x1 = np.log10(domain[1])
|
|
264
|
+
x_values = np.logspace(x0, x1, N+1)
|
|
265
|
+
cmds = []
|
|
266
|
+
next_cmd = 'M'
|
|
267
|
+
for x in x_values:
|
|
268
|
+
y = f(x)
|
|
269
|
+
if y < 0 and log_y:
|
|
270
|
+
next_cmd = 'M'
|
|
271
|
+
continue
|
|
272
|
+
p = diagram.transform((x, f(x)))
|
|
273
|
+
cmds += [next_cmd, util.pt2str(p)]
|
|
274
|
+
next_cmd = 'L'
|
|
275
|
+
return cmds
|
|
276
|
+
|
|
277
|
+
def polar_path(element, diagram, f, domain, N):
|
|
278
|
+
bbox = diagram.bbox()
|
|
279
|
+
center = math_util.midpoint(bbox[:2], bbox[2:])
|
|
280
|
+
R = math_util.distance(center, bbox[2:])
|
|
281
|
+
|
|
282
|
+
if element.get('domain-degrees', 'no') == 'yes':
|
|
283
|
+
domain = [math.radians(d) for d in domain]
|
|
284
|
+
t = domain[0]
|
|
285
|
+
dt = (domain[1] - domain[0])/N
|
|
286
|
+
polar_cmds = []
|
|
287
|
+
next_cmd = 'M'
|
|
288
|
+
for _ in range(N+1):
|
|
289
|
+
try:
|
|
290
|
+
r = f(t)
|
|
291
|
+
except:
|
|
292
|
+
next_cmd = 'M'
|
|
293
|
+
t += dt
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
p = (r*math.cos(t), r*math.sin(t))
|
|
297
|
+
if math_util.distance(p, center) > 2*R:
|
|
298
|
+
next_cmd = 'M'
|
|
299
|
+
t += dt
|
|
300
|
+
continue
|
|
301
|
+
polar_cmds.append(next_cmd)
|
|
302
|
+
polar_cmds.append(util.pt2str(diagram.transform(p)))
|
|
303
|
+
next_cmd = 'L'
|
|
304
|
+
t += dt
|
|
305
|
+
if element.get('closed', 'no') == 'yes':
|
|
306
|
+
polar_cmds.append('Z')
|
|
95
307
|
|
|
308
|
+
return polar_cmds
|