Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add visualization based on pygraphviz (requires graphviz installed locally) #34

Closed
wants to merge 63 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
ec4390f
add: First version of new IR with dependency resolving
leclairm Nov 4, 2024
13491dd
ref: move input_arg_options to task definition
leclairm Nov 4, 2024
cbd2f59
fix: missing part in __setitem__
leclairm Nov 4, 2024
8209941
ref: some minor typing changes
leclairm Nov 4, 2024
8bd74a4
ref(core): add method redundant with __setitem__
leclairm Nov 4, 2024
a730750
fix: adapt small test yaml file
leclairm Nov 4, 2024
c1c36a5
add: first version of workflow string representation
leclairm Nov 4, 2024
d24627c
fix: allow referencing TimeSeries by absolute date
leclairm Nov 4, 2024
0688291
ref:add: generic TimeSeries type and refactor __str__ methods
leclairm Nov 4, 2024
6eb2f7c
add: serialized data
leclairm Nov 4, 2024
2612b6c
del: cleanup
leclairm Nov 4, 2024
4e302e0
fix: hatch fmt
leclairm Nov 4, 2024
1f67b84
fix: hatch fmt
leclairm Nov 4, 2024
55a9f50
fix: hatch fmt
leclairm Nov 4, 2024
1e10b0a
add: logging
leclairm Nov 5, 2024
9ed2793
fix: hatch fmt
leclairm Nov 5, 2024
0c148f6
add: visualization based on pygraphviz
leclairm Nov 5, 2024
910eefc
fix: hatch fmt
leclairm Nov 5, 2024
1167d7e
fix: hatch fmt
leclairm Nov 5, 2024
68ebef4
add: green nodes for available data
leclairm Nov 5, 2024
549f7bc
del: remove buttons
leclairm Nov 5, 2024
3f3cbc4
ref:add: tooltips for nodes, color calculation
leclairm Nov 6, 2024
2c93ff0
fix: hatch fmt
leclairm Nov 6, 2024
adf1634
ref: string handling
leclairm Nov 7, 2024
22b9e0f
fix: check if TImeSeries initialized before testing dates
leclairm Nov 7, 2024
69a326d
fix: revert of test order again.
leclairm Nov 7, 2024
ce6fa8e
ref: use dataclass for Task as well
leclairm Nov 7, 2024
eb2b0ff
hatch fmt
leclairm Nov 7, 2024
6c877bc
ref: move resolve_dates from parsing to core
leclairm Nov 7, 2024
2868b0e
fix: remove also from parsing ...
leclairm Nov 7, 2024
48bb377
ref: move cycle date iterator from parsing to core
leclairm Nov 7, 2024
828af69
fix: hatch fmt
leclairm Nov 7, 2024
00d3032
ref: put underscore back even if ruff complains
leclairm Nov 8, 2024
37bd52f
add: test for serialized workflow IR graph
leclairm Nov 8, 2024
a6391b5
ref: not only atmospheric science!
leclairm Nov 8, 2024
8f65011
add:test: test against serialized data
leclairm Nov 8, 2024
7967fc3
fix: Climate Science classifier does not exist
leclairm Nov 8, 2024
b6daca8
ref: remove _ from DataBaseModel, used outside
leclairm Nov 8, 2024
dbba6fc
fix: hatch fmt
leclairm Nov 8, 2024
d098fe3
ref: _resolve_target_dates is a staticmethod
leclairm Nov 8, 2024
c418973
Merge branch 'IR_graph' into vizgraph
leclairm Nov 8, 2024
5b5a7e2
fix:test: invalid suffix
leclairm Nov 8, 2024
cf114a4
Merge branch 'IR_graph' into vizgraph
leclairm Nov 8, 2024
ae75d89
upd: serialized data
leclairm Nov 8, 2024
c9d5ce7
del: unused function
leclairm Nov 8, 2024
510585f
ref: move testing data to own directory
leclairm Nov 8, 2024
78fa026
ref: move testing data to its own directory
leclairm Nov 8, 2024
fd65e0e
Merge branch 'IR_graph' into vizgraph
leclairm Nov 8, 2024
ee023b6
fix: generate test data in the right directory
leclairm Nov 8, 2024
5ad966e
Merge branch 'IR_graph' into vizgraph
leclairm Nov 8, 2024
0dfc913
fix: regenerate testing data
leclairm Nov 8, 2024
c48ccd8
fix: bad merge conflict solving
leclairm Nov 8, 2024
453cb1b
fix: hatch fmt
leclairm Nov 8, 2024
406f1a5
fix: install graphviz for testing CI
leclairm Nov 8, 2024
40c1a3e
fix: use GH action for graphviz setup
leclairm Nov 8, 2024
5a39fa7
fix: try other GH action for graphviz
leclairm Nov 8, 2024
fe93c34
fix: another try for graphviz
leclairm Nov 8, 2024
da472e0
fix: graphviz for the 3 tests
leclairm Nov 8, 2024
593778f
fix: hatch fmt
leclairm Nov 8, 2024
8610895
Merge branch 'IR_graph' into vizgraph
leclairm Nov 8, 2024
b36d5e3
Merge branch 'main' into vizgraph
leclairm Nov 12, 2024
2fdd1f6
fix: workflow.name lost in merge conflict
leclairm Nov 12, 2024
72a6327
fix: replace tabs by spaces
leclairm Nov 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ jobs:
run: |
pip install --upgrade pip
pip install hatch
- name: Install Graphviz
run: sudo apt-get install graphviz graphviz-dev
- name: Install package
run: |
pip install .
Expand All @@ -51,6 +53,8 @@ jobs:
run: |
pip install --upgrade pip
pip install hatch
- name: Install Graphviz
run: sudo apt-get install graphviz graphviz-dev
- name: Install package
run: |
pip install .
Expand All @@ -71,6 +75,8 @@ jobs:
run: |
pip install --upgrade pip
pip install hatch
- name: Install Graphviz
run: sudo apt-get install graphviz graphviz-dev
- name: Install package
run: |
pip install .
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Expand Down Expand Up @@ -29,6 +30,8 @@ dependencies = [
"pydantic",
"pydantic-yaml",
"aiida-core>=2.5",
"pygraphviz",
"lxml",
"termcolor"
]
[project.urls]
Expand Down
1 change: 1 addition & 0 deletions src/sirocco/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ class Workflow:
"""Internal reprensentation of a workflow"""

