"""
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)