Skip to content

Commit e173dd4

Browse files
committed
Rearrange force directed layouts to use the undo redo stack.
1 parent 1c32577 commit e173dd4

3 files changed

Lines changed: 164 additions & 112 deletions

File tree

src/mapclient/core/managers/workflowmanager.py

Lines changed: 0 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@
2525
from PySide6 import QtCore
2626

2727
from mapclient.core.metrics import metrics_logger
28-
from mapclient.core.workflow.layouts.forcedirected import ForceDirectedLayout
29-
from mapclient.core.workflow.layouts.springforce import SpringForce
3028
from mapclient.settings import info
3129
from mapclient.core.workflow.workflowscene import WorkflowScene, read_steps, load_from
3230
from mapclient.core.workflow.workflowsteps import WorkflowSteps, \
@@ -61,20 +59,6 @@ def _get_workflow_meta_absolute_filename(location):
6159
return os.path.join(location, info.DEFAULT_WORKFLOW_ANNOTATION_FILENAME)
6260

6361

64-
def _determine_input_output_ports(meta_step):
65-
"""
66-
Determine the input and output ports for a step.
67-
:param meta_step: The step to determine the ports for.
68-
:return: A list of input and output ports.
69-
"""
70-
step = meta_step.getStep()
71-
step_ports = step.getPorts()
72-
uses_ports = [port for port in step_ports if port.has_uses()]
73-
provides_ports = [port for port in step_ports if port.has_provides()]
74-
75-
return {'inputs': len(uses_ports), 'outputs': len(provides_ports)}
76-
77-
7862
class WorkflowManager(object):
7963
"""
8064
This class manages (models?) the workflow.
@@ -88,10 +72,6 @@ def __init__(self, parent):
8872
self._previousLocation = None
8973
self._saveStateIndex = 0
9074
self._currentStateIndex = 0
91-
self._layout_engine = None
92-
self._layout_timer = QtCore.QTimer()
93-
self._layout_timer.timeout.connect(self._animation_step)
94-
self._iteration_count = 0
9575

9676
self._title = None
9777

@@ -158,97 +138,6 @@ def abort_execution(self):
158138
def set_workflow_direction(self, direction):
159139
self._scene.set_workflow_direction(direction)
160140

161-
def layout_workflow(self, layout_algorithm, animate=True):
162-
graph, graph_edges, reverse_graph = self._scene.graph()
163-
view_parameters = self._scene.getViewParameters()
164-
adjacent_graph = {}
165-
vertices = []
166-
167-
unique_meta_steps = set()
168-
for node, targets in graph.items():
169-
unique_meta_steps.add(node)
170-
unique_meta_steps.update(targets)
171-
172-
vertices.append(node)
173-
adjacent_graph[node] = graph[node]
174-
175-
node_definitions = {}
176-
for node in unique_meta_steps:
177-
node_ports = _determine_input_output_ports(node)
178-
node_definitions[node.getIdentifier()] = {
179-
'name': node.getName(),
180-
'position': (node.getPos().x(), node.getPos().y()),
181-
'inputs': node_ports['inputs'],
182-
'outputs': node_ports['outputs'],
183-
}
184-
185-
edge_definitions = [
186-
{'from': edge['from'].getIdentifier(), 'from_port': edge['from_port'],
187-
'to': edge['to'].getIdentifier(), 'to_port': edge['to_port']}
188-
for edge in graph_edges
189-
]
190-
191-
for node in reverse_graph:
192-
if node not in adjacent_graph:
193-
vertices.append(node)
194-
adjacent_graph[node] = []
195-
196-
adjacent_graph[node] = list(set(adjacent_graph[node] + reverse_graph[node]))
197-
198-
all_set = set(adjacent_graph.keys())
199-
non_adjacent_graph = {}
200-
for node in adjacent_graph:
201-
adjacent_set = set(adjacent_graph[node])
202-
non_adjacent_set = all_set - adjacent_set - {node}
203-
non_adjacent_graph[node] = list(non_adjacent_set)
204-
205-
dataset = {
206-
'vertices': vertices,
207-
'adjacent': adjacent_graph,
208-
'non_adjacent': non_adjacent_graph
209-
}
210-
211-
if layout_algorithm == 'force_directed':
212-
self._layout_engine = ForceDirectedLayout(node_definitions, edge_definitions, view_parameters['rect'].width())
213-
elif layout_algorithm == 'spring_force':
214-
self._layout_engine = SpringForce(dataset, iterations=100, target_node_speed=0.01)
215-
else:
216-
raise WorkflowError(f"Unknown layout algorithm: {layout_algorithm}")
217-
218-
self._iteration_count = 0
219-
if animate:
220-
self._layout_timer.start(16)
221-
else:
222-
self._layout_worfklow()
223-
224-
def _layout_worfklow(self):
225-
while self._iteration_count < self._layout_engine.max_iterations():
226-
if hasattr(self._layout_engine, 'update_layout'):
227-
damping = 1.0 - (self._iteration_count / self._layout_engine.max_iterations())
228-
self._layout_engine.update_layout(damping)
229-
elif hasattr(self._layout_engine, 'spring_layout'):
230-
self._layout_engine.spring_layout(c1=2.0, c2=10.0, c3=1000000, c4=1, return_after=1)
231-
232-
self._iteration_count += 1
233-
234-
positions = {k: QtCore.QPoint(v[0], v[1]) for k, v in self._layout_engine.positions().items()}
235-
self._scene.set_step_positions(positions)
236-
237-
def _animation_step(self):
238-
if self._iteration_count >= self._layout_engine.max_iterations():
239-
self._layout_timer.stop()
240-
return
241-
242-
if hasattr(self._layout_engine, 'update_layout'):
243-
damping = 1.0 - (self._iteration_count / self._layout_engine.max_iterations())
244-
self._layout_engine.update_layout(damping)
245-
elif hasattr(self._layout_engine, 'spring_layout'):
246-
self._layout_engine.spring_layout(c1=2.0, c2=10.0, c3=1000000, c4=1, return_after=1)
247-
248-
positions = {k: QtCore.QPoint(v[0], v[1]) for k, v in self._layout_engine.positions().items()}
249-
self._scene.set_step_positions(positions)
250-
self._iteration_count += 1
251-
252141
def canExecute(self):
253142
return self._scene.canExecute()
254143

src/mapclient/view/workflow/workflowgraphicsview.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
from PySide6 import QtCore, QtWidgets, QtGui
2424

25+
from mapclient.core.workflow.layouts.forcedirected import ForceDirectedLayout
26+
from mapclient.core.workflow.layouts.springforce import SpringForce
2527
from mapclient.core.workflow.workflowutils import revert_parameterised_position
2628
from mapclient.mountpoints.workflowstep import workflowStepFactory
2729
from mapclient.core.workflow.workflowscene import MetaStep
@@ -31,6 +33,12 @@
3133

3234
logger = logging.getLogger()
3335

36+
# This is the threshold for the stability of the layout. If the change in position of all nodes is less than this value,
37+
# the layout is considered stable and the animation stops.
38+
STABILITY_THRESHOLD = 0.05
39+
# The number of consecutive stable frames before the layout is considered stable.
40+
CONSECUTIVE_STABLE_FRAMES = 5
41+
3442

3543
class WorkflowGraphicsView(QtWidgets.QGraphicsView):
3644

@@ -48,6 +56,14 @@ def __init__(self, parent=None):
4856
self._graphics_scale_factor = 1.0
4957
self._margin = 10
5058

59+
self._layout_engine = None
60+
self._layout_timer = QtCore.QTimer()
61+
self._layout_timer.timeout.connect(self._animation_step)
62+
self._layout_iteration_count = 0
63+
self._layout_initial_positions = None
64+
self._layout_previous_positions = None
65+
self._layout_stability_counter = 0
66+
5167
self._undoStack = None
5268
self._location = ''
5369
self._showStepNames = True
@@ -602,3 +618,150 @@ def scale_workflow(self, sf_x, sf_y):
602618
if x != item.x() or y != item.y():
603619
self._undoStack.push(CommandMove(item, item.pos(), QtCore.QPointF(x, y)))
604620
self._undoStack.endMacro()
621+
622+
def layout_workflow(self, layout_algorithm, animate=True):
623+
sceneRect = self.sceneRect()
624+
core_scene = self.scene().workflowScene()
625+
626+
graph, graph_edges, reverse_graph = core_scene.graph()
627+
adjacent_graph = {}
628+
vertices = []
629+
630+
unique_meta_steps = set()
631+
for node, targets in graph.items():
632+
unique_meta_steps.add(node)
633+
unique_meta_steps.update(targets)
634+
635+
vertices.append(node)
636+
adjacent_graph[node] = graph[node]
637+
638+
node_definitions = {}
639+
for node in unique_meta_steps:
640+
node_ports = _determine_input_output_ports(node)
641+
node_definitions[node.getIdentifier()] = {
642+
'name': node.getName(),
643+
'position': (node.getPos().x(), node.getPos().y()),
644+
'inputs': node_ports['inputs'],
645+
'outputs': node_ports['outputs'],
646+
}
647+
648+
edge_definitions = [
649+
{'from': edge['from'].getIdentifier(), 'from_port': edge['from_port'],
650+
'to': edge['to'].getIdentifier(), 'to_port': edge['to_port']}
651+
for edge in graph_edges
652+
]
653+
654+
for node in reverse_graph:
655+
if node not in adjacent_graph:
656+
vertices.append(node)
657+
adjacent_graph[node] = []
658+
659+
adjacent_graph[node] = list(set(adjacent_graph[node] + reverse_graph[node]))
660+
661+
all_set = set(adjacent_graph.keys())
662+
non_adjacent_graph = {}
663+
for node in adjacent_graph:
664+
adjacent_set = set(adjacent_graph[node])
665+
non_adjacent_set = all_set - adjacent_set - {node}
666+
non_adjacent_graph[node] = list(non_adjacent_set)
667+
668+
dataset = {
669+
'vertices': vertices,
670+
'adjacent': adjacent_graph,
671+
'non_adjacent': non_adjacent_graph
672+
}
673+
674+
if layout_algorithm == 'force_directed':
675+
self._layout_engine = ForceDirectedLayout(node_definitions, edge_definitions, sceneRect.width())
676+
elif layout_algorithm == 'spring_force':
677+
self._layout_engine = SpringForce(dataset, iterations=100, target_node_speed=0.01)
678+
679+
self._layout_iteration_count = 0
680+
self._layout_initial_positions = {k: QtCore.QPoint(v[0], v[1]) for k, v in self._layout_engine.positions().items()}
681+
self._layout_previous_positions = {k: QtCore.QPoint(v[0], v[1]) for k, v in self._layout_engine.positions().items()}
682+
if animate:
683+
self._undoStack.beginMacro('Move Step(s)')
684+
self._layout_timer.start(16)
685+
else:
686+
self._layout_workflow()
687+
688+
def _move_nodes(self, positions):
689+
for item in self.items():
690+
if item.type() == Node.Type:
691+
item_identifier = item.metaItem().getIdentifier()
692+
if item_identifier in positions:
693+
self._undoStack.push(CommandMove(item, self._layout_initial_positions[item_identifier], positions[item_identifier]))
694+
695+
def _layout_workflow(self):
696+
while self._layout_iteration_count < self._layout_engine.max_iterations():
697+
if hasattr(self._layout_engine, 'update_layout'):
698+
damping = 1.0 - (self._layout_iteration_count / self._layout_engine.max_iterations())
699+
self._layout_engine.update_layout(damping)
700+
elif hasattr(self._layout_engine, 'spring_layout'):
701+
self._layout_engine.spring_layout(c1=2.0, c2=10.0, c3=1000000, c4=1, return_after=1)
702+
703+
positions = {k: QtCore.QPoint(v[0], v[1]) for k, v in self._layout_engine.positions().items()}
704+
self._check_stability(positions)
705+
706+
self._layout_iteration_count += 1
707+
708+
positions = {k: QtCore.QPoint(v[0], v[1]) for k, v in self._layout_engine.positions().items()}
709+
710+
self._undoStack.beginMacro('Move Step(s)')
711+
self._move_nodes(positions)
712+
self._undoStack.endMacro()
713+
714+
def _check_stability(self, positions):
715+
total_movement_sq = _calculate_total_movement(self._layout_previous_positions, positions)
716+
self._layout_previous_positions = positions
717+
if total_movement_sq < STABILITY_THRESHOLD:
718+
self._layout_stability_counter += 1
719+
else:
720+
# If there's any significant movement, reset the counter
721+
self._layout_stability_counter = 0
722+
723+
if self._layout_stability_counter >= CONSECUTIVE_STABLE_FRAMES:
724+
self._layout_iteration_count = self._layout_engine.max_iterations()
725+
726+
def _animation_step(self):
727+
if self._layout_iteration_count >= self._layout_engine.max_iterations():
728+
self._undoStack.endMacro()
729+
self._layout_timer.stop()
730+
return
731+
732+
if hasattr(self._layout_engine, 'update_layout'):
733+
damping = 1.0 - (self._layout_iteration_count / self._layout_engine.max_iterations())
734+
self._layout_engine.update_layout(damping)
735+
elif hasattr(self._layout_engine, 'spring_layout'):
736+
self._layout_engine.spring_layout(c1=2.0, c2=10.0, c3=1000000, c4=1, return_after=1)
737+
738+
positions = {k: QtCore.QPoint(v[0], v[1]) for k, v in self._layout_engine.positions().items()}
739+
self._move_nodes(positions)
740+
self._check_stability(positions)
741+
self._layout_iteration_count += 1
742+
743+
744+
def _calculate_total_movement(previous_positions, current_positions):
745+
total_movement_sq = 0
746+
for identifier, prev_pos in previous_positions.items():
747+
curr_pos = current_positions[identifier]
748+
749+
dx = curr_pos.x() - prev_pos.x()
750+
dy = curr_pos.y() - prev_pos.y()
751+
total_movement_sq += dx * dx + dy * dy
752+
753+
return total_movement_sq
754+
755+
756+
def _determine_input_output_ports(meta_step):
757+
"""
758+
Determine the input and output ports for a step.
759+
:param meta_step: The step to determine the ports for.
760+
:return: A list of input and output ports.
761+
"""
762+
step = meta_step.getStep()
763+
step_ports = step.getPorts()
764+
uses_ports = [port for port in step_ports if port.has_uses()]
765+
provides_ports = [port for port in step_ports if port.has_provides()]
766+
767+
return {'inputs': len(uses_ports), 'outputs': len(provides_ports)}

src/mapclient/view/workflow/workflowwidget.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ def _abort_workflow(self):
210210
self._reset_workflow_direction()
211211

212212
def _do_layout(self, layout_type):
213-
self._workflowManager.layout_workflow(layout_type, self._options_manager.getOption(ANIMATE_LAYOUT_UPDATES))
213+
self._ui.graphicsView.layout_workflow(layout_type, self._options_manager.getOption(ANIMATE_LAYOUT_UPDATES))
214214

215215
def _spring_force_layout(self):
216216
self._do_layout("spring_force")

0 commit comments

Comments
 (0)