# -*- coding: utf-8 -*-
"""Utility module for plotting reactions/compounds/networks dotfiles.
Attributes:
C_WHITE (:obj:`Color`): Default white color.
C_BLACK (:obj:`Color`): Default black color.
COLORSCH (sequence of :obj:`Color`): Default colorscheme.
GRAPH_ATTR_DEF (:obj:`Opts`): Default graph global attributes.
NODE_ATTR_DEF (:obj:`Opts`): Default node global attributes.
EDCE_ATTR_DEF (:obj:`Opts`): Default edge global attributes.
BOX_TMP (str): HTML box template.
LABEL_TMP (str): HTML label template.
"""
from collections.abc import Callable, Iterator, Sequence
from enum import StrEnum
from functools import partial, reduce
from itertools import chain, repeat, product, starmap
from typing import NamedTuple
from ..chemistry import (
ChemCfg
, calc_reactions_k_norms
, network_energy_normalizer
)
from ..dot import Edge, Graph, Node, Opts, OptsGlob
from ..struct import Compound, FFlags, Network, Reaction, Visibility
from ..colors.utils import (
Color
, ColorSpace
, rgb_achromatize
, color_sel_lum
, interp_cs
, rgb_to_hexstr
)
from ..colors.colorschemes import VIRIDIS, Colorscheme
from ..colors.palettes import css
C_WHITE: Color = css["white"]
C_BLACK: Color = css["black"]
COLORSCH: Colorscheme = VIRIDIS
GRAPH_ATTR_DEF: Opts = {
"rankdir": 'TB'
, "ranksep": '0.5'
, "nodesep": '0.25'
}
NODE_ATTR_DEF: Opts = {
"shape": "plaintext"
, "style": "filled"
}
EDGE_ATTR_DEF: Opts = {
"weight": "2."
}
BOX_TMP: str = """<
<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" CELLPADDING="0">
<TR>
<TD>{0}</TD>
</TR>
</TABLE>
>
"""
LABEL_TMP: str = '<FONT COLOR="{0}">{1}</FONT>'
[docs]
class EdgeArgs(NamedTuple):
"""Situational tuple to store the filter arguments for the filter function.
Notes:
Internal use to avoid type checker complaints.
"""
react: Reaction
width: float | None
color: Color | None
reversed: bool = False
[docs]
class NodeCfg(NamedTuple):
"""Dot node general configuration.
Attributes:
opts (dict of str as keys and str as values or None, optional): Dot
additional global node options. Defauts to None.
box_tmp (str, optional): Box template. Should have two format
modificators, the first consisting on the label and the second
consisting on the background color. Defaults to :obj:`BOX_TMP`
font_color (:obj:`Color`, optional): Font color of the node. Defaults
to :obj:`C_BLACK`
font_color_alt (:obj:`Color` or None, optional): Alternative font
color. If not None, would be used as the font color for dark background
colors (see :obj:`calc_relative_luminance`)
font_lum_threshold (float on None, optional): If :attr:`font_color_alt`
is defined, the luminance threshold to use it instead of
:attr:`font_color`.
"""
opts: Opts | None = NODE_ATTR_DEF
box_tmp: str = BOX_TMP
font_color: Color = C_BLACK
font_color_alt: Color | None = C_WHITE
font_lum_threshold: float | None = None
[docs]
class EdgeCfg(NamedTuple):
"""Dot edge general configuration.
Attributes:
opts (dict of str as keys and str as values or None, optional): Dot
additional global edge options. Defauts to None.
solid_color (:obj:`Color` or None, optional): If set, all lines will be
draw with that color, else, the color will be decided based on the
reaction energy. Defaults to None.
width (float, optional): Minimum width of the edges. Defaults to 1..
max_width (float or None, optional): If set, the width or the arrows
will be decided based on their kinetic constant (k, see
:obj:`calc_pseudo_k_constant`), using width as the minimum width
and this max_width as the maximum width, if None, all the arrows
will be draw with constant width.
"""
opts: Opts | None = EDGE_ATTR_DEF
solid_color: Color | None = None
width: float = 1.
max_width: float | None = 5.
[docs]
class GraphCfg(NamedTuple):
"""Dot graph general configuration.
Attributes:
opts (dict of str as keys and str as values or None, optional): Dot
additional global graph options. Defauts to None. Defaults to
:obj:`GRAPH_ATTR_DEF`.
kind (str, optional): Kind of the dotgraph. Defaults to digraph.
colorscheme (sequence of floats): Sequence of colors that will be
interpolate to set the colors of the graph. See
:obj:`interp_cs`. Defaults to :obj:`COLORSCH`.
color_offset (tuple of two floats, optional): When using the energy of
a node to decide its color, the offset to apply to norm. Should be
a value between 0. and 1. Useful to avoid falling on the extremes
of a predefined colorscheme. Defaults to (0., 0.)
"""
opts: Opts | None = GRAPH_ATTR_DEF
kind: str = "digraph"
colorscheme: Colorscheme = COLORSCH
color_offset: tuple[float, float] = (0., 0.)
node: NodeCfg = NodeCfg()
edge: EdgeCfg = EdgeCfg()
[docs]
def color_interp(
norm_fn: Callable[[float], float]
, cs: Sequence[Color]
, offset: tuple[float, float] = (0., 0.)
, cspace: ColorSpace = "lab"
) -> Callable[[float], Color]:
"""Given a reaction network and a sequence of colors, build a color
interpolation function that converts the energy of a component inside the
network to the corresponding color in the color sequence.
Args:
n (:obj:`Network`): Reaction network.
cs (sequence of :obj:`Color`): Sequence of colors to interpolate.
offset (tuple of 2 float, optional): Minimum and maximum energy offsets
to add to the minimum and maximum detected energies.
c_space (str): Colorspace in which the interpolation will be
perform. Check :obj:`ColorSpace` for available values.
Returns:
:obj:`Callable[[float], Color]`:
A function that takes a float as input an returns a :obj:`Color`
as output.
Notes:
The normalizer is set for the input network and thus, if an energy
value that it is out of range is used as input, the normalization
will follow the (under)overflow rules of :obj:`normalizer`.
I found that the offset is extremely useful to avoid very dark and very
bright zones of certain colorschemes.
"""
c_interp: Callable[[float], Color] = interp_cs(cs, cspace)
def interp_fn(x: float) -> Color:
return c_interp(norm_fn(x))
return interp_fn
[docs]
def build_node_box(
s: str
, fc: Color = C_BLACK
, box_tmp: str = BOX_TMP
) -> str:
"""Creates an string describing a colored-background HTML with a text label
in the middle. This is the typical node representation of this code.
Args:
s (str): String that will be used as a label.
fc (:obj:`Color`, optional): Text color for the label. Defaults to black
(:obj:`C_BLACK`).
box_tmp (str, optional): Box template. Should have a format modificator
for the text label. Defaults to :obj:`BOX_TMP`
Returns:
str: HTML table with the given label, background color and text color.
Note:
Note that :obj:`Color` follows the colorsys representation, meaning
that it consists of 3 float values ranging from [0, 1].
"""
return box_tmp.format(
LABEL_TMP.format(
rgb_to_hexstr(fc), s
))
[docs]
def gen_react_arrows(
rt: Sequence[Compound]
, pt: Sequence[Compound]
) -> Iterator[tuple[Compound, Compound]]:
"""Given a set of reactants and a set of products, generate an iterator
that returns every react->product combination, skipping the ones containing
a not visible :obj:`Compound`.
Args:
rt (sequence of :obj:`Compound`): Compounds acting as reactants.
pt (sequence of :obj:`Compound`): Compounds acting as products.
Returns:
:obj:`Iterator[tuple[Compound, Compound]]`:
A consumable iterator with all the (react, product) combinations
excluding the ones with non visible compounds.
Note:
To avoid duplicated edges due to duplicated compounds, e.g. 2H + 2O ->
2H2O ((H,H,O,O), (H2O,H2O)), only the unique reactants and products
will be taken into account.
"""
filter_vis = partial(filter, lambda x: x.visible != Visibility.FALSE)
return product(filter_vis(set(rt)), filter_vis(set(pt)))
[docs]
def build_dotnode(
c: Compound
, bc: Color = C_WHITE
, fc: Color = C_BLACK
) -> Node:
"""Creates a :obj:`Node` from a :obj:`Compound`.
Args:
c (:obj:`Compound`): Compound to be coverted.
bc (:obj:`Color`, optional): RGB color of the background. Defaults to
white.
fc (:obj:`Color`, optional): RGB color of the text. Defaults to black
(:obj:`C_BLACK`).
Returns:
:obj:`Node`:
Dot node with the given font and background colors.
Inherits applies the format flags and the dot opts in
:obj:`Compound`.
Note:
To allow custom labels, this function will check for "label" in
:attr:`Compound.opts`, if found, it will use it instead of the
layer.
"""
l: str = c.opts["label"] if (c.opts and "label" in c.opts) else c.name
return Node(
name=l
, options=c.opts or {} | {
"label": build_node_box(
s=apply_html_format(l, c.fflags) if c.fflags else l
, fc=fc
)
, "fillcolor": '"{}"'.format(rgb_to_hexstr(
c=bc
, inc_hash=True
))
}
)
[docs]
def build_dotedge(
o: str
, t: str
, opts: Opts | None = None
, c: Color | None = None
, w: float | None = None
) -> Edge:
"""Build a dot :obj:`Edge`.
Args:
o (str): Origin node.
t (str): Target node.
opts (:obj:`Opts` or None, optional): Graphviz attributes of the
edge. Defaults to None.
c (:obj:`Color` or None, optional): Color of edge. Defaults to None.
w (float or None, optional): Edge width. Defaults to None.
Returns:
:obj:`Edge`:
Note:
None values will not be defined in the generated file and default
Graphviz values will be used. Opts overrides color and width
definitions.
"""
return Edge(
origin=o
, target=t
, direction="->"
, options=( {}
| ({} if c is None else {
"color": f'"{rgb_to_hexstr(c, inc_hash=True)}"'})
| ({} if w is None else {"penwidth": str(w)})
| ({} if opts is None else opts)))
[docs]
def build_dotedges(
r: Reaction
, width: float
, color: Color | None = C_BLACK
, reverse: bool = False
) -> Iterator[Edge]:
"""Converts a `Reaction` into one or more dot :obj:`Edge`.
Args:
r (:obj:`Reaction`): Reaction to convert.
width (float): Width of the reaction arrow.
color (:obj:`Color`, optional): Color of the arrow. Defaults to
:obj:`C_BLACK`.
reverse (bool, optional): If True, invert the order of the reactants
and the products.
Returns:
:obj:`Iterator[Edge]`:
Iterator with the generated dot :obj:`Edge` corresponding to the
:obj:`Reaction` with the given width and color.
"""
sel_col: Color | None
match (color, r.visible):
case (None, _): sel_col = None
case (_, Visibility.GREY): sel_col = rgb_achromatize(color)
case _: sel_col = color
rs, ps = reversed(r.compounds) if reverse else r.compounds
return starmap(
partial(build_dotedge, opts=r.opts, c=sel_col, w=width)
, gen_react_arrows(rs, ps))
[docs]
def nodecolor_sel(
c_norm: Callable[[float], Color]
, fg_c: Color = C_BLACK
, fg_alt: Color | None = None
, lum_threshold: float | None = None
) -> Callable[[float, Visibility], tuple[Color, Color]]:
"""Creates a function that takes an energy as input and returns the
assigned color for the background of the node and the foreground.
Args:
c_norm (Function taking a float as input and returns a :obj:`Color`):
Color normalizer that will be used to compute the color of the
node background. See :obj:`network_color_enery_interp`.
fg_c (:obj:`Color`, optional): Color of the text. Defaults to
:obj:`C_BLACK`.
fg_alt (:obj:`Color`, optional): If set, alternative :obj:`Color` for
the text if the luminance is below the a certain
threshold. Defaults to None.
lum_threshold (float, optional): If set, threshold of the luminance. If
None, the default value in :obj:`color_sel_lum` will be used. This
argument has no effect if :attr:`fg_alt` is not defined. Ideally,
a value between 0. and 1. Defaults to None.
Returns:
:obj:`Callable(Compound) -> tuple[Color, Color]` :
A function that given a :obj:`Compound` returns a tuple of two
:obj:`Color` s, the first being the background color and the second
one being the color of the font.
"""
if fg_alt:
def fg_fn(x: Color) -> Color:
return color_sel_lum(fg_c, fg_alt, x, lum_threshold or 0.5)
else:
def fg_fn(x: Color) -> Color: return fg_c
def out_fn(x: float, v: Visibility) -> tuple[Color, Color]:
bg_c: Color = c_norm(x)
if v == Visibility.GREY:
bg_c = rgb_achromatize(bg_c)
return (bg_c, fg_fn(bg_c))
return out_fn
[docs]
def build_glob_opt(
cfg: GraphCfg
) -> OptsGlob | None:
"""Given a certain :obj:`GraphCfg`, build a dictionary containing the dot
options for the graph, the node and the edges.
Args:
cfg (:obj:`GraphCfg`): Configuration to use to generate the dictionary.
Returns:
:obj:`OptsGlob`:
With the given dictionary. Using graph, node and edge
as keys and their corresponding :obj:`Opts` as values.
None: If no Opts are found.
"""
# After dealing with the type checker, this is the less problematic
# solution to convince it that there are no None values in the dictionary.
nms: tuple[str, ...] = ("graph", "node", "edge")
os: tuple[Opts | None, ...] = (cfg.opts, cfg.node.opts, cfg.edge.opts)
return {k: v for k, v in zip(nms, os) if v is not None} or None