Skip to content

Commit 7776d27

Browse files
authored
Merge pull request #156 from hsorby/force-layout-2
Add ability to automatically layout a workflow
2 parents d1145c1 + 46af3c4 commit 7776d27

20 files changed

Lines changed: 796 additions & 144 deletions

src/mapclient/core/mainapplication.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from mapclient.core.checks import runChecks
3030
from mapclient.settings.definitions import CHECK_TOOLS_ON_STARTUP, RECENTS_LENGTH
3131
from mapclient.settings.general import get_settings
32+
from mapclient.settings.version import __version__ as version
3233

3334
logger = logging.getLogger(__name__)
3435

@@ -91,6 +92,9 @@ def doEnvironmentChecks(self):
9192

9293
def writeSettings(self):
9394
settings = get_settings()
95+
settings.beginGroup('Application')
96+
settings.setValue('version', version)
97+
settings.endGroup()
9498
settings.beginGroup('MainWindow')
9599
settings.setValue('size', self._size)
96100
settings.setValue('pos', self._pos)
@@ -109,6 +113,9 @@ def writeSettings(self):
109113

110114
def readSettings(self):
111115
settings = get_settings()
116+
settings.beginGroup('Application')
117+
settings_version = settings.value('version', '0.0.0')
118+
settings.endGroup()
112119
settings.beginGroup('MainWindow')
113120
self._size = settings.value('size', self._size)
114121
self._pos = settings.value('pos', self._pos)
@@ -120,7 +127,7 @@ def readSettings(self):
120127
settings.endArray()
121128
settings.endGroup()
122129

123-
self._pluginManager.readSettings(settings)
130+
self._pluginManager.readSettings(settings, settings_version)
124131
self._workflowManager.readSettings(settings)
125132
self._optionsManager.readSettings(settings)
126133
self._package_manager.read_settings(settings)

src/mapclient/core/managers/optionsmanager.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77
from mapclient.settings.general import get_virtualenv_directory
88
from mapclient.settings.definitions import SHOW_STEP_NAMES, CLOSE_AFTER, METRICS_PERMISSION, INTERNAL_EXE, UNSET_FLAG, \
99
DONT_CREATE_VIRTUAL_ENV, OPTIONS_SETTINGS_TAG, INTERNAL_WORKFLOWS_AVAILABLE, INTERNAL_WORKFLOW_DIR, VIRTUAL_ENV_PATH, \
10-
GIT_EXE, PYSIDE_UIC_EXE, PYSIDE_RCC_EXE, PREVIOUS_PW_WRITE_STEP_LOCATION, PREVIOUS_PW_ICON_LOCATION, CHECK_TOOLS_ON_STARTUP, \
10+
GIT_EXE, PYSIDE_UIC_EXE, PYSIDE_RCC_EXE, PREVIOUS_PW_WRITE_STEP_LOCATION, PREVIOUS_PW_ICON_LOCATION, \
11+
CHECK_TOOLS_ON_STARTUP, \
1112
USE_EXTERNAL_GIT, USE_EXTERNAL_RCC, USE_EXTERNAL_UIC, RECENTS_ABSOLUTE_PATHS, RECENTS_LENGTH, PREVIOUS_WORKFLOW, \
12-
AUTOLOAD_PREVIOUS_WORKFLOW, METRICS_PERMISSION_ATTAINED
13+
AUTOLOAD_PREVIOUS_WORKFLOW, METRICS_PERMISSION_ATTAINED, ANIMATE_LAYOUT_UPDATES
1314

1415

1516
def _is_boolean(option):
1617
return option in [SHOW_STEP_NAMES, CHECK_TOOLS_ON_STARTUP, DONT_CREATE_VIRTUAL_ENV, METRICS_PERMISSION, USE_EXTERNAL_GIT,
17-
USE_EXTERNAL_RCC, USE_EXTERNAL_UIC, RECENTS_ABSOLUTE_PATHS, INTERNAL_WORKFLOWS_AVAILABLE, AUTOLOAD_PREVIOUS_WORKFLOW]
18+
USE_EXTERNAL_RCC, USE_EXTERNAL_UIC, RECENTS_ABSOLUTE_PATHS, INTERNAL_WORKFLOWS_AVAILABLE,
19+
AUTOLOAD_PREVIOUS_WORKFLOW, ANIMATE_LAYOUT_UPDATES]
1820

1921

