Skip to content

Commit e576876

Browse files
jmchiltonclaude
andcommitted
cytoscape: --layout flag with shared topological algorithm
Adds `--layout <name>` to gxwf-viz, mirroring the TS port at galaxy-tool-util-ts. Names match cytoscape.js layout vocabulary: - preset (default) — today's behavior, byte-identical output - topological — computed leveled layout, baked into data.position - dagre / breadthfirst / grid / cose / random — hint-only; positions dropped, runtime renderer (cytoscape.js) places nodes at view time `topological` is a tiny longest-path layering (~30 LOC) with stride constants pinned to a cross-language spec at galaxy-tool-util-ts/docs/architecture/cytoscape-layout.md. Cross-language parity verified end-to-end: TS and Python emit byte-identical positions for the same workflow. Default flat-list JSON shape preserved for back-compat. Non-default layouts emit a {elements, layout} wrapper so consumers see the hint. Template synced from TS (adds cytoscape-dagre + dagre CDN, $layout substitution, falls back to preset if dagre unavailable). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fe47a31 commit e576876

9 files changed

Lines changed: 446 additions & 13 deletions

File tree

gxformat2/cytoscape/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,42 @@
22

33
from ._builder import cytoscape_elements
44
from ._cli import main, to_cytoscape
5+
from ._layout import (
6+
bakes_coordinates,
7+
COL_STRIDE,
8+
is_layout_name,
9+
LAYOUT_NAMES,
10+
ROW_STRIDE,
11+
topological_positions,
12+
)
513
from ._render import CYTOSCAPE_JS_TEMPLATE, render_html
614
from .models import (
715
CytoscapeEdge,
816
CytoscapeEdgeData,
917
CytoscapeElements,
18+
CytoscapeLayout,
1019
CytoscapeNode,
1120
CytoscapeNodeData,
1221
CytoscapePosition,
1322
)
1423

1524
__all__ = (
25+
"COL_STRIDE",
1626
"CYTOSCAPE_JS_TEMPLATE",
1727
"CytoscapeEdge",
1828
"CytoscapeEdgeData",
1929
"CytoscapeElements",
30+
"CytoscapeLayout",
2031
"CytoscapeNode",
2132
"CytoscapeNodeData",
2233
"CytoscapePosition",
34+
"LAYOUT_NAMES",
35+
"ROW_STRIDE",
36+
"bakes_coordinates",
2337
"cytoscape_elements",
38+
"is_layout_name",
2439
"main",
2540
"render_html",
2641
"to_cytoscape",
42+
"topological_positions",
2743
)

gxformat2/cytoscape/_builder.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
from gxformat2.normalized import ensure_format2, NormalizedFormat2, NormalizedWorkflowStep
99
from gxformat2.schema.gxformat2 import BaseInputParameter, GalaxyType, GalaxyWorkflow
1010

