Skip to content

Commit

Permalink
feat(core): color edges on a per-node basis (#2719)
Browse files Browse the repository at this point in the history
  • Loading branch information
Panaetius committed Mar 2, 2022
1 parent 0bfceaf commit ffa10fb
Show file tree
Hide file tree
Showing 5 changed files with 101 additions and 44 deletions.
18 changes: 7 additions & 11 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions renku/cli/utils/curses.py
Expand Up @@ -250,6 +250,8 @@ def _update_activity_overlay(self, screen):
f"{agents}\n\n"
"Plan Id:\n"
f"{self.selected_activity.association.plan.id}\n\n"
"Plan Name:\n"
f"{self.selected_activity.association.plan.name}\n\n"
"Inputs:\n"
f"{usages}\n\n"
"Outputs:\n"
Expand Down
82 changes: 61 additions & 21 deletions renku/core/commands/view_model/activity_graph.py
Expand Up @@ -19,6 +19,12 @@

from collections import namedtuple
from textwrap import shorten
from typing import TYPE_CHECKING, List, Tuple

if TYPE_CHECKING:
from grandalf.graphs import Edge

from renku.core.commands.view_model.text_canvas import TextCanvas

Point = namedtuple("Point", ["x", "y"])

Expand Down Expand Up @@ -71,7 +77,7 @@ def layout_graph(self, columns):

columns = [ACTIVITY_GRAPH_COLUMNS[c] for c in columns.split(",")]

self.layouts = []
self.layouts: List[SugiyamaLayout] = []

components = networkx.weakly_connected_components(self.graph)
subgraphs = [self.graph.subgraph(component).copy() for component in components]
Expand All @@ -98,7 +104,7 @@ def layout_graph(self, columns):

layout = SugiyamaLayout(graph.C[0])
layout.init_all(roots=roots, optimize=True)
layout.xspace = shortest_node_width / 2
layout.xspace = max(shortest_node_width / 2, 20)
layout.yspace = shortest_node_height

# space between beginning of one node and beginning of next
Expand All @@ -108,27 +114,31 @@ def layout_graph(self, columns):
layout.draw(5)
self.layouts.append(layout)

def _add_edge_to_canvas(self, edge, canvas, edges, min_y):
def _add_edges_to_canvas(
self, edges: List["Edge"], canvas: "TextCanvas", existing_edges: List["Edge"], min_y
) -> Tuple[int, str]:
"""Add an edge to a canvas object.
Makes sure overlapping edges don't have the same color.
"""
from renku.core.commands.view_model.text_canvas import EdgeShape

points = edge.view._pts
edge_color = EdgeShape.next_color()
new_edges = []
max_y = 0
for index in range(len(points) - 1):
start = points[index]
end = points[index + 1]
max_y = max(max_y, end[1])
new_edges.append(
EdgeShape(Point(start[0], start[1] + min_y), Point(end[0], end[1] + min_y), color=edge_color)
)

for edge in edges:
points = edge.view._pts
for index in range(len(points) - 1):
start = points[index]
end = points[index + 1]
max_y = max(max_y, end[1])
new_edges.append(
EdgeShape(Point(start[0], start[1] + min_y), Point(end[0], end[1] + min_y), color=edge_color)
)

# figure out if this edge crosses any existing edge with the same color, if so, change the color
same_color_edges = [e for e in edges if e.color == edge_color]
same_color_edges = [e for e in existing_edges if e.color == edge_color]

if same_color_edges:
# check for intersections
Expand All @@ -143,14 +153,15 @@ def _add_edge_to_canvas(self, edge, canvas, edges, min_y):
e.color = edge_color

[canvas.add_shape(e, layer=0) for e in new_edges]
edges.extend(new_edges)
return max_y
existing_edges.extend(new_edges)
return max_y, edge_color

def text_representation(self, columns: str, color: bool = True, ascii=False):
"""Return an ascii representation of the graph."""
from grandalf.layouts import DummyVertex

from renku.core.commands.view_model.text_canvas import NodeShape, TextCanvas
from renku.core.models.provenance.activity import Activity

self.layout_graph(columns=columns)

Expand All @@ -168,19 +179,47 @@ def text_representation(self, columns: str, color: bool = True, ascii=False):
max_y = 0
existing_edges = []

# sort edges to have consistent coloring
edges = sorted(layout.g.sE, key=lambda e: e.view._pts[0])

for edge in edges:
max_y = max(max_y, self._add_edge_to_canvas(edge, canvas, existing_edges, min_y))

for layer in layout.layers:
layer_nodes = []

for node in layer:
if isinstance(node, DummyVertex):
continue

node_color = None

if node.data[2] and isinstance(node.data[2], Activity):
# NOTE: get edges for node
connected_edges = list(layout.g.E(cond=lambda e: e.v[0] == node or e.v[1] == node))

visited_edges = set()
visited_nodes = {node}

# NOTE: Follow all edges connected to the node (might have DummyNode's in between)
while connected_edges:
current_edge = connected_edges.pop()
visited_edges.add(current_edge)

dummy_node = None

if isinstance(current_edge.v[0], DummyVertex) and current_edge.v[0] not in visited_nodes:
dummy_node = current_edge.v[0]
elif isinstance(current_edge.v[1], DummyVertex) and current_edge.v[0] not in visited_nodes:
dummy_node = current_edge.v[1]

if dummy_node:
connected_edges.extend(e for e in dummy_node.e if e not in visited_edges)
visited_nodes.add(dummy_node)

local_max_y, node_color = self._add_edges_to_canvas(
visited_edges, canvas, existing_edges, min_y
)
max_y = max(max_y, local_max_y)

xy = node.view.xy
node_shape = NodeShape(node.data[0], Point(xy[0], xy[1] + min_y), double_border=node.data[1])
node_shape = NodeShape(
node.data[0], Point(xy[0], xy[1] + min_y), double_border=node.data[1], color=node_color
)
canvas.add_shape(node_shape, layer=1)
max_y = max(max_y, node_shape.extent[0][1])

Expand Down Expand Up @@ -214,4 +253,5 @@ def text_representation(self, columns: str, color: bool = True, ascii=False):
"command": lambda a: " ".join(a.plan_with_values.to_argv(with_streams=True)),
"id": lambda a: a.id,
"date": lambda a: a.ended_at_time.isoformat(),
"plan": lambda a: a.plan_with_values.name,
}
41 changes: 30 additions & 11 deletions renku/core/commands/view_model/text_canvas.py
Expand Up @@ -18,8 +18,9 @@
"""Activity graph view model."""

