mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 07:18:25 +01:00
300 lines
7.9 KiB
Python
300 lines
7.9 KiB
Python
"""Draws DAG in ASCII."""
|
|
|
|
import math
|
|
import os
|
|
|
|
from grandalf.graphs import Edge, Graph, Vertex
|
|
from grandalf.layouts import SugiyamaLayout
|
|
from grandalf.routing import EdgeViewer, route_with_lines
|
|
|
|
from dvc.log import logger
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
class VertexViewer:
|
|
"""Class to define vertex box boundaries that will be accounted for during
|
|
graph building by grandalf.
|
|
|
|
Args:
|
|
name (str): name of the vertex.
|
|
"""
|
|
|
|
HEIGHT = 3 # top and bottom box edges + text
|
|
|
|
def __init__(self, name):
|
|
self._h = self.HEIGHT # top and bottom box edges + text
|
|
self._w = len(name) + 2 # right and left bottom edges + text
|
|
|
|
@property
|
|
def h(self):
|
|
"""Height of the box."""
|
|
return self._h
|
|
|
|
@property
|
|
def w(self):
|
|
"""Width of the box."""
|
|
return self._w
|
|
|
|
|
|
class AsciiCanvas:
|
|
"""Class for drawing in ASCII.
|
|
|
|
Args:
|
|
cols (int): number of columns in the canvas. Should be > 1.
|
|
lines (int): number of lines in the canvas. Should be > 1.
|
|
"""
|
|
|
|
TIMEOUT = 10
|
|
|
|
def __init__(self, cols, lines):
|
|
assert cols > 1
|
|
assert lines > 1
|
|
|
|
self.cols = cols
|
|
self.lines = lines
|
|
|
|
self.canvas = [[" "] * cols for line in range(lines)]
|
|
|
|
def draw(self):
|
|
"""Draws ASCII canvas on the screen."""
|
|
lines = map("".join, self.canvas)
|
|
return os.linesep.join(lines)
|
|
|
|
def point(self, x, y, char):
|
|
"""Create a point on ASCII canvas.
|
|
|
|
Args:
|
|
x (int): x coordinate. Should be >= 0 and < number of columns in
|
|
the canvas.
|
|
y (int): y coordinate. Should be >= 0 an < number of lines in the
|
|
canvas.
|
|
char (str): character to place in the specified point on the
|
|
canvas.
|
|
"""
|
|
assert len(char) == 1
|
|
assert x >= 0
|
|
assert x < self.cols
|
|
assert y >= 0
|
|
assert y < self.lines
|
|
|
|
self.canvas[y][x] = char
|
|
|
|
def line(self, x0, y0, x1, y1, char): # noqa: C901, PLR0912
|
|
"""Create a line on ASCII canvas.
|
|
|
|
Args:
|
|
x0 (int): x coordinate where the line should start.
|
|
y0 (int): y coordinate where the line should start.
|
|
x1 (int): x coordinate where the line should end.
|
|
y1 (int): y coordinate where the line should end.
|
|
char (str): character to draw the line with.
|
|
"""
|
|
if x0 > x1:
|
|
x1, x0 = x0, x1
|
|
y1, y0 = y0, y1
|
|
|
|
dx = x1 - x0
|
|
dy = y1 - y0
|
|
|
|
if dx == 0 and dy == 0:
|
|
self.point(x0, y0, char)
|
|
elif abs(dx) >= abs(dy):
|
|
for x in range(x0, x1 + 1):
|
|
if dx == 0:
|
|
y = y0
|
|
else:
|
|
y = y0 + round((x - x0) * dy / float(dx))
|
|
self.point(x, y, char)
|
|
elif y0 < y1:
|
|
for y in range(y0, y1 + 1):
|
|
if dy == 0:
|
|
x = x0
|
|
else:
|
|
x = x0 + round((y - y0) * dx / float(dy))
|
|
self.point(x, y, char)
|
|
else:
|
|
for y in range(y1, y0 + 1):
|
|
if dy == 0:
|
|
x = x0
|
|
else:
|
|
x = x1 + round((y - y1) * dx / float(dy))
|
|
self.point(x, y, char)
|
|
|
|
def text(self, x, y, text):
|
|
"""Print a text on ASCII canvas.
|
|
|
|
Args:
|
|
x (int): x coordinate where the text should start.
|
|
y (int): y coordinate where the text should start.
|
|
text (str): string that should be printed.
|
|
"""
|
|
for i, char in enumerate(text):
|
|
self.point(x + i, y, char)
|
|
|
|
def box(self, x0, y0, width, height):
|
|
"""Create a box on ASCII canvas.
|
|
|
|
Args:
|
|
x0 (int): x coordinate of the box corner.
|
|
y0 (int): y coordinate of the box corner.
|
|
width (int): box width.
|
|
height (int): box height.
|
|
"""
|
|
assert width > 1
|
|
assert height > 1
|
|
|
|
width -= 1
|
|
height -= 1
|
|
|
|
for x in range(x0, x0 + width):
|
|
self.point(x, y0, "-")
|
|
self.point(x, y0 + height, "-")
|
|
|
|
for y in range(y0, y0 + height):
|
|
self.point(x0, y, "|")
|
|
self.point(x0 + width, y, "|")
|
|
|
|
self.point(x0, y0, "+")
|
|
self.point(x0 + width, y0, "+")
|
|
self.point(x0, y0 + height, "+")
|
|
self.point(x0 + width, y0 + height, "+")
|
|
|
|
|
|
def _build_sugiyama_layout(vertices, edges):
|
|
#
|
|
# Just a reminder about naming conventions:
|
|
# +------------X
|
|
# |
|
|
# |
|
|
# |
|
|
# |
|
|
# Y
|
|
#
|
|
|
|
vertices = {v: Vertex(f" {v} ") for v in vertices}
|
|
# NOTE: reverting edges to correctly orientate the graph
|
|
edges = [Edge(vertices[e], vertices[s]) for s, e in edges]
|
|
vertices = vertices.values()
|
|
graph = Graph(vertices, edges)
|
|
|
|
for vertex in vertices:
|
|
vertex.view = VertexViewer(vertex.data)
|
|
|
|
# NOTE: determine min box length to create the best layout
|
|
minw = min(v.view.w for v in vertices)
|
|
|
|
for edge in edges:
|
|
edge.view = EdgeViewer()
|
|
|
|
sug = SugiyamaLayout(graph.C[0])
|
|
graph = graph.C[0]
|
|
roots = list(filter(lambda x: len(x.e_in()) == 0, graph.sV))
|
|
|
|
sug.init_all(roots=roots, optimize=True)
|
|
|
|
sug.yspace = VertexViewer.HEIGHT
|
|
sug.xspace = minw
|
|
sug.route_edge = route_with_lines # ty: ignore[unresolved-attribute]
|
|
|
|
sug.draw()
|
|
|
|
return sug
|
|
|
|
|
|
def draw(vertices, edges):
|
|
"""Build a DAG and draw it in ASCII.
|
|
|
|
Args:
|
|
vertices (list): list of graph vertices.
|
|
edges (list): list of graph edges.
|
|
|
|
Returns:
|
|
str: ASCII representation
|
|
|
|
Example:
|
|
>>> from dvc.dagascii import draw
|
|
>>> vertices = [1, 2, 3, 4]
|
|
>>> edges = [(1, 2), (2, 3), (2, 4), (1, 4)]
|
|
>>> print(draw(vertices, edges))
|
|
+---+ +---+
|
|
| 3 | | 4 |
|
|
+---+ *+---+
|
|
* ** *
|
|
* ** *
|
|
* * *
|
|
+---+ *
|
|
| 2 | *
|
|
+---+ *
|
|
* *
|
|
* *
|
|
**
|
|
+---+
|
|
| 1 |
|
|
+---+
|
|
"""
|
|
|
|
# NOTE: coordinates might me negative, so we need to shift
|
|
# everything to the positive plane before we actually draw it.
|
|
Xs = [] # noqa: N806
|
|
Ys = [] # noqa: N806
|
|
|
|
sug = _build_sugiyama_layout(vertices, edges)
|
|
|
|
for vertex in sug.g.sV:
|
|
# NOTE: moving boxes w/2 to the left
|
|
Xs.append(vertex.view.xy[0] - vertex.view.w / 2.0)
|
|
Xs.append(vertex.view.xy[0] + vertex.view.w / 2.0)
|
|
Ys.append(vertex.view.xy[1])
|
|
Ys.append(vertex.view.xy[1] + vertex.view.h)
|
|
|
|
for edge in sug.g.sE:
|
|
for x, y in edge.view._pts:
|
|
Xs.append(x)
|
|
Ys.append(y)
|
|
|
|
minx = min(Xs)
|
|
miny = min(Ys)
|
|
maxx = max(Xs)
|
|
maxy = max(Ys)
|
|
|
|
canvas_cols = math.ceil(math.ceil(maxx) - math.floor(minx)) + 1
|
|
canvas_lines = round(maxy - miny)
|
|
|
|
canvas = AsciiCanvas(canvas_cols, canvas_lines)
|
|
|
|
# NOTE: first draw edges so that node boxes could overwrite them
|
|
for edge in sug.g.sE:
|
|
assert len(edge.view._pts) > 1
|
|
for index in range(1, len(edge.view._pts)):
|
|
start = edge.view._pts[index - 1]
|
|
end = edge.view._pts[index]
|
|
|
|
start_x = round(start[0] - minx)
|
|
start_y = round(start[1] - miny)
|
|
end_x = round(end[0] - minx)
|
|
end_y = round(end[1] - miny)
|
|
|
|
assert start_x >= 0
|
|
assert start_y >= 0
|
|
assert end_x >= 0
|
|
assert end_y >= 0
|
|
|
|
canvas.line(start_x, start_y, end_x, end_y, "*")
|
|
|
|
for vertex in sug.g.sV:
|
|
# NOTE: moving boxes w/2 to the left
|
|
x = vertex.view.xy[0] - vertex.view.w / 2.0
|
|
y = vertex.view.xy[1]
|
|
|
|
canvas.box(
|
|
round(x - minx),
|
|
round(y - miny),
|
|
vertex.view.w,
|
|
vertex.view.h,
|
|
)
|
|
|
|
canvas.text(round(x - minx) + 1, round(y - miny) + 1, vertex.data)
|
|
|
|
return canvas.draw()
|