2022
def _is_float(option):
@@ -32,6 +34,7 @@ def __init__(self):
3234
self._options = {
3335
SHOW_STEP_NAMES: True, CLOSE_AFTER: 2.0, METRICS_PERMISSION: False,
3436
DONT_CREATE_VIRTUAL_ENV: False, CHECK_TOOLS_ON_STARTUP: True,
37+
ANIMATE_LAYOUT_UPDATES: True,
3538
USE_EXTERNAL_GIT: False, USE_EXTERNAL_RCC: False, USE_EXTERNAL_UIC: False,
3639
RECENTS_ABSOLUTE_PATHS: False, RECENTS_LENGTH: 10,
3740
VIRTUAL_ENV_PATH: get_virtualenv_directory(), GIT_EXE: which('git'),

src/mapclient/core/managers/pluginmanager.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -342,24 +342,33 @@ def profile_directories(self):
342342
"""
343343
return self._profile_directories
344344

345-
def readSettings(self, settings):
345+
def readSettings(self, settings, settings_version):
346346
self._profile_directories = {}
347347
settings.beginGroup('Plugins')
348348
self._doNotShowPluginErrors = settings.value('donot_show_plugin_errors', 'true') == 'true'
349349
self._virtualenv_setup_attempted = settings.value('virtualenv_setup_attempted', 'false') == 'true'
350350
self._current_profile = settings.value(_get_app_profile_key(), CONST_DEFAULT_PROFILE)
351-
profiles_count = settings.beginReadArray('profiles')
352-
for i in range(profiles_count):
353-
settings.setArrayIndex(i)
354-
profile_name = settings.value('name')
351+
if settings_version == '0.0.0':
355352
directory_count = settings.beginReadArray('directories')
356353
directories = []
357-
for j in range(directory_count):
358-
settings.setArrayIndex(j)
354+
for i in range(directory_count):
355+
settings.setArrayIndex(i)
359356
directories.append(settings.value('directory'))
360357
settings.endArray()
361-
self._profile_directories[profile_name] = directories
362-
settings.endArray()
358+
self._profile_directories[CONST_DEFAULT_PROFILE] = directories
359+
else:
360+
profiles_count = settings.beginReadArray('profiles')
361+
for i in range(profiles_count):
362+
settings.setArrayIndex(i)
363+
profile_name = settings.value('name')
364+
directory_count = settings.beginReadArray('directories')
365+
directories = []
366+
for j in range(directory_count):
367+
settings.setArrayIndex(j)
368+
directories.append(settings.value('directory'))
369+
settings.endArray()
370+
self._profile_directories[profile_name] = directories
371+
settings.endArray()
363372
settings.endGroup()
364373
self._profile_directories[CONST_DEFAULT_PROFILE] = self._profile_directories.get(CONST_DEFAULT_PROFILE, [])
365374
settings.beginGroup('Ignored Plugins')

src/mapclient/core/managers/workflowmanager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"""
2020
import os
2121
import logging
22+
2223
from packaging import version
2324

2425
from PySide6 import QtCore

src/mapclient/core/workflow/layouts/__init__.py

Whitespace-only changes.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import math
2+
from collections import deque
3+
4+
5+
def _build_adjacency_list(nodes, edges):
6+
adj = {name: [] for name in nodes}
7+
for edge in edges:
8+
adj[edge['from']].append(edge['to'])
9+
return adj
10+
11+
12+
def _topological_sort(graph):
13+
in_degree = {u: 0 for u in graph}
14+
for u in graph:
15+
for v in graph[u]:
16+
in_degree[v] += 1
17+
queue = deque([u for u in graph if in_degree[u] == 0])
18+
sorted_order = []
19+
while queue:
20+
u = queue.popleft()
21+
sorted_order.append(u)
22+
for v in graph[u]:
23+
in_degree[v] -= 1
24+
if in_degree[v] == 0: queue.append(v)
25+
if len(sorted_order) == len(graph):
26+
return sorted_order
27+
else:
28+
raise ValueError("Graph contains a cycle.")
29+
30+
31+
def _line_intersection(p1, p2, p3, p4):
32+
def orientation(p, q, r):
33+
val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1])
34+
if val == 0: return 0
35+
return 1 if val > 0 else 2
36+
37+
o1, o2, o3, o4 = orientation(p1, p2, p3), orientation(p1, p2, p4), orientation(p3, p4, p1), orientation(p3, p4, p2)
38+
if o1 != o2 and o3 != o4: return True
39+
return False
40+
41+
42+
class ForceDirectedLayout:
43+
def __init__(self, nodes, edges, height, node_size=(64, 64), iterations=300,
44+
k_repel=60000, k_attract=0.3, k_edge_repel=15, ideal_length=120, min_x_spacing=60):
45+
self._nodes = nodes
46+
self._edges = edges
47+
self._node_size = node_size
48+
self._iterations = iterations
49+
self._k_repel, self._k_attract, self._k_edge_repel = k_repel, k_attract, k_edge_repel
50+
self._ideal_length, self._min_x_spacing = ideal_length, min_x_spacing
51+
52+
adj_list = _build_adjacency_list(nodes, edges)
53+
self._sorted_nodes = _topological_sort(adj_list)
54+
self._parents = {u: [] for u in nodes}
55+
for edge in edges: self._parents[edge['to']].append(edge['from'])
56+
57+
self._positions = {}
58+
ranks = self._calculate_ranks()
59+
nodes_by_rank = {}
60+
for node, rank in ranks.items():
61+
if rank not in nodes_by_rank: nodes_by_rank[rank] = []
62+
nodes_by_rank[rank].append(node)
63+
for rank, nodes_in_rank in nodes_by_rank.items():
64+
x_pos = rank * (self._node_size[0] + self._min_x_spacing + 20) + 70
65+
num_in_rank = len(nodes_in_rank)
66+
if num_in_rank == 1:
67+
y_positions = [height / 2]
68+
else:
69+
total_span = (num_in_rank - 1) * (self._node_size[1] + 50)
70+
start_offset = -total_span / 2.0
71+
y_positions = [height / 2 + start_offset + i * (self._node_size[1] + 50) for i in range(num_in_rank)]
72+
for i, node in enumerate(nodes_in_rank):
73+
existing_nodes_position = nodes[node]['position'] if 'position' in nodes[node] else [0, 0]
74+
if existing_nodes_position == [0 , 0]:
75+
self._positions[node] = [x_pos, y_positions[i]]
76+
else:
77+
self._positions[node] = [existing_nodes_position[0], existing_nodes_position[1]]
78+
79+
def _calculate_ranks(self):
80+
ranks = {node: 0 for node in self._nodes}
81+
for node in self._sorted_nodes:
82+
if self._parents[node]:
83+
ranks[node] = max(ranks[p] for p in self._parents[node]) + 1
84+
return ranks
85+
86+
def max_iterations(self):
87+
return self._iterations
88+
89+
def positions(self):
90+
return self._positions
91+
92+
def get_port_position(self, node_name, port_type, port_index):
93+
center_x, center_y = self._positions[node_name]
94+
node_width, node_height = self._node_size
95+
if port_type == 'input':
96+
num_ports, x_pos = self._nodes[node_name]['inputs'], center_x - node_width / 2
97+
else:
98+
num_ports, x_pos = self._nodes[node_name]['outputs'], center_x + node_width / 2
99+
if num_ports <= 1: return [x_pos, center_y]
100+
vertical_span = node_height * 0.8
101+
spacing = vertical_span / (num_ports - 1)
102+
start_offset = -vertical_span / 2.0
103+
y_pos = center_y + start_offset + port_index * spacing
104+
return [x_pos, y_pos]
105+
106+
def _calculate_forces(self):
107+
forces = {node: [0.0, 0.0] for node in self._nodes}
108+
node_list = list(self._nodes.keys())
109+
for i in range(len(node_list)):
110+
for j in range(i + 1, len(node_list)):
111+
node1, node2 = node_list[i], node_list[j]
112+
dx, dy = self._positions[node1][0] - self._positions[node2][0], self._positions[node1][1] - self._positions[node2][
113+
1]
114+
distance = math.sqrt(dx ** 2 + dy ** 2) + 0.0001
115+
fy = (dy / distance) * (self._k_repel / (distance ** 2))
116+
forces[node1][1] += fy
117+
forces[node2][1] -= fy
118+
for edge in self._edges:
119+
pos1 = self.get_port_position(edge['from'], 'output', edge['from_port'])
120+
pos2 = self.get_port_position(edge['to'], 'input', edge['to_port'])
121+
dx, dy = pos1[0] - pos2[0], pos1[1] - pos2[1]
122+
distance = math.sqrt(dx ** 2 + dy ** 2) + 0.0001
123+
fy_attr = (dy / distance) * (self._k_attract * (distance - self._ideal_length))
124+
forces[edge['from']][1] -= fy_attr
125+
forces[edge['to']][1] += fy_attr
126+
if self._k_edge_repel > 0:
127+
for i in range(len(self._edges)):
128+
for j in range(i + 1, len(self._edges)):
129+
edge1, edge2 = self._edges[i], self._edges[j]
130+
if len({edge1['from'], edge1['to'], edge2['from'], edge2['to']}) < 4: continue
131+
e1_p1 = self.get_port_position(edge1['from'], 'output', edge1['from_port'])
132+
e1_p2 = self.get_port_position(edge1['to'], 'input', edge1['to_port'])
133+
e2_p1 = self.get_port_position(edge2['from'], 'output', edge2['from_port'])
134+
e2_p2 = self.get_port_position(edge2['to'], 'input', edge2['to_port'])
135+
if _line_intersection(e1_p1, e1_p2, e2_p1, e2_p2):
136+
mid1_y, mid2_y = (e1_p1[1] + e1_p2[1]) / 2, (e2_p1[1] + e2_p2[1]) / 2
137+
force_y = self._k_edge_repel if mid1_y < mid2_y else -self._k_edge_repel
138+
forces[edge1['from']][1] -= force_y
139+
forces[edge1['to']][1] -= force_y
140+
forces[edge2['from']][1] += force_y
141+
forces[edge2['to']][1] += force_y
142+
return forces
143+
144+
def update_layout(self, damping):
145+
forces = self._calculate_forces()
146+
for node in self._sorted_nodes:
147+
_, fy = forces[node]
148+
self._positions[node][1] += fy * damping
149+
max_parent_x = 0
150+
if self._parents[node]:
151+
max_parent_x = max(self._positions[p][0] for p in self._parents[node])
152+
self._positions[node][0] = max_parent_x + self._node_size[0] + self._min_x_spacing

0 commit comments

Comments
 (0)