Source code for ete4.smartview.draw

"""
Classes and functions for drawing a tree.
"""

from math import sin, cos, pi, sqrt, atan2

from ..core import operations as ops
from .coordinates import Size, Box, make_box, get_xs, get_ys
from .layout import Label, update_style
from .faces import LegendFace, EvalTextFace, default_anchors
from . import graphics as gr


[docs] def draw(tree, layouts, overrides=None, labels=None, viewport=None, zoom=(1, 1, 1), collapsed_ids=None, searches=None): """Yield graphic commands to draw the tree.""" style = {} # tree style faces = [] # tree faces # Merge the style from all layouts, and get all faces. for layout in layouts: for element in layout.draw_tree(tree): if type(element) is dict: update_style(style, element) else: faces.append(element) # NOTE: No need to susbstitute aliased values for their "aliases" # style, as opposed to the /style endpoint in explorer.py. # Those tree styles are applied (via gui.js) through css manipulation. # Override tree style (with options that normally come from the gui). style.update(overrides) # Get the appropriate drawer (for rectangular or circular), and draw! drawer_class = {'rectangular': DrawerRect, 'circular': DrawerCirc}[style['shape']] draw_node_fns = [layout.draw_node for layout in layouts] drawer_obj = drawer_class(tree, style, draw_node_fns, labels, viewport, zoom, collapsed_ids, searches) yield from drawer_obj.draw() # yield graphic commands for all nodes # Get the graphic commands, and xmaxs, from applying the tree faces. xmin = 0 box = Box(0, 0, 0, 0) # unlimited in all directions bdy = 0 content_height_min = style.get('content-height-min', 5) circular = (style['shape'] == 'circular') commands, xmaxs = draw_faces(faces, [tree], xmin, box, bdy, zoom, content_height_min, collapsed=[], circular=circular) yield from commands # yield graphics commands for the tree # Now that we know all sizes, yield the "setting all xmax values" command. for panel in set(xmaxs.keys()) | set(drawer_obj.xmaxs.keys()): xmaxs[panel] = max(xmaxs.get(panel, 0), drawer_obj.xmaxs.get(panel, 0)) yield gr.set_xmaxs(xmaxs) # And draw the legends too. for face in faces: if type(face) is LegendFace: yield gr.draw_legend(face.title, face.variable, face.colormap, face.value_range, face.color_range)
[docs] class Drawer: """Base class (needs subclassing with extra functions to draw)."""
[docs] def __init__(self, tree, tree_style=None, draw_node_fns=None, labels=None, viewport=None, zoom=(1, 1, 1), collapsed_ids=None, searches=None): self.tree = tree self.tree_style = tree_style or {} self.draw_node_fns = draw_node_fns or [] self.labels = [read_label(label) for label in (labels or [])] self.viewport = Box(*viewport) if viewport else None self.zoom = zoom self.collapsed_ids = collapsed_ids or set() # manually collapsed self.searches = searches or {} # looks like {text: (results, parents)} # Get some useful constants from the tree style. # Any node that has less pixels will be collapsed. self.node_height_min = self.tree_style.get('node-height-min', 10) # Any content with less pixels won't be shown. self.content_height_min = self.tree_style.get('content-height-min', 5) # xmin, ymin, ymax used only for the circular mode for the moment. self.xmin, self.ymin, self.ymax = 0, 0, 0
[docs] def draw(self): """Yield commands to draw the tree.""" self.collapsed = [] # nodes that are curretly collapsed together self.outline = None # box surrounding the current collapsed nodes self.nodeboxes = [] # boxes surrounding all nodes and collapsed boxes self.nodes_dx = [0] # nodes dx, from root to current (with subnodes) self.bdy_dys = [[]] # lists of branch dys and total dys self.xmaxs = {0: 0} # maximum x values we reached per panel point = self.xmin, self.ymin for it in ops.walk(self.tree): graphics = [] # list that will contain the graphic elements to draw if it.first_visit: point = self.on_first_visit(point, it, graphics) else: point = self.on_last_visit(point, it, graphics) yield from graphics if self.collapsed: yield from self.flush_collapsed() # send the remaining drawings # We have been collecting in postorder the boxes surrounding the nodes. # Draw them now in preorder (so they stack nicely, small over big ones). yield from self.nodeboxes[::-1]
# NOTE: No need to do "yield gr.set_xmaxs(self.xmaxs)". The calling # function will use our self.xmaxs and send the right command.
[docs] def on_first_visit(self, point, it, graphics): """Update list of graphics to draw and return new position.""" box_node = make_box(point, self.node_size(it.node)) x, y = point # Skip if not in viewport. if not self.is_visible(box_node): self.bdy_dys[-1].append( (box_node.dy / 2, box_node.dy) ) it.descend = False # skip children return x, y + box_node.dy # Deal with collapsed nodes. is_leaf_fn = self.tree_style.get('is-leaf-fn') is_collapsed = (it.node_id in self.collapsed_ids or is_leaf_fn and is_leaf_fn(it.node)) if self.collapsed and (is_collapsed or not self.is_small(self.outline)): graphics += self.flush_collapsed() # don't stack more collapsed if is_collapsed or self.is_small(box_node): self.nodes_dx[-1] = max(self.nodes_dx[-1], box_node.dx) self.collapsed.append(it.node) self.outline = stack(self.outline, box_node) self.clip_outline() # make sure self.outline has a reasonable box it.descend = False # skip children return x, y + box_node.dy # If we arrive here, the node will be fully drawn (eventually). if self.collapsed: # if there were previously collapsed nodes... graphics += self.flush_collapsed() # draw and reset them self.bdy_dys.append([]) # we will store new branch dys and total dys dx, dy = self.content_size(it.node) if it.node.is_leaf: return self.on_last_visit((x + dx, y + dy), it, graphics) else: self.nodes_dx.append(0) # keep track of the extra dx from children return x + dx, y
[docs] def on_last_visit(self, point, it, graphics): """Update list of graphics to draw and return new position.""" # This node (it.node) is being visited in post-order. if self.collapsed: # we flush any pending collapsed nodes first graphics += self.flush_collapsed() x_after, y_after = point # before +--dx--+ dx, dy = self.content_size(it.node) # dy| | x_before, y_before = x_after - dx, y_after - dy # +------+ after style, content_graphics = self.draw_content(it.node, (x_before, y_before)) graphics += content_graphics # dx of the node including all its graphics and its children's. ndx = ((max(self.xmaxs[0], x_after) - x_before) if it.node.is_leaf else (dx + self.nodes_dx.pop())) self.nodes_dx[-1] = max(self.nodes_dx[-1], ndx) # keep track of max(dx) box = Box(x_before, y_before, ndx, dy) result_of = [text for text,(results,_) in self.searches.items() if it.node in results] self.nodeboxes += self.draw_nodebox(it.node, it.node_id, box, result_of, style.get('box', '')) return x_before, y_after
[docs] def draw_content(self, node, point): """Return the node content's style and graphic commands.""" x, y = point dx, dy = self.content_size(node) box = Box(x, y, dx, dy) if not self.is_visible(box): return {}, [], x + dx commands = [] # will contain the graphics commands to return # Find branch dy to first child (bdy0), last (bdy1), and self (bdy). bdy_dys = self.bdy_dys.pop() # bdy_dys[i] == (bdy, dy) if bdy_dys: bdy0 = bdy_dys[0][0] # branch dy to first child dy_butlast = dy - bdy_dys[-1][1] # dy of combined children but last bdy1 = dy_butlast + bdy_dys[-1][0] # branch dy to last child else: bdy0 = bdy1 = dy / 2 # branch dys of the first and last children bdy = (bdy0 + bdy1) / 2 # this node's branch dy self.bdy_dys[-1].append( (bdy, dy) ) # Get the drawing commands and style that the user wants for this node. style, node_commands = self.draw_nodes([node], box, bdy) # Draw the branch line ("hz_line"). if dx > 0: parent_of = [text for text,(_,parents) in self.searches.items() if node in parents] commands += self.draw_hz_line((x, y + bdy), (x + dx, y + bdy), parent_of, style=style.get('hz-line', '')) # Draw a line spanning all children ("vt_line"). if bdy0 != bdy1: commands += self.draw_vt_line((x + dx, y + bdy0), (x + dx, y + bdy1), style=style.get('vt-line', '')) # Draw a dot on the node tip. dot_center = (x + dx, y + bdy) if self.is_visible(make_box(dot_center, (0, 0))): commands.append(gr.draw_nodedot(dot_center, dy_max=min(bdy, dy-bdy), style=style.get('dot', ''))) return style, commands + node_commands
[docs] def flush_collapsed(self): """Yield representation and graphics from collapsed nodes.""" # This includes all the graphics for representing the collapsed nodes, # and empties self.outline and self.collapsed. result_of = [text for text,(results,parents) in self.searches.items() if any(node in results or node in parents for node in self.collapsed)] graphics = [] # will contain the graphic commands to draw node0 = self.collapsed[0] uncollapse = len(self.collapsed) == 1 and node0.is_leaf # single leaf? if not uncollapse: # normal case: we represent the collapsed nodes graphics += self.draw_collapsed() # it updates self.bdy_dys too style, collapsed_graphics = self.draw_nodes( self.collapsed, self.outline, self.outline.dy / 2) graphics += collapsed_graphics else: # forced uncollapse: we simply draw node0's content self.bdy_dys.append([]) # empty list of extra bdy_dys to add x, y, _, _ = self.outline style, content_graphics = self.draw_content(node0, (x, y)) graphics += content_graphics self.collapsed = [] # reset the list of currently collapsed nodes x, y, dx, dy = self.outline ndx = self.xmaxs[0] - x self.nodes_dx[-1] = max(self.nodes_dx[-1], ndx) self.outline = None # reset the outline box nodebox = Box(x, y, max(dx, ndx), dy) name, props = (('(collapsed)', {}) if not uncollapse else (node0.name, self.get_nodeprops(node0))) box = gr.draw_nodebox(nodebox, name, props, node0.id, result_of, style.get('collapsed', '')) self.nodeboxes.append(box) yield from graphics
[docs] def draw_collapsed(self, *args, **kwargs): """Yield collapsed nodes representation.""" # This is the shape of the outline. It also updates self.bdy_dys. x, y, dx, dy = self.outline _, zy, _ = self.zoom shape = self.tree_style.get('collapsed-shape', 'skeleton') if shape == 'skeleton': points = points_from_nodes(self.collapsed, (x, y), self.content_height_min/zy, *args, **kwargs) # for subclasses y1 = points[-1][1] # last point's y (it is at branch position) self.bdy_dys[-1].append( (y1 - y, dy) ) yield gr.draw_skeleton(points) elif shape == 'outline': self.bdy_dys[-1].append( (dy/2, dy) ) yield gr.draw_outline(self.outline) else: raise ValueError(f'unrecognized collapsed shape: {shape!r}')
[docs] def get_nodeprops(self, node): """Return the node properties that we want to show with the nodebox.""" style = self.tree_style # shortcut # Not present? use defaults; None? use all; else, use whatever they are. shown = (style['show-popup-props'] if 'show-popup-props' in style else ['dist', 'support']) # defaults shown = shown if shown is not None else node.props.keys() hidden = style.get('hide-popup-props') or [] # nothing special for None return {k: str(node.props[k]) for k in shown if k in node.props and k not in hidden}
# NOTE: So the properties appear in the order given in included.
[docs] def draw_nodes(self, nodes, box, bdy, circular): # bdy: branch dy (height) """Return style and graphic commands for representing nodes.""" style = {} # style, combining all the existing style dictionaries faces = [] # list of all faces to draw # Add style and faces from draw_node_fns (from layouts). for draw_node in self.draw_node_fns: for element in draw_node(nodes[0], tuple(self.collapsed)): # NOTE: draw_node() is cached: tuple(...) works (can be hashed). if type(element) is dict: style.update(element) else: faces.append(element) # from layouts # Add faces from labels. is_leaf = nodes[0].is_leaf or self.collapsed # for is_valid_label() faces.extend(make_face(label) for label in self.labels if is_valid_label(label, is_leaf)) # Get the graphic commands, and xmaxs, from applying the faces. commands, xmaxs = draw_faces(faces, nodes, self.xmin, box, bdy, self.zoom, self.content_height_min, collapsed=self.collapsed, circular=circular) for panel, x in xmaxs.items(): self.xmaxs[panel] = max(self.xmaxs.get(panel, 0), x) return style, commands
[docs] def read_label(label): """Return a Label from the label description as a tuple.""" expression, node_type, position, column, (ax, ay), fs_max = label assert node_type in ['leaf', 'internal', 'any'], \ f'invalid node type: {node_type}' assert position in ['top', 'bottom', 'left', 'right', 'aligned'], \ f'invalid position: {position}' def to_num(a): return float(a) if a is not None else None # Name the style similar to label.js with get_class_name(...). valid = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-' style = 'label_' + ''.join(x for x in expression if x in valid) return Label( code=compile(expression, '<string>', 'eval'), style=style, # will be used to set its looks in css node_type=node_type, # type of nodes to apply this label to position=position, # top, bottom, left, right, aligned column=int(column), # to locate relative to others in the same position anchor=(to_num(ax), to_num(ay)), # point used for anchoring fs_max=fs_max) # maximum font size (height in pixels)
[docs] class DrawerRect(Drawer): """Drawer for a rectangular representation."""
[docs] def __init__(self, tree, tree_style=None, draw_node_fns=None, labels=None, viewport=None, zoom=(1, 1, 1), collapsed_ids=None, searches=None): super().__init__(tree, tree_style, draw_node_fns, labels, viewport, zoom, collapsed_ids, searches)
# We don't really need to define this function, but we do it # for symmetry, because in DrawerCirc it needs to do more things.
[docs] def is_visible(self, box): """Return True if the node in box will produce something visible.""" if not self.viewport: return True # everything is visible if viewport is unrestricted return intersects_segment(get_ys(self.viewport), get_ys(box))
# NOTE: If we didn't care about aligned items, we could restrict more: # return intersects_box(self.viewport, box)
[docs] def clip_outline(self): """Clip borders of outline to make sure that its box is reasonable.""" pass # this function exists only for symmetry with DrawerCirc
[docs] def draw_collapsed(self): """Yield collapsed nodes representation with all the skeleton points.""" yield from super().draw_collapsed()
# For symmetry with DrawerCirc.
[docs] def node_size(self, node): """Return the size of a node (its content and its children).""" return Size(node.size[0], node.size[1])
[docs] def content_size(self, node): """Return the size of the node's content.""" return Size(dist(node), node.size[1])
[docs] def is_small(self, box): _, zy, _ = self.zoom return box.dy * zy < self.node_height_min
[docs] def draw_hz_line(self, p1, p2, parent_of, style): """Yield a "horizontal line" representing a length.""" line = gr.draw_hz_line(p1, p2, parent_of, style) if not self.viewport or intersects_box(self.viewport, get_rect(line)): yield line
[docs] def draw_vt_line(self, p1, p2, style): """Yield a "vertical line" spanning children, from p1 to p2.""" line = gr.draw_vt_line(p1, p2, style) if not self.viewport or intersects_box(self.viewport, get_rect(line)): yield line
[docs] def draw_nodebox(self, node, node_id, box, result_of, style): props = self.get_nodeprops(node) yield gr.draw_nodebox(box, node.name, props, node_id, result_of, style)
[docs] def draw_nodes(self, nodes, box, bdy): # bdy: branch dy (height) """Return style and graphic commands for the contents of nodes.""" return super().draw_nodes(nodes, box, bdy, circular=False)
[docs] class DrawerCirc(Drawer): """Drawer for a circular representation."""
[docs] def __init__(self, tree, tree_style=None, draw_node_fns=None, labels=None, viewport=None, zoom=(1, 1, 1), collapsed_ids=None, searches=None): super().__init__(tree, tree_style, draw_node_fns, labels, viewport, zoom, collapsed_ids, searches) assert self.zoom[0] == self.zoom[1], 'zoom must be equal in x and y' self.xmin = self.tree_style.get('radius', 0) amin = self.tree_style.get('angle-start') amax = self.tree_style.get('angle-end') self.ymin = amin * pi/180 if amin is not None else -pi self.ymax = amax * pi/180 if amax is not None else +pi da = self.tree_style.get('angle-span') if da is not None: if amin is not None and amax is not None: assert abs(amax - (amin + da)) < 1e-10, \ 'incompatible values: angle-start, angle-end, angle-span' if amin is not None: self.ymax = self.ymin + da * pi/180 else: self.ymin = self.ymax - da * pi/180 self.dy2da = (self.ymax - self.ymin) / self.tree.size[1]
[docs] def is_visible(self, box): """Return True if the node in box will produce something visible.""" if not self.viewport: # Just make sure the box has a valid angle. return intersects_segment((-pi, +pi), get_ys(box)) return intersects_angles(self.viewport, box)
# NOTE: If we didn't care about aligned items, we could restrict more: # return (intersects_box(self.viewport, circumrect(box)) and # intersects_segment((-pi, +pi), get_ys(box)))
[docs] def clip_outline(self): """Clip borders of outline to make sure that its box is reasonable.""" r, a, dr, da = self.outline a1, a2 = clip_angles(a, a + da) self.outline = Box(r, a1, dr, a2 - a1)
[docs] def draw_collapsed(self): """Yield collapsed nodes representation with all the skeleton points.""" yield from super().draw_collapsed(self.dy2da)
[docs] def node_size(self, node): """Return the size of a node (its content and its children).""" return Size(node.size[0], node.size[1] * self.dy2da)
[docs] def content_size(self, node): """Return the size of the node's content.""" return Size(dist(node), node.size[1] * self.dy2da)
[docs] def is_small(self, box): z = self.zoom[0] # zx == zy in this drawer r, a, dr, da = box return (r + dr) * da * z < self.node_height_min
[docs] def draw_hz_line(self, p1, p2, parent_of, style): """Yield a "horizontal line" representing a length.""" if -pi <= p1[1] < pi: # NOTE: the angles p1[1] and p2[1] are equal yield gr.draw_hz_line(p1, p2, parent_of, style)
[docs] def draw_vt_line(self, p1, p2, style): """Yield a "vertical line" (arc) spanning children, from p1 to p2.""" (r1, a1), (r2, a2) = p1, p2 a1, a2 = clip_angles(a1, a2) if a1 < a2: yield gr.draw_vt_line((r1, a1), (r2, a2), style)
[docs] def draw_nodebox(self, node, node_id, box, result_of, style): r, a, dr, da = box a1, a2 = clip_angles(a, a + da) if a1 < a2: props = self.get_nodeprops(node) yield gr.draw_nodebox(Box(r, a1, dr, a2 - a1), node.name, props, node_id, result_of, style)
[docs] def draw_nodes(self, nodes, box, bda): # bda: branch da (height) """Return style and graphic commands for the contents of nodes.""" return super().draw_nodes(nodes, box, bda, circular=True)
[docs] def clip_angles(a1, a2): """Return the angles such that a1 to a2 extend at maximum from -pi to pi.""" EPSILON = 1e-8 # without it, p1 can be == p2 and svg arcs are not drawn return max(-pi + EPSILON, a1), min(pi - EPSILON, a2)
[docs] def cartesian(point): r, a = point return r * cos(a), r * sin(a)
[docs] def is_valid_label(label, is_leaf): """Return True if the given label would be drawn if it is/isn't a leaf.""" ntype = label.node_type return ((ntype == 'any') or (ntype == 'leaf' and is_leaf) or (ntype == 'internal' and not is_leaf))
[docs] def make_face(label): """Return a Face object from its description as a Label one.""" a = label # shortcut return EvalTextFace(a.code, fs_max=a.fs_max, style=a.style, position=a.position, column=a.column, anchor=a.anchor)
[docs] def draw_faces(faces, nodes, xmin, content_box, bdy, zoom, min_size, collapsed, circular=False): """Return the graphic commands from the faces, and xmaxs.""" positions = {a.position for a in faces} xmaxs = {0: content_box.x + content_box.dx} commands = [] for pos in positions: pos_box = get_position_box(content_box, bdy, pos) bdy_dy = bdy / content_box.dy if bdy > 0 else 0 # FIXME: This is a hack to add a little padding! # We should get the padding from the style instead. if pos == 'right': pos_box = Box(pos_box.x + 10/zoom[0], pos_box.y, pos_box.dx, pos_box.dy) faces_at_pos = [f for f in faces if f.position == pos] columns = sorted(set(f.column for f in faces_at_pos)) ncols = len(columns) # number of columns x_col = max(pos_box.x, xmin) # where we start for icol, col in enumerate(columns): if pos == 'aligned': # here columns are "panels" (starting at 1) commands.append(gr.set_panel(col + 1)) elif pos == 'header': commands.append(gr.set_panel(- col - 1)) # NOTE: A negative panel number indicates that we will # be drawing the *header* for that (positive) panel. rows = [f for f in faces_at_pos if f.column == col] dx_col = (pos_box.dx - (x_col - pos_box.x)) / (ncols - icol) if (pos in ['left', 'right', 'aligned', 'header'] or # unlimited dx dx_col * zoom[0] > min_size): # dx == 0 has no special meaning elements, x_col = get_col_data(rows, x_col, dx_col, nodes, pos_box, pos, bdy_dy, zoom, min_size, collapsed, circular) if pos in ['aligned', 'header']: xmaxs[col + 1] = max(xmaxs.get(col + 1, 0), x_col) x_col = xmin else: xmaxs[0] = max(xmaxs[0], x_col) commands += elements if pos in ['aligned', 'header']: # leaving the aligned panel commands.append(gr.set_panel(0)) # command to change to panel 0 return commands, xmaxs
[docs] def get_col_data(rows, x_col, dx_col, nodes, pos_box, pos, bdy_dy, zoom, min_size, collapsed, circular=False): """Return the graphic elements at the given rows, and the new x_col.""" # rows contains all the faces that go in this column. # x_col is the starting x for this column (after all boxes in previos cols). # dx_col is the "allocated dx for this column". x_pos, y_pos, dx_pos, dy_pos = pos_box dx_max = 0 # will have the max dx of all the drawn elements nrows = len(rows) # number of rows in this column blocks = [] # will contain the column data to send afterwards in_aligned_panel = not circular and pos in ['aligned', 'header'] zx, zy, za = zoom zoom_xy = (zx if not in_aligned_panel else za, zy) # Iterate over the faces and get their graphics (none if # there's not enough space). We iterate reversed ([::-1]) so the # first faces are the ones with more space (dy) allocated. dy_sum = 0 ax, ay = None, None # so we set the anchor only once per column for irow, face in enumerate(rows[::-1]): # iterate over all faces if ax is None: # anchor already set? then no more for this column! ax, ay = get_anchor(face.anchor, pos, bdy_dy) dy_row = (dy_pos - dy_sum) / (nrows - irow) # allocated dy for this row is_small = (pos != 'header' and dy_row * zoom[1] < min_size if not circular else (pos != 'aligned' and # circular aligned items always drawn circular_dy(x_col, dx_col, dy_row) * zoom[1] < min_size)) if is_small: continue # skip if the available size is too small # Finally draw the face. r is for "radius" (in circular mode). r = x_pos if circular and pos not in ['aligned', 'header'] else 1 elements, size = face.draw(nodes, Size(dx_col, dy_row), collapsed, zoom_xy, (ax, ay), r) blocks.append( (elements, size) ) dx_max = max(dx_max, size.dx) dy_sum += size.dy # Get all the graphic elements appropriately positioned. y = y_pos + ay * (dy_pos - dy_sum) # starting y position for the blocks elements_all = [] # all the graphic elements to send if ay <= 0.5: blocks.reverse() # we want the 1st block closer to its anchor point for elements, size in blocks: elements_all += gr.draw_group(elements, circular, shift=(x_col, y)) y += size.dy return elements_all, x_col + dx_max
[docs] def get_position_box(content_box, bdy, position): """Return the box corresponding to the given content box and position.""" x, y, dx, dy = content_box # box with contents of the node, branch included p = position if p == 'top': return Box(x , y , dx, bdy ) # above branch elif p == 'bottom': return Box(x , y + bdy, dx, dy - bdy) # below branch elif p == 'left': return Box(x - dx, y , dx, dy ) # to the left elif p == 'right': return Box(x + dx, y , 0 , dy ) # to the right elif p == 'aligned': return Box(0 , y , 0 , dy ) # aligned panel elif p == 'header': return Box(0 , y , 0 , dy ) # aligned panel else: raise ValueError(f'unknown position: {p}')
[docs] def get_anchor(anchor, pos, bdy_dy): """Return the anchor inside [0, 1] from the one inside [-1, 1] at pos.""" # From the gui we have an anchor inside [-1, 1], where 0 means "branch # position" for the y. This function returns the anchor inside [0, 1]. ax, ay = anchor if ax is None or ay is None: # not specified? use defaults for position default_ax, default_ay = default_anchors[pos] ax = ax if ax is not None else default_ax ay = ay if ay is not None else default_ay # Transform anchor from [-1, -1] to [0, 1] (with 0 -> bdy / content_box.dy). ax = (ax + 1) * 0.5 if pos in ['left', 'right']: ax = 1 if pos == 'left' else 0 # x anchor does not make sense in these ay = (ay + 1) * bdy_dy if ay < 0 else bdy_dy + ay * (1 - bdy_dy) else: ay = (ay + 1) * 0.5 # for 'top' or 'bottom' return ax, ay
[docs] def circular_dy(r, dr, da): """Return the dy corresponding to the exterior part of an annular sector.""" return (r + dr) * da if r > 0 else 0 # but dy=0 if r=0 (no interior part)
# Box-related functions.
[docs] def get_rect(element): """Return the rectangle that contains the given graphic element.""" eid = element[0] if eid in ['nodebox', 'array', 'text']: return element[1] elif eid == 'collapsed': points = element[1] x, y = points[0] return Box(x, y, 0, 0) # we don't care for the rect of this element elif eid in ['line', 'hz-line', 'vt-line']: (x1, y1), (x2, y2) = element[1], element[2] return Box(min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1)) elif eid in ['arc']: # not a great approximation for an arc... (x1, y1), (x2, y2) = cartesian(element[1]), cartesian(element[2]) return Box(min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1)) elif eid == 'circle': (x, y), r = element[1], element[2] return Box(x, y, 0, 0) else: raise ValueError(f'unrecognized element: {element!r}')
[docs] def intersects_box(b1, b2): """Return True if the boxes b1 and b2 (of the same kind) intersect.""" return (intersects_segment(get_xs(b1), get_xs(b2)) and intersects_segment(get_ys(b1), get_ys(b2)))
[docs] def intersects_segment(s1, s2): """Return True if the segments s1 and s2 intersect.""" s1min, s1max = s1 s2min, s2max = s2 return s1min <= s2max and s2min <= s1max
[docs] def intersects_angles(rect, asec): """Return True if any part of rect is contained within the asec angles.""" return any(intersects_segment(get_ys(circumasec(r)), get_ys(asec)) for r in split_thru_negative_xaxis(rect))
# We divide rect in two if it passes thru the -x axis, because then its # circumbscribing asec goes from -pi to +pi and (wrongly) always intersects.
[docs] def split_thru_negative_xaxis(rect): """Return a list of rectangles resulting from cutting the given one.""" x, y, dx, dy = rect if x >= 0 or y > 0 or y + dy < 0: return [rect] else: EPSILON = 1e-8 return [Box(x, y, dx, -y-EPSILON), Box(x, EPSILON, dx, dy + y)]
[docs] def stack(box1, box2): """Return the box resulting from stacking the given boxes.""" if not box1: return box2 else: x, y, dx1, dy1 = box1 _, _, dx2, dy2 = box2 return Box(x, y, max(dx1, dx2), dy1 + dy2)
[docs] def points_from_nodes(nodes, point, dy_min, dy2da=1, maxdepth=30): """Return the points sketching the given nodes, starting at point.""" x, y = point # top-left origin at point dx, dy = 0, 0 # defined here so they can be accessed inside add_points() points = [] # the actual points we are interested in abox = [x, y, 0, 0] # box surrounding the accumulated node boxes def add_points(ps): if abox[-1] > dy_min: points.extend(corner_points(*abox)) points.extend(ps) abox[:] = [x, y+dy, 0, 0] for node in nodes: dx, dy = node.size[0], node.size[1] * dy2da if maxdepth < 0: # we went too far: add bounding box add_points(corner_points(x, y, dx, dy)) elif dy < dy_min: # too small to peek inside if dy < 0.2 * dy_min: # and even to try to draw ax, ay, adx, ady = abox abox = [ax, ay, max(adx, dx), ady+dy] # accumulate it! else: # small, but no too small add_points(corner_points(x, y, dx, dy)) # just add bounding box else: add_points(points_from_node(node, (x, y), dy_min, dy2da, maxdepth-1)) y += dy if abox[-1] > 0: # do we still have any accumulated box of points? points.extend(corner_points(*abox)) # flush remaining points if len(nodes) < 2: return points else: by = (points[0][1] + points[-1][1]) / 2 return [(x, by)] + points + [(x, by)]
[docs] def points_from_node(node, point, dy_min, dy2da=1, maxdepth=30): """Return the points sketching the given node, starting at point.""" x, y = point dx, dy = dist(node), node.size[1] * dy2da points = points_from_nodes(node.children, (x + dx, y), dy_min, dy2da, maxdepth-1) by = ((points[0][1] + points[-1][1]) / 2) if points else (y + dy/2) return ([(x, by), (x + dx, by)] + points + [(x + dx, by), (x, by)])
[docs] def corner_points(x, y, dx, dy): """Return the corner points of a box at (x, y) and dimensions (dx, dy).""" return [(x, y), # 1 1,5.-----.2 (x+dx, y), # 2 | | (x+dx, y+dy), # 3 | | (x, y+dy), # 4 4·-----·3 (x, y)] # 5 (same as 1, closing the box)
[docs] def dist(node): """Return the distance of a node, with default values if not set.""" default = 0 if node.is_root else 1 return float(node.props.get('dist', default))
[docs] def circumasec(rect): """Return the annular sector that circumscribes the given rectangle.""" if rect is None: return None x, y, dx, dy = rect points = [(x, y), (x, y+dy), (x+dx, y), (x+dx, y+dy)] radius2 = [x*x + y*y for x,y in points] if x <= 0 and x+dx >= 0 and y <= 0 and y+dy >= 0: return Box(0, -pi, sqrt(max(radius2)), 2*pi) else: angles = [atan2(y, x) for x,y in points] rmin, amin = sqrt(min(radius2)), min(angles) return Box(rmin, amin, sqrt(max(radius2)) - rmin, max(angles) - amin)