def __init__(self, workflow_config: ConfigWorkflow) -> None:
self.name = workflow_config.name
self.tasks = Store()
self.data = Store()
self.cycles = Store()
Expand Down
171 changes: 171 additions & 0 deletions src/sirocco/svg-interactive-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
function addInteractivity(evt) {

var svg = evt.target;
var edges = document.getElementsByClassName('edge');
var nodes = document.getElementsByClassName('node');
var clusters = document.getElementsByClassName('cluster');
var selectedElement, offset, transform, nodrag, origmousepos;

svg.addEventListener('mousedown', startDrag);
svg.addEventListener('mousemove', drag);
svg.addEventListener('mouseup', endDrag);
svg.addEventListener('mouseleave', endDrag);
svg.addEventListener('touchstart', startDrag);
svg.addEventListener('touchmove', drag);
svg.addEventListener('touchend', endDrag);
svg.addEventListener('touchleave', endDrag);
svg.addEventListener('touchcancel', endDrag);

for (var i = 0; i < edges.length; i++) {
edges[i].addEventListener('click', clickEdge);
}

for (var i = 0; i < nodes.length; i++) {
nodes[i].addEventListener('click', clickNode);
}

var svg = document.querySelector('svg');
var viewBox = svg.viewBox.baseVal;
adjustViewBox(svg);

function getMousePosition(evt) {
var CTM = svg.getScreenCTM();
if (evt.touches) { evt = evt.touches[0]; }
return {
x: (evt.clientX - CTM.e) / CTM.a,
y: (evt.clientY - CTM.f) / CTM.d
};
}

function startDrag(evt) {
origmousepos = getMousePosition(evt);
nodrag=true;
selectedElement = evt.target.parentElement;
if (selectedElement){
offset = getMousePosition(evt);

// Make sure the first transform on the element is a translate transform
var transforms = selectedElement.transform.baseVal;

if (transforms.length === 0 || transforms.getItem(0).type !== SVGTransform.SVG_TRANSFORM_TRANSLATE) {
// Create an transform that translates by (0, 0)
var translate = svg.createSVGTransform();
translate.setTranslate(0, 0);
selectedElement.transform.baseVal.insertItemBefore(translate, 0);
}

// Get initial translation
transform = transforms.getItem(0);
offset.x -= transform.matrix.e;
offset.y -= transform.matrix.f;
}
}

function drag(evt) {
if (selectedElement) {
evt.preventDefault();
var coord = getMousePosition(evt);
transform.setTranslate(coord.x - offset.x, coord.y - offset.y);
}
}

function endDrag(evt) {
<!-- comment out the following line if you wnat drags to stay in place, with this line they snap back to their original position after drag end -->
//if statement to avoid the header section being affected of the translate (0,0)
if (selectedElement){
if (selectedElement.classList.contains('header')){
selectedElement = false;
} else {
selectedElement = false;
transform.setTranslate(0,0);
}
}
var currentmousepos=getMousePosition(evt);
if (currentmousepos.x===origmousepos.x|currentmousepos.y===origmousepos.y){
nodrag=true;
} else {
nodrag=false;
}

}

function clickEdge() {
if (nodrag) {
if (this.classList.contains("edge-highlight")){
this.classList.remove("edge-highlight");
this.classList.remove("text-highlight-edges");
}
else {
this.classList.add("edge-highlight");
this.classList.add("text-highlight-edges");
animateEdge(this);
}
}
}

function clickNode() {
if (nodrag) {
var nodeName = this.childNodes[1].textContent;
// Escape special characters in the node name
var nodeNameEscaped = nodeName.replace(/[-[\]{}()*+!<=:?.\/\\^$|#\s,]/g, '\\$&');

var patroon = new RegExp('^' + nodeNameEscaped + '->|->' + nodeNameEscaped + '$|' + nodeNameEscaped + '--|--' + nodeNameEscaped + '$')

if (this.classList.contains("node-highlight")) {
this.classList.remove("node-highlight");
this.classList.remove("text-highlight-nodes");
var edges = document.getElementsByClassName('edge');
for (var i = 0; i < edges.length; i++) {
if (patroon.test(edges[i].childNodes[1].textContent)) {
edges[i].classList.remove("edge-highlight");
edges[i].classList.remove("text-highlight-edges");
}
}
} else {
this.classList.add("node-highlight");
this.classList.add("text-highlight-nodes");
var edges = document.getElementsByClassName('edge');
for (var i = 0; i < edges.length; i++) {
if (patroon.test(edges[i].childNodes[1].textContent)) {
edges[i].classList.add("edge-highlight");
edges[i].classList.add("text-highlight-edges");
animateEdge(edges[i]);
}
}
}
}
}

function animateEdge(edge){
var path = edge.querySelector('path');
var polygon = edge.querySelector('polygon');
var length = path.getTotalLength();
// Clear any previous transition
path.style.transition = path.style.WebkitTransition = 'none';
if (polygon){polygon.style.transition = polygon.style.WebkitTransition = 'none';};
// Set up the starting positions
path.style.strokeDasharray = length + ' ' + length;
path.style.strokeDashoffset = length;
if(polygon){polygon.style.opacity='0';};
// Trigger a layout so styles are calculated & the browser
// picks up the starting position before animating
path.getBoundingClientRect();
// Define our transition
path.style.transition = path.style.WebkitTransition =
'stroke-dashoffset 2s ease-in-out';
if (polygon){polygon.style.transition = polygon.style.WebkitTransition =
'fill-opacity 1s ease-in-out 2s';};
// Go!
path.style.strokeDashoffset = '0';
if (polygon){setTimeout(function(){polygon.style.opacity='1';},2000)};
}
}

function adjustViewBox(svg) {
var viewBoxParts = svg.getAttribute("viewBox").split(" ");
var newYMin = parseFloat(viewBoxParts[1]) - 30; // Adjust this value as needed
var newYMax = parseFloat(viewBoxParts[3]) + 30; // Adjust this value as needed
var newXMax = Math.max(parseFloat(viewBoxParts[2]),240);
var newViewBox = viewBoxParts[0] + " " + newYMin + " " + newXMax + " " + newYMax;
svg.setAttribute("viewBox", newViewBox);
}
60 changes: 60 additions & 0 deletions src/sirocco/svg-interactive-style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/* The first 2 styles are hiding longer description texts on */
/* nodes and edges, that are shown when nodes are clicked */
/* .edge text{ */
/* opacity: 0; */
/* } */
/* .node text:not(:first-of-type){ */
/* opacity: 0; */
/* } */

.text-highlight-nodes text{
opacity: 1 !important;
stroke-width: 5;
font-size: 20px;
font-weight: bold;
fill: black;
}
.text-highlight-edges text{
opacity: 1 !important;
stroke-width: 5;
font-size: 20px;
font-weight: bold;
fill: Indigo;
}
.edge-highlight path{
opacity: 1;
stroke-width: 5;
stroke: crimson;
}
.edge-highlight polygon{
opacity: 1;
stroke-width: 5;
stroke: crimson;
}
.node-highlight polygon{
opacity: 1;
stroke-width: 5;
stroke: crimson;
z-index:99999;
}
.node-highlight ellipse{
opacity: 1;
stroke-width: 5;
stroke: crimson;
z-index:99999;
}
.node-highlight path{
opacity: 1;
stroke-width: 5;
stroke: crimson;
z-index:99999;
}
.compass {
fill: #fff;
stroke: #000;
stroke-width: 1;
}
.plus-minus {
fill: #fff;
pointer-events: none;
}
113 changes: 113 additions & 0 deletions src/sirocco/vizgraph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from __future__ import annotations

from colorsys import hsv_to_rgb
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar

from lxml import etree
from pygraphviz import AGraph

if TYPE_CHECKING:
from sirocco.core import Store
from sirocco.core import Workflow


def hsv_to_hex(h: float, s: float, v: float) -> str:
r, g, b = hsv_to_rgb(h, s, v)
return "#{:02x}{:02x}{:02x}".format(*map(round, (255 * r, 255 * g, 255 * b)))


def node_colors(h: float) -> dict[str:str]:
fill = hsv_to_hex(h / 365, 0.15, 1)
border = hsv_to_hex(h / 365, 1, 0.20)
font = hsv_to_hex(h / 365, 1, 0.15)
return {"fillcolor": fill, "color": border, "fontcolor": font}


class VizGraph:
"""Class for visualizing a Sirocco workflow"""

node_base_kw: ClassVar[dict[str:Any]] = {"style": "filled", "fontname": "Fira Sans", "fontsize": 14, "penwidth": 2}
edge_base_kw: ClassVar[dict[str:Any]] = {"color": "#77767B", "penwidth": 1.5}
data_node_base_kw: ClassVar[dict[str:Any]] = node_base_kw | {"shape": "ellipse"}

data_av_node_kw: ClassVar[dict[str:Any]] = data_node_base_kw | node_colors(116)
data_gen_node_kw: ClassVar[dict[str:Any]] = data_node_base_kw | node_colors(214)
task_node_kw: ClassVar[dict[str:Any]] = node_base_kw | {"shape": "box"} | node_colors(354)
io_edge_kw: ClassVar[dict[str:Any]] = edge_base_kw
wait_on_edge_kw: ClassVar[dict[str:Any]] = edge_base_kw | {"style": "dashed"}
cluster_kw: ClassVar[dict[str:Any]] = {"bgcolor": "#F6F5F4", "color": None, "fontsize": 16}

def __init__(self, name: str, cycles: Store, data: Store) -> None:
self.name = name
self.agraph = AGraph(name=name, fontname="Fira Sans", newrank=True)
for data_node in data.values():
gv_kw = self.data_av_node_kw if data_node.available else self.data_gen_node_kw
tooltip = data_node.name if data_node.date is None else f"{data_node.name}\n {data_node.date}"
self.agraph.add_node(data_node, tooltip=tooltip, label=data_node.name, **gv_kw)

k = 1
for cycle in cycles.values():
# NOTE: For some reason, clusters need to have a unique name that starts with 'cluster'
# otherwise they are not taken into account. Hence the k index.
cluster_nodes = []
for task_node in cycle.tasks:
cluster_nodes.append(task_node)
tooltip = task_node.name if task_node.date is None else f"{task_node.name}\n {task_node.date}"
self.agraph.add_node(task_node, label=task_node.name, tooltip=tooltip, **self.task_node_kw)
for data_node in task_node.inputs:
self.agraph.add_edge(data_node, task_node, **self.io_edge_kw)
for data_node in task_node.outputs:
self.agraph.add_edge(task_node, data_node, **self.io_edge_kw)
cluster_nodes.append(data_node)
for wait_task_node in task_node.wait_on:
self.agraph.add_edge(wait_task_node, task_node, **self.wait_on_edge_kw)
cluster_label = cycle.name
if cycle.date is not None:
cluster_label += "\n" + cycle.date.isoformat()
self.agraph.add_subgraph(
cluster_nodes,
name=f"cluster_{cycle.name}_{k}",
clusterrank="global",
label=cluster_label,
tooltip=cluster_label,
**self.cluster_kw,
)
k += 1

def draw(self, **kwargs):
# draw graphviz dot graph to svg file
self.agraph.layout(prog="dot")
file_path = Path(f"./{self.name}.svg")
self.agraph.draw(path=file_path, format="svg", **kwargs)

# Add interactive capabilities to the svg graph thanks to
# https://github.com/BartBrood/dynamic-SVG-from-Graphviz

# Parse svg
svg = etree.parse(file_path) # noqa: S320 this svg is safe as generated internaly
svg_root = svg.getroot()
# Add 'onload' tag
svg_root.set("onload", "addInteractivity(evt)")
# Add css style for interactivity
this_dir = Path(__file__).parent
style_file_path = this_dir / "svg-interactive-style.css"
node = etree.Element("style")
node.text = style_file_path.read_text()
svg_root.append(node)
# Add scripts
js_file_path = this_dir / "svg-interactive-script.js"
node = etree.Element("script")
node.text = etree.CDATA(js_file_path.read_text())
svg_root.append(node)

# write svg again
svg.write(file_path)

@classmethod
def from_core_workflow(cls, workflow: Workflow):
return cls(workflow.name, workflow.cycles, workflow.data)

@classmethod
def from_yaml(cls, config_path: str):
return cls.from_core_workflow(Workflow.from_yaml(config_path))
Loading