from collections import defaultdict, namedtuple
from copy import deepcopy
from io import StringIO
from typing import List, Tuple
from typing import List, Optional, Tuple

import numpy as np
from click import style
Expand All @@ -44,17 +45,26 @@ class RectangleShape(Shape):

UNICODE_CHARACTERS_DOUBLE = {"edge": ["╔", "╚", "╗", "╝"], "horizontal": "═", "vertical": "║"}

def __init__(self, start: Point, end: Point, double_border=False):
def __init__(self, start: Point, end: Point, double_border=False, color: Optional[str] = None):
self.start = start
self.end = end
self.double_border = double_border
self.color = color

def draw(self, color: bool = True, ascii=False) -> Tuple[List[Tuple[int]], List[str]]:
"""Return the indices and values to draw this shape onto the canvas."""
if not ascii and self.double_border:
characters = self.UNICODE_CHARACTERS_DOUBLE
characters = deepcopy(self.UNICODE_CHARACTERS_DOUBLE)
else:
characters = self.ASCII_CHARACTERS if ascii else self.UNICODE_CHARACTERS
characters = deepcopy(self.ASCII_CHARACTERS if ascii else self.UNICODE_CHARACTERS)

if color and self.color:
# NOTE: Add color to border characters
for key, value in list(characters.items()):
if isinstance(value, list):
characters[key] = [style(v, fg=self.color) for v in value]
else:
characters[key] = style(value, fg=self.color)

