from dataclasses import dataclass
import math
from typing import Any, Dict, List, Optional, Tuple, Union
[docs]
class Map:
def __init__(
self,
name: str,
description: str,
homepage: Optional[str] = None,
schema: Optional[str] = None,
canvas: Optional[Tuple[float, float, float, float]] = None,
):
if homepage is None:
homepage = "https://escher.github.io"
if schema is None:
schema = "https://escher.github.io/escher/jsonschema/1-0-0#"
if canvas is None:
canvas = (0, 0, 1000, 1000)
self.name = name
self.description = description
self.homepage = homepage
self.schema = schema
self.reactions = {}
self.nodes = {}
self.segments = {}
self.labels = {}
self.canvas = canvas
self._node_counter = 0
self._reaction_counter = 0
self._segment_counter = 0
self._label_counter = 0
[docs]
def add_node(self, node: Optional["Node"]):
if node is None or node.identifier is not None:
return
while str(self._node_counter) in self.nodes:
self._node_counter += 1
node.identifier = str(self._node_counter)
self.nodes[node.identifier] = node
self._node_counter += 1
[docs]
def add_segment(self, segment: "Segment"):
while str(self._segment_counter) in self.segments:
self._segment_counter += 1
segment.identifier = str(self._segment_counter)
self.segments[segment.identifier] = segment
self._segment_counter += 1
[docs]
def add_reaction(self, reaction: "Reaction"):
while str(self._reaction_counter) in self.reactions:
self._reaction_counter += 1
reaction.identifier = str(self._reaction_counter)
reaction._map = self
self.reactions[reaction.identifier] = reaction
self._reaction_counter += 1
for _, node in reaction.metabolites:
self.add_node(node)
self.add_node(reaction.mid_marker)
self.add_node(reaction.multi_markers[0])
self.add_node(reaction.multi_markers[1])
for segment in reaction.segments:
self.add_segment(segment)
[docs]
def add_label(self, label: "TextLabel"):
while str(self._label_counter) in self.labels:
self._label_counter += 1
label.identifier = str(self._label_counter)
self.labels[label.identifier] = label
self._label_counter += 1
[docs]
def fit_canvas(self, spacing: float = 100, expand_only=False):
if expand_only:
min_x = self.canvas[0]
min_y = self.canvas[1]
max_x = self.canvas[0] + self.canvas[2]
max_y = self.canvas[1] + self.canvas[3]
else:
min_x, max_x, min_y, max_y = 0, 0, 0, 0
for node in self.nodes.values():
min_x = min(min_x, node.x)
max_x = max(max_x, node.x)
min_y = min(min_y, node.y)
max_y = max(max_y, node.y)
min_x -= spacing
min_y -= spacing
max_x += spacing
max_y += spacing
self.canvas = (min_x, min_y, max_x - min_x, max_y - min_y)
[docs]
def to_escher(self):
d_header = {
"map_name": self.name,
"map_description": self.description,
"homepage": self.homepage,
"schema": self.schema,
}
d_body = {
"reactions": {k: v.to_escher() for k, v in self.reactions.items()},
"nodes": {k: v.to_escher() for k, v in self.nodes.items()},
"text_labels": {k: v.to_escher() for k, v in self.labels.items()},
"canvas": {
"x": self.canvas[0],
"y": self.canvas[1],
"width": self.canvas[2],
"height": self.canvas[3],
},
}
return [d_header, d_body]
[docs]
class TextLabel:
def __init__(self, x: float, y: float, text: str):
self.identifier = None
self.x = x
self.y = y
self.text = text
[docs]
def to_escher(self):
d = self.__dict__.copy()
del d["identifier"]
return d
[docs]
class Node:
def __init__(self, x: Optional[float] = None, y: Optional[float] = None):
self.identifier = None
self.node_type = None
self.x = x
self.y = y
[docs]
def to_escher(self):
d = self.__dict__.copy()
del d["identifier"]
return d
[docs]
def copy(self):
o = self.__class__(**{k: v for k, v in self.__dict__.items() if k not in ["node_type", "identifier"]})
o.node_type = self.node_type
return o
[docs]
class MultiMarkerNode(Node):
def __init__(self, x: float, y: float):
super().__init__(x, y)
self.node_type = "multimarker"
[docs]
class MidMarkerNode(Node):
def __init__(self, x: float, y: float):
super().__init__(x, y)
self.node_type = "midmarker"
[docs]
class Segment:
def __init__(
self,
from_node: Node,
to_node: Node,
b1: Optional[Tuple[float, float]] = None,
b2: Optional[Tuple[float, float]] = None,
):
self.identifier = None
self.from_node = from_node
self.to_node = to_node
self.b1 = b1
self.b2 = b2
[docs]
def to_escher(self):
d = {
"from_node_id": self.from_node.identifier,
"to_node_id": self.to_node.identifier,
"b1": None if self.b1 is None else {"x": self.b1[0], "y": self.b1[1]},
"b2": None if self.b2 is None else {"x": self.b2[0], "y": self.b2[1]},
}
return d
[docs]
def node_from_dict(d: Dict[str, Any]) -> Node:
node_class = Node
if d["node_type"] == "metabolite":
node_class = MetaboliteNode
elif d["node_type"] == "midmarker":
node_class = MidMarkerNode
elif d["node_type"] == "multimarker":
node_class = MultiMarkerNode
return node_class(
**{k: v for k, v in d.items() if k != "node_type"}
)
[docs]
class Reaction:
def __init__(
self,
name: str,
bigg_id: str,
label_x: float,
label_y: float,
mid_marker: MidMarkerNode,
plus_multi_marker: Optional[MultiMarkerNode],
minus_multi_marker: Optional[MultiMarkerNode],
reversibility: bool = True,
gene_reaction_rule: Optional[str] = None,
genes: Optional[List[Dict[str, str]]] = None,
):
self.identifier = None
self._map: Optional[Map] = None
self.name = name
self.bigg_id = bigg_id
self.label_x = label_x
self.label_y = label_y
self.reversibility = reversibility
self.mid_marker = mid_marker
self.multi_markers = (minus_multi_marker, plus_multi_marker)
self.metabolites = []
self.segments = []
self.gene_reaction_rule = gene_reaction_rule
self.genes = genes
self._add_multi_marker_segments()
[docs]
def add_segment(self, segment: "Segment"):
if self._map is not None:
self._map.add_segment(segment)
self.segments.append(segment)
def _add_multi_marker_segments(self):
if self.multi_markers[0] is not None:
self.add_segment(Segment(self.multi_markers[0], self.mid_marker))
if self.multi_markers[1] is not None:
self.add_segment(Segment(self.mid_marker, self.multi_markers[1]))
[docs]
def to_escher(self):
d = {
"name": self.name,
"bigg_id": self.bigg_id,
"reversibility": self.reversibility,
"label_x": self.label_x,
"label_y": self.label_y,
"gene_reaction_rule": "",
"genes": [],
"metabolites": [
{"coefficient": coeff, "bigg_id": node.bigg_id}
for coeff, node in self.metabolites
],
"segments": {
segment.identifier: segment.to_escher() for segment in self.segments
},
}
if self.gene_reaction_rule is not None:
d["gene_reaction_rule"] = self.gene_reaction_rule
if self.genes is not None:
d["genes"] = self.genes
return d
[docs]
def cubic_bezier_bt(t, b0, b1, b2, b3):
bt = (
((1 - t) ** 3) * b0
+ 3 * t * ((1 - t) ** 2) * b1
+ 3 * (t**2) * (1 - t) * b2
+ (t**3) * b3
)
return bt
[docs]
def non_primary_scaling(x):
return (1 - (min(x - 1, 5) / 5)) * 0.3 + 0.5
[docs]
def default_text_offset(x):
return 20 + x * 12
[docs]
class PlacementOptions:
def __init__(
self,
delta=math.pi * 0.15,
delta_tolerance=0.5,
no_primary_length_f=None,
scale=3.0,
b1_scale=0.3,
b2_scale=0.8,
text_y_correction=6,
text_offset_f=None,
placement_f=None,
):
self.delta = delta
self.delta_tolerance = delta_tolerance
self.scale = scale
self.b1_scale = b1_scale
self.b2_scale = b2_scale
self.text_y_correction = text_y_correction
self.placement_f = placement_f
if no_primary_length_f is None:
self.no_primary_length_f = non_primary_scaling
else:
self.no_primary_length_f = no_primary_length_f
if text_offset_f is None:
self.text_offset_f = default_text_offset
else:
self.text_offset_f = text_offset_f
[docs]
class AutoReaction(Reaction):
def __init__(
self,
bigg_id: str,
mid_marker: MidMarkerNode,
angle: float,
unit: float = 50,
text_y_correction: float = 8,
label_x: Optional[float] = None,
label_y: Optional[float] = None,
minus_multi_marker: Optional[Union[MultiMarkerNode, MidMarkerNode]] = None,
plus_multi_marker: Optional[Union[MultiMarkerNode, MidMarkerNode]] = None,
**kwargs
):
self.angle = angle
self.unit = unit
text_offset = 16
self._used_deltas = ([], [])
if minus_multi_marker is None:
minus_multi_marker = MultiMarkerNode(
mid_marker.x + self.unit * math.cos(self.angle + math.pi),
mid_marker.y + self.unit * math.sin(self.angle + math.pi),
)
if minus_multi_marker is mid_marker:
minus_multi_marker = None
if plus_multi_marker is None:
plus_multi_marker = MultiMarkerNode(
mid_marker.x + self.unit * math.cos(self.angle),
mid_marker.y + self.unit * math.sin(self.angle),
)
if plus_multi_marker is mid_marker:
plus_multi_marker = None
if label_x is None or label_y is None:
label_x = mid_marker.x + text_offset * abs(math.cos(self.angle - 0.5 * math.pi))
label_y = (
mid_marker.y
+ text_offset * math.sin(self.angle - 0.5 * math.pi)
+ text_y_correction
)
super().__init__(
bigg_id=bigg_id,
mid_marker=mid_marker,
label_x=label_x,
label_y=label_y,
minus_multi_marker=minus_multi_marker,
plus_multi_marker=plus_multi_marker,
**kwargs
)
[docs]
def alternating_side_placement(self, i, delta, plus_minus):
n = 1 + (i - 1) // 2
side = (i - 1) % 2
d = (n * delta) if side else -(n * delta)
return n, side, d
[docs]
def same_side_placement(self, n, delta, plus_minus, absolute_side=0):
side = bool(absolute_side) == bool(plus_minus)
if (self.angle % (2 * math.pi)) > math.pi:
side = not side
side = int(side)
d = (n * delta) if side else -(n * delta)
return n, side, d
[docs]
def calculate_placement(
self, ref_node, node, plus_minus, angle_delta, n, b1_b2, placement_opts
):
size = placement_opts.scale
angle = self.angle + angle_delta
x, y = node.x, node.y
if x is None or y is None:
if not node.node_is_primary:
size = placement_opts.no_primary_length_f(n) * placement_opts.scale
x = ref_node.x + self.unit * size * math.cos(
angle + (1 - plus_minus) * math.pi
)
y = ref_node.y + self.unit * size * math.sin(
angle + (1 - plus_minus) * math.pi
)
else:
size = math.sqrt((x - ref_node.x) ** 2 + (y - ref_node.y) ** 2) / self.unit
if b1_b2 is not None:
b1, b2 = b1_b2
else:
b2 = (
x
+ self.unit
* (1 - placement_opts.b2_scale)
* size
* math.cos(angle + (2 - plus_minus) * math.pi),
y
+ self.unit
* (1 - placement_opts.b2_scale)
* size
* math.sin(angle + (2 - plus_minus) * math.pi),
)
# b2 = None
b1 = (
ref_node.x
+ self.unit
* placement_opts.b1_scale
* size
* math.cos(self.angle + (1 - plus_minus) * math.pi),
ref_node.y
+ self.unit
* placement_opts.b1_scale
* size
* math.sin(self.angle + (1 - plus_minus) * math.pi),
)
t = min(1.5 / size, 1.0)
bt = (
cubic_bezier_bt(t, ref_node.x, b1[0] if b1 is not None else ref_node.x, b2[0] if b2 is not None else x, x),
cubic_bezier_bt(t, ref_node.y, b1[1] if b1 is not None else ref_node.y, b2[1] if b2 is not None else y, y),
)
effective_angle_delta = (
math.atan2(bt[1] - ref_node.y, bt[0] - ref_node.x) - self.angle
)
if not plus_minus:
effective_angle_delta = effective_angle_delta + math.pi
effective_angle_delta = math.remainder(effective_angle_delta, math.pi * 2)
return x, y, size, b1, b2, effective_angle_delta