11+
from ._layout import bakes_coordinates, is_layout_name, topological_positions
1112
from .models import (
1213
CytoscapeEdge,
1314
CytoscapeEdgeData,
1415
CytoscapeElements,
16+
CytoscapeLayout,
1517
CytoscapeNode,
1618
CytoscapeNodeData,
1719
CytoscapePosition,
@@ -22,12 +24,22 @@
2224

2325
def cytoscape_elements(
2426
workflow: dict[str, Any] | str | Path | GalaxyWorkflow | NormalizedFormat2,
27+
*,
28+
layout: str = "preset",
2529
) -> CytoscapeElements:
2630
"""Build Cytoscape visualization elements from a Galaxy workflow.
2731
2832
Accepts anything ``normalized_format2()`` supports, plus an already
2933
normalized ``NormalizedFormat2`` instance.
34+
35+
``layout`` selects the placement strategy (default ``preset``); see
36+
``_layout.py`` and the cross-language spec for details.
3037
"""
38+
if not is_layout_name(layout):
39+
raise ValueError(
40+
f'Unknown layout "{layout}". Valid values: ' "preset, topological, dagre, breadthfirst, grid, cose, random."
41+
)
42+
3143
if isinstance(workflow, NormalizedFormat2):
3244
nf2 = workflow
3345
else:
@@ -44,7 +56,25 @@ def cytoscape_elements(
4456
nodes.append(_step_node(step, i + inputs_offset))
4557
edges.extend(_step_edges(step, nf2))
4658

47-
return CytoscapeElements(nodes=nodes, edges=edges)
59+
elements = CytoscapeElements(nodes=nodes, edges=edges)
60+
61+
if layout == "preset":
62+
return elements
63+
64+
if bakes_coordinates(layout):
65+
# Currently only ``topological`` reaches here.
66+
positions = topological_positions(elements)
67+
for node in nodes:
68+
p = positions.get(node.data.id)
69+
if p is not None:
70+
node.position = p
71+
else:
72+
# Hint-only layout: drop coordinates; the runtime renderer places nodes.
73+
for node in nodes:
74+
node.position = None
75+
76+
elements.layout = CytoscapeLayout(name=layout)
77+
return elements
4878

4979

5080
def _fallback_position(order_index: int) -> CytoscapePosition:

gxformat2/cytoscape/_cli.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66

77
from ._builder import cytoscape_elements
8+
from ._layout import LAYOUT_NAMES
89
from ._render import render_html
910

1011
SCRIPT_DESCRIPTION = """
@@ -19,20 +20,23 @@
1920
"""
2021

2122

22-
def to_cytoscape(workflow_path: str, output_path=None):
23+
def to_cytoscape(workflow_path: str, output_path=None, layout: str = "preset"):
2324
"""Produce cytoscape output for supplied workflow path."""
2425
if output_path is None:
2526
output_path, _ = os.path.splitext(workflow_path)
2627
output_path += ".html"
2728

28-
elements = cytoscape_elements(workflow_path)
29+
elements = cytoscape_elements(workflow_path, layout=layout)
2930

3031
if output_path.endswith(".html"):
3132
with open(output_path, "w") as f:
32-
f.write(render_html(elements))
33+
f.write(render_html(elements, layout=layout))
3334
else:
35+
# Bare flat list for ``preset`` (back-compat); wrapped {elements, layout}
36+
# otherwise so the layout hint travels with the JSON.
37+
payload = elements.to_list() if layout == "preset" else elements.to_dict()
3438
with open(output_path, "w") as f:
35-
json.dump(elements.to_list(), f)
39+
json.dump(payload, f)
3640

3741

3842
def main(argv=None):
@@ -41,7 +45,7 @@ def main(argv=None):
4145
argv = sys.argv[1:]
4246

4347
args = _parser().parse_args(argv)
44-
to_cytoscape(args.input_path, args.output_path)
48+
to_cytoscape(args.input_path, args.output_path, layout=args.layout)
4549

4650

4751
def _parser():
@@ -50,4 +54,14 @@ def _parser():
5054
parser = argparse.ArgumentParser(description=SCRIPT_DESCRIPTION)
5155
parser.add_argument("input_path", metavar="INPUT", type=str, help="input workflow path (.ga/gxwf.yml)")
5256
parser.add_argument("output_path", metavar="OUTPUT", type=str, nargs="?", help="output viz path (.json/.html)")
57+
parser.add_argument(
58+
"--layout",
59+
type=str,
60+
default="preset",
61+
choices=list(LAYOUT_NAMES),
62+
help=(
63+
"Layout strategy: preset (default; honors workflow positions), "
64+
"topological (computed leveled layout), dagre, breadthfirst, grid, cose, random"
65+
),
66+
)
5367
return parser

gxformat2/cytoscape/_layout.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Cross-language topological layout for Cytoscape elements.
2+
3+
Mirror of the TypeScript port at
4+
``galaxy-tool-util-ts/packages/schema/src/workflow/cytoscape-layout.ts``.
5+
6+
Both implementations MUST produce byte-identical (x, y) coordinates for a
7+
given input. The normative spec lives in the galaxy-tool-util-ts repo at
8+
``docs/architecture/cytoscape-layout.md``. Any change here is a breaking
9+
visual diff and must land in lockstep with that file.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import get_args, Literal
15+
16+
from .models import CytoscapeElements, CytoscapePosition
17+
18+
COL_STRIDE = 220
19+
ROW_STRIDE = 100
20+
21+
LayoutName = Literal[
22+
"preset",
23+
"topological",
24+
"dagre",
25+
"breadthfirst",
26+
"grid",
27+
"cose",
28+
"random",
29+
]
30+
31+
LAYOUT_NAMES: tuple[str, ...] = get_args(LayoutName)
32+
33+
34+
def is_layout_name(value: str) -> bool:
35+
return value in LAYOUT_NAMES
36+
37+
38+
def bakes_coordinates(layout: str) -> bool:
39+
"""Layouts that bake coordinates into ``data.position``.
40+
41+
All other layouts are hint-only and rely on the runtime renderer.
42+
"""
43+
return layout in ("preset", "topological")
44+
45+
46+
def topological_positions(elements: CytoscapeElements) -> dict[str, CytoscapePosition]:
47+
"""Compute positions per the topological layering spec.
48+
49+
Returns a mapping keyed by node ``data.id``.
50+
"""
51+
nodes = elements.nodes
52+
node_ids = [n.data.id for n in nodes]
53+
index_by_id = {node_id: i for i, node_id in enumerate(node_ids)}
54+
55+
incoming: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
56+
for edge in elements.edges:
57+
source = edge.data.source
58+
target = edge.data.target
59+
if source not in index_by_id or target not in index_by_id:
60+
continue
61+
incoming[target].append(source)
62+
63+
in_degree: dict[str, int] = {node_id: len(srcs) for node_id, srcs in incoming.items()}
64+
65+
dependents: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
66+
for target, sources in incoming.items():
67+
for s in sources:
68+
dependents[s].append(target)
69+
70+
column: dict[str, int] = {}
71+
visited: set[str] = set()
72+
73+
# Kahn topo sort, declaration-index tie break.
74+
queue: list[str] = [node_id for node_id in node_ids if in_degree[node_id] == 0]
75+
queue.sort(key=lambda nid: index_by_id[nid])
76+
77+
while queue:
78+
# Pop lowest declaration index.
79+
best = 0
80+
for i in range(1, len(queue)):
81+
if index_by_id[queue[i]] < index_by_id[queue[best]]:
82+
best = i
83+
node_id = queue.pop(best)
84+
visited.add(node_id)
85+
86+
sources = incoming[node_id]
87+
if not sources:
88+
column[node_id] = 0
89+
else:
90+
max_col = 0
91+
for s in sources:
92+
c = column.get(s)
93+
if c is not None and c + 1 > max_col:
94+
max_col = c + 1
95+
column[node_id] = max_col
96+
97+
for dep in dependents[node_id]:
98+
in_degree[dep] -= 1
99+
if in_degree[dep] == 0:
100+
queue.append(dep)
101+
102+
# Cycle fallback: any unvisited node gets column = declaration index.
103+
for node_id in node_ids:
104+
if node_id not in visited:
105+
column[node_id] = index_by_id[node_id]
106+
107+
# Row assignment: per column, declaration order.
108+
rows_by_column: dict[int, list[str]] = {}
109+
for node_id in node_ids:
110+
c = column[node_id]
111+
rows_by_column.setdefault(c, []).append(node_id)
112+
113+
positions: dict[str, CytoscapePosition] = {}
114+
for c, ids in rows_by_column.items():
115+
for row, node_id in enumerate(ids):
116+
positions[node_id] = CytoscapePosition(x=c * COL_STRIDE, y=row * ROW_STRIDE)
117+
return positions

gxformat2/cytoscape/_render.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
CYTOSCAPE_JS_TEMPLATE = os.path.join(os.path.dirname(__file__), "cytoscape.html")
1010

1111

12-
def render_html(elements: CytoscapeElements) -> str:
12+
def render_html(elements: CytoscapeElements, layout: str = "preset") -> str:
1313
"""Return a standalone HTML page visualizing the workflow with Cytoscape.js.
1414
1515
The returned string is a complete HTML document suitable for writing
1616
to a file or embedding in a Jupyter notebook.
1717
"""
1818
with open(CYTOSCAPE_JS_TEMPLATE) as f:
1919
template = f.read()
20-
return string.Template(template).safe_substitute(elements=json.dumps(elements.to_list()))
20+
return string.Template(template).safe_substitute(
21+
elements=json.dumps(elements.to_list()),
22+
layout=json.dumps(layout),
23+
)

gxformat2/cytoscape/cytoscape.html

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,36 @@
55
<head>
66
<title>Galaxy Workflow</title>
77
<script src="https://unpkg.com/cytoscape@3.33.2/dist/cytoscape.min.js"></script>
8+
<script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
9+
<script src="https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
810
<script src="https://unpkg.com/@popperjs/core@2.11.8/dist/umd/popper.min.js"></script>
911
<script src="https://unpkg.com/cytoscape-popper@2.0.0/cytoscape-popper.js"></script>
1012
<script src="https://unpkg.com/tippy.js@6.3.7/dist/tippy.umd.min.js"></script>
1113
<link rel="stylesheet" href="https://unpkg.com/tippy.js@6.3.7/dist/tippy.css" />
1214
<script>
1315
document.addEventListener("DOMContentLoaded", function() {
16+
var requestedLayout = $layout;
17+
var dagreReady = (typeof cytoscapeDagre !== 'undefined');
18+
if (dagreReady && cytoscape.use) {
19+
try { cytoscape.use(cytoscapeDagre); } catch (e) { /* already registered */ }
20+
}
21+
var layoutConfig = (function(name) {
22+
if (name === 'dagre') {
23+
return dagreReady
24+
? { name: 'dagre', rankDir: 'LR', nodeSep: 40, rankSep: 80 }
25+
: { name: 'preset' };
26+
}
27+
if (name === 'breadthfirst') return { name: 'breadthfirst', directed: true, spacingFactor: 1.2 };
28+
if (name === 'grid') return { name: 'grid' };
29+
if (name === 'cose') return { name: 'cose' };
30+
if (name === 'random') return { name: 'random' };
31+
if (name === 'topological') return { name: 'preset' };
32+
return { name: 'preset' };
33+
})(requestedLayout);
1434
var cy = cytoscape({
1535
container: document.getElementById('cy'),
1636
elements: $elements,
17-
layout: {
18-
name: 'preset'
19-
},
37+
layout: layoutConfig,
2038
// so we can see the ids
2139
style: [
2240
{
@@ -46,6 +64,27 @@
4664
shape: 'round-rectangle',
4765
'background-color': '#2c3143'
4866
}
67+
},
68+
{
69+
selector: 'edge.mapover_1',
70+
style: { width: 4, 'line-color': '#5a8' }
71+
},
72+
{
73+
selector: 'edge.mapover_2',
74+
style: { width: 6, 'line-color': '#5a8' }
75+
},
76+
{
77+
selector: 'edge.mapover_3',
78+
style: { width: 8, 'line-color': '#5a8' }
79+
},
80+
{
81+
selector: 'edge.reduction',
82+
style: {
83+
'line-style': 'dashed',
84+
'line-color': '#a55',
85+
'target-arrow-shape': 'tee',
86+
'target-arrow-color': '#a55'
87+
}
4988
}
5089
]
5190
});
@@ -83,6 +122,14 @@
83122
} else {
84123
innerHTML += "Connected to input " + input;
85124
}
125+
let mapDepth = ele.data("map_depth");
126+
if (mapDepth) {
127+
let mapping = ele.data("mapping");
128+
innerHTML += "<p><i>Map depth:</i> " + mapDepth + (mapping ? " (" + mapping + ")" : "") + "</p>";
129+
}
130+
if (ele.data("reduction")) {
131+
innerHTML += "<p><i>Reduction:</i> list → multi-data</p>";
132+
}
86133
}
87134
content.innerHTML = innerHTML;
88135

0 commit comments

Comments
 (0)