# first set corners
xs = np.array([self.start.x, self.start.x, self.end.x - 1, self.end.x - 1])
Expand Down Expand Up @@ -98,10 +108,11 @@ def extent(self) -> Tuple[Tuple[int]]:
class TextShape(Shape):
"""A text object."""

def __init__(self, text: str, point: Point, bold=False):
def __init__(self, text: str, point: Point, bold: bool = False, color: Optional[str] = None):
self.point = point
self.text = text.splitlines()
self.bold = bold
self.color = color

def draw(self, color: bool = True, ascii=False) -> Tuple[List[Tuple[int]], List[str]]:
"""Return the indices and values to draw this shape onto the canvas."""
Expand All @@ -116,10 +127,17 @@ def draw(self, color: bool = True, ascii=False) -> Tuple[List[Tuple[int]], List[
for char in line:
xs.append(current_x)
ys.append(current_y)
kwargs = dict()
if self.bold:
vals.append(style(char, bold=True))
kwargs["bold"] = True
if color and self.color:
kwargs["fg"] = self.color

if kwargs:
vals.append(style(char, **kwargs))
else:
vals.append(char)

current_x += 1
current_x = self.point.x
current_y += 1
Expand All @@ -137,15 +155,16 @@ def extent(self) -> Tuple[Tuple[int]]:
class NodeShape(Shape):
"""An activity node shape."""

def __init__(self, text: str, point: Point, double_border=False):
def __init__(self, text: str, point: Point, double_border=False, color: Optional[str] = None):
self.point = Point(round(point.x), round(point.y - len(text.splitlines())))
self.text_shape = TextShape(text, self.point, bold=double_border)
self.text_shape = TextShape(text, self.point, bold=double_border, color=color)

text_extent = self.text_shape.extent
self.box_shape = RectangleShape(
Point(text_extent[0].x - 1, text_extent[0].y - 1),
Point(text_extent[1].x + 1, text_extent[1].y + 1),
double_border=double_border,
color=color,
)

# move width/2 to the left to center on coordinate
Expand Down Expand Up @@ -174,7 +193,7 @@ def extent(self) -> Tuple[Tuple[int]]:
class EdgeShape(Shape):
"""An edge between two activities."""

EDGE_COLORS = ["red", "green", "yellow", "blue", "magenta", "cyan"]
COLORS = ["red", "green", "yellow", "blue", "magenta", "cyan"]
CURRENT_COLOR = 0

def __init__(self, start: Point, end: Point, color: str):
Expand All @@ -186,8 +205,8 @@ def __init__(self, start: Point, end: Point, color: str):
@staticmethod
def next_color() -> str:
"""Get the next color in the color rotation."""
EdgeShape.CURRENT_COLOR = (EdgeShape.CURRENT_COLOR + 1) % len(EdgeShape.EDGE_COLORS)
return EdgeShape.EDGE_COLORS[EdgeShape.CURRENT_COLOR]
EdgeShape.CURRENT_COLOR = (EdgeShape.CURRENT_COLOR + 1) % len(EdgeShape.COLORS)
return EdgeShape.COLORS[EdgeShape.CURRENT_COLOR]

def _line_indices(self, start: Point, end: Point):
"""Interpolate a line."""
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/test_workflow.py
Expand Up @@ -630,7 +630,7 @@ def test_workflow_visualize_non_interactive(runner, project, client, workflow_gr

# We don't use pytest paramtrization for performance reasons, so we don't need to build the workflow_graph fixture
# for each execution
columns = [[], ["-c", "command"], ["-c", "command,id,date"]]
columns = [[], ["-c", "command"], ["-c", "command,id,date,plan"]]
from_command = [
([], set()),
(["--from", "B"], {"A", "Z", "H", "J"}),
Expand Down

0 comments on commit ffa10fb

Please sign in to comment.