Source code for cosapp.tools.views.visjs

"""Build a VISjs view of a System."""

import os
from typing import Dict, List, Optional, Tuple, Union

from cosapp.drivers import Driver
from cosapp.ports.port import BasePort, ExtensiblePort
from cosapp.systems import System
from cosapp.tools import templates

from .baseRenderer import BaseRenderer


[docs] class VisJsRenderer(BaseRenderer): """ Utility class to export a system as HTML using vis.JS library. Parameters ---------- system : System System to export embeddable: bool, optional Is the HTML to be embedded in an existing page? Default: False """ __globals = None def __init__(self, system: System, embeddable=False): super().__init__(system, embeddable)
[docs] @staticmethod def get_system_label(system: System): label = system.name lmax = 7 if len(label) > lmax: dots = "\u2026" # single character label = label[:lmax-1] + dots return label
[docs] def get_system_title(self, system: System): head = self.system if system is head: name = head.name else: name = head.get_path_to_child(system) return f"{name} - {system.__class__.__name__}"
[docs] def get_system_group(self, system: System): head = self.system if system is head: name = f".{head.name}" else: path = head.get_path_to_child(system.parent) name = f"{head.name}.{path}" if path else head.name return name
[docs] def get_data(self) -> Dict: """Convert the `System` in a dictionary with 4 keys: title, nodes, edges and groups. The title is a string with the Module name. The nodes is a list of dictionaries defining node for vis JS library. The edges is a list of dictionaries defining edge for vis JS library. The groups is a list of string defining the nodes clusters for vis JS library. Parameters ---------- focus_system : System or None System on which the visualization should focus by default Returns ------- dict Dictionary containing elements to draw the `System` using visJS library """ head = self.system def get_component2id( system: System, cmpt2id: Dict[Union[System, BasePort], int], node_id: int, edges: List[Dict], ) -> Tuple[Dict[Union[System, BasePort], int], int, List[Dict]]: """Set the mapping between the `Module` and their unique id. Parameters ---------- system : Module The parent `Module` for which id must be set cmpt2id : Dict[Union[Module, BasePort], int] Mapping of `Module` or port and its id node_id : int Latest used id edges : List[Dict] List of connection edges Returns ------- tuple (List[Dict], Dict[Union["Module", "BasePort"], int], int) The first is the mapping between each involved system or port and an unique id, the second is the latest used id and the third index is the list of edges (one per connector). """ def get_driver2id(driver: Driver, cmpt2id: dict, node_id: int, edges: List[dict]): if driver not in cmpt2id: cmpt2id[driver] = node_id node_id += 1 previous_driver = None for subdriver in driver.children.values(): cmpt2id, node_id, edges = get_driver2id( subdriver, cmpt2id, node_id, edges ) # Connection between same level drivers if previous_driver is not None: edges.append( { "from": cmpt2id[previous_driver], "to": cmpt2id[subdriver], "arrows": "to", "title": "", "hidden": True, "physics": False, } ) previous_driver = subdriver # Connection with parent driver edges.append( { "from": cmpt2id[driver], "to": cmpt2id[subdriver], "title": "", "hidden": True, "physics": False, } ) return cmpt2id, node_id, edges def connectors_to_visJS( system: System, component_id: Optional[Dict[Union[System, BasePort], int]] = None, node_id: int = 1, ) -> Tuple[List[Dict], Dict[Union[System, BasePort], int], int]: """Build the dictionary the list of edges. Parameters ---------- system : System System owning the connectors component_id : Dict[Union[System, Port], int], optional Mapping of `System` or `Port` and its unique id; default None node_id : int, optional Latest used id; default is 1. Returns ------- tuple (List[Dict], Dict[System | BasePort, int], int) First element is the mapping between each involved system/port and a unique id. second element is the latest id used; Third element is the list of edges (one per connector); """ edges = list() for connection in system.all_connectors(): supplier = connection.source.owner if supplier.children: # Insert port as node supplier = connection.source if supplier not in component_id: component_id[supplier] = node_id node_id += 1 target = connection.sink.owner if target.children: # Insert port as node target = connection.sink if target not in component_id: component_id[target] = node_id node_id += 1 edge = { "from": component_id[supplier], "to": component_id[target], "arrows": "to", "title": "", } if connection.is_mirror(): edge["title"] = type(connection.source).__name__ else: edge["title"] = f"[{connection.pretty_mapping()}]" # if supplier or target is top-system, set edge.length = 0 if ( connection.source.owner is connection.sink.owner.parent or connection.source.owner.parent is connection.sink.owner ): edge["length"] = 0 edges.append(edge) return edges, component_id, node_id for driver in system.drivers.values(): cmpt2id, node_id, edges = get_driver2id(driver, cmpt2id, node_id, edges) for child in system.children.values(): if child not in cmpt2id: cmpt2id[child] = node_id node_id += 1 if child.children: cmpt2id, node_id, edges = get_component2id(child, cmpt2id, node_id, edges) local_edges, cmpt2id, node_id = connectors_to_visJS(system, cmpt2id, node_id) edges.extend(local_edges) return cmpt2id, node_id, edges cmpt2id = dict() id = 1 edges = list() cmpt2id, id_init, edges = get_component2id(head, cmpt2id, id, edges) nodes = list() # Add hidden node for the top system nodes.append( dict( name=head.name, label=self.get_system_label(head), title=self.get_system_title(head), id=id_init, level=0, hidden=True, physics=False, group=f".{head.name}", ) ) # Add edges from uppest level to its children but deactivate physics on # them. The goal is to have them available for the hierarchical view but # not playing any role in the default layout. for child in head.children.values(): edges.append( { "from": id_init, "to": cmpt2id[child], "hidden": True, "physics": False, } ) groups = list() for c, id in cmpt2id.items(): if isinstance(c, BasePort): ref = dict( title=f"{c.full_name(trim_root=True)}", id=id, group=head.get_path_to_child(c.owner, trim_top=False), mass=2, ) if not isinstance(c, ExtensiblePort): ref["title"] += f" - {type(c).__name__}" # Add edge to bind to central owner node edge = { "from": id, "to": cmpt2id[c.owner] if c.owner in cmpt2id else id_init, "hidden": True, } if id_init in (edge["from"], edge["to"]): edge["physics"] = False edges.append(edge) elif isinstance(c, Driver): # Add driver node ref = dict( label=type(c).__name__, title=f"{c.name} - {type(c).__name__}", id=id, level=-0.7, shape="box", hidden=True, physics=False, group=head.get_path_to_child(c.owner, trim_top=False), ) # Add edge to Module owner if top driver if c.parent is None: edges.append( { "from": cmpt2id[c.owner] if c.owner in cmpt2id else id_init, "to": cmpt2id[c], "dashes": True, "hidden": True, "physics": False, } ) else: driver_depth = len(list(c.path_to_root())) - 1 ref["level"] += 0.2 * driver_depth else: # c is a System ref = dict( name=c.name, label=self.get_system_label(c), title=self.get_system_title(c), id=id, widthConstraint={'maximum': '10px'}, ) if c.children: # Component containing component => Cluster ref["hidden"] = True # Add edge to force binding to central system node for child in c.children.values(): edges.append({"from": id, "to": cmpt2id[child], "hidden": True}) ref["group"] = self.get_system_group(c) group = ref["group"] ref.setdefault("level", 0) ref["level"] += group.count(".") + 1 # Set hierarchical level nodes.append(ref) if group not in groups: groups.append(group) # Sort the groups by system to ensure that levels are clustered bottom-up groups.sort(reverse=True) data = { "title": self.get_system_label(head), "nodes": nodes, "edges": edges, "groups": groups, } return data
[docs] def html_content(self) -> str: """Returns vis.JS HTML content of renderer"s system as a character string.""" elements = self.get_data() common = self.get_globals() elements["visJS"] = common["visJS"] elements["visCSS"] = common["visCSS"] html_begin_tags = common["html_begin_tags"] html_end_tags = common["html_end_tags"] template = common["template"] content = template.render(elements) if not self.embeddable: html_begin_tags = html_begin_tags.replace("{{title}}", elements["title"]) content = html_begin_tags + content + html_end_tags return content
[docs] @classmethod def html_resources(cls) -> Dict[str, str]: """ Return a dictionary of two keys. - `visJS` key holds the content of vis.js library. - `visCSS` key holds the css style of plot. Returns ------- Dict[str, Dict] The necessary resources to render Jinja template """ ressource_dir = os.path.dirname(os.path.abspath(templates.__file__)) libs_dir = os.path.join(ressource_dir, "lib") with open(os.path.join(libs_dir, "vis.min.js"), "rb") as f: vis_js = f.read().decode("utf-8") with open(os.path.join(libs_dir, "vis.min.css"), "rb") as f: vis_css = f.read().decode("utf-8") return {"visJS": vis_js, "visCSS": vis_css}
[docs] @classmethod def html_template(cls): """ Return the Jinja template used to create HTML file. Returns ------- Template """ try: from jinja2 import Environment, PackageLoader except ImportError: raise ImportError("jinja2 needs to be installed to export to vis JS.") env = Environment( loader=PackageLoader("cosapp.tools", "templates"), trim_blocks=True, lstrip_blocks=True, ) template = env.get_template("system_repr.html") return template
[docs] @classmethod def get_globals(cls): cg = cls.__globals if cg is None: cg = super().get_globals() return cg
[docs] def visjs_html(system: System, embeddable=False) -> str: """Returns vis.JS HTML content of `system`. Parameters ---------- system : System System to export embeddable: bool, optional Is the HTML to be embedded in an existing page? Default: False """ renderer = VisJsRenderer(system, embeddable) return renderer.html_content()
[docs] def to_visjs(system: System, filename: str, embeddable=False) -> None: """Export a system as HTML using vis.JS library. Parameters ---------- system : System System to export filename : str Filename to write to embeddable: bool, optional Is the HTML to be embedded in an existing page? Default: False """ ext = os.path.splitext(filename)[1] html_ext = f"{os.extsep}html" if ext != html_ext: filename += html_ext rendered_html = visjs_html(system, embeddable) with open(filename, mode="w", encoding="utf-8") as f: f.write(rendered_html)