diagramboxes 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diagramboxes-0.4.0/PKG-INFO +14 -0
- diagramboxes-0.4.0/diagramboxes/__init__.py +22 -0
- diagramboxes-0.4.0/diagramboxes/elk.py +506 -0
- diagramboxes-0.4.0/diagramboxes/layout.py +2210 -0
- diagramboxes-0.4.0/diagramboxes/primitives.py +788 -0
- diagramboxes-0.4.0/diagramboxes/pyelk_layout.py +52 -0
- diagramboxes-0.4.0/diagramboxes/sugiyama.py +436 -0
- diagramboxes-0.4.0/diagramboxes/svg_canvas.py +646 -0
- diagramboxes-0.4.0/pyproject.toml +20 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: diagramboxes
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: UML/SysML block diagrams in your terminal (and SVG)
|
|
5
|
+
Requires-Python: >=3.10,<4.0
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Provides-Extra: pyelk
|
|
13
|
+
Requires-Dist: drawille
|
|
14
|
+
Requires-Dist: pyelk ; extra == "pyelk"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from diagramboxes.primitives import (
|
|
2
|
+
NONE, OPEN, TRIANGLE, DIAMOND, FILLED, DEFINITION, REDEFINITION, REFERENCE_SUBSETTING, PORTION, CIRCLE, UNOWNED,
|
|
3
|
+
SOLID, DASHED, ARROW_SIZE, COMMENT_FOLD, ROUNDED_RADIUS,
|
|
4
|
+
draw_line, draw_arrowhead, draw_relation, draw_polyline, draw_class_box,
|
|
5
|
+
draw_port_box, draw_comment_box, draw_view_box,
|
|
6
|
+
draw_start_node, draw_done_node, draw_terminate_node,
|
|
7
|
+
draw_fork_join_node, draw_decision_node,
|
|
8
|
+
draw_history_node, draw_entry_exit_point,
|
|
9
|
+
PORT_W, PORT_H,
|
|
10
|
+
)
|
|
11
|
+
from diagramboxes.layout import Diagram, Node, Edge, Port, Comment, View, StartNode, DoneNode, TerminateNode, \
|
|
12
|
+
ForkJoinNode, DecisionNode, \
|
|
13
|
+
InitialPseudostate, JunctionPseudostate, ChoicePseudostate, \
|
|
14
|
+
ForkPseudostate, JoinPseudostate, FinalState, TerminatePseudostate, \
|
|
15
|
+
HistoryPseudostate, EntryPoint, ExitPoint, StateNode
|
|
16
|
+
from diagramboxes.svg_canvas import SvgCanvas, svg_draw_edge, svg_draw_node, svg_draw_port, \
|
|
17
|
+
svg_draw_start_node, svg_draw_done_node, svg_draw_terminate_node, \
|
|
18
|
+
svg_draw_fork_join_node, svg_draw_decision_node, \
|
|
19
|
+
svg_draw_history_node, svg_draw_entry_exit_point
|
|
20
|
+
from diagramboxes.sugiyama import sugiyama_layout
|
|
21
|
+
from diagramboxes.elk import layout_with_elk
|
|
22
|
+
from diagramboxes.pyelk_layout import layout_with_pyelk
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""ELKjs integration — spawns Node.js subprocess for production layouts.
|
|
2
|
+
|
|
3
|
+
Bridges to the Eclipse Layout Kernel (ELK) layered algorithm via elkjs.
|
|
4
|
+
The pipeline:
|
|
5
|
+
|
|
6
|
+
1. ``to_elk_json(diagram)`` — converts Diagram → ELK JSON
|
|
7
|
+
2. ``elk_layout(graph)`` — spawns ``node -e``, pipes JSON via stdin
|
|
8
|
+
3. ``apply_elk_result(diagram, result)`` — maps positions/waypoints back
|
|
9
|
+
|
|
10
|
+
Layout options configured (see layout_with_elk for defaults):
|
|
11
|
+
- elk.algorithm: layered, elk.direction: DOWN
|
|
12
|
+
- elk.edgeRouting: ORTHOGONAL
|
|
13
|
+
- Port constraints: FIXED_SIDE, alignment: CENTER
|
|
14
|
+
|
|
15
|
+
Requires
|
|
16
|
+
--------
|
|
17
|
+
npm install elkjs
|
|
18
|
+
node --version >= 16
|
|
19
|
+
|
|
20
|
+
See also
|
|
21
|
+
--------
|
|
22
|
+
sugiyama.py : pure-Python alternative (~400 lines, no dependencies)
|
|
23
|
+
layout.py : homegrown orthogonal router (used by default)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import subprocess
|
|
28
|
+
import os
|
|
29
|
+
from collections import defaultdict
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
_ELK_BOOTSTRAP = """
|
|
33
|
+
const ELK = require('elkjs');
|
|
34
|
+
const elk = new ELK();
|
|
35
|
+
let data = '';
|
|
36
|
+
process.stdin.on('data', d => data += d);
|
|
37
|
+
process.stdin.on('end', () => {
|
|
38
|
+
elk.layout(JSON.parse(data))
|
|
39
|
+
.then(r => { process.stdout.write(JSON.stringify(r)); process.exit(0); })
|
|
40
|
+
.catch(e => { process.stderr.write(e.message); process.exit(1); });
|
|
41
|
+
});
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def elk_layout(graph_dict, node_modules=None, timeout=30):
|
|
46
|
+
"""Send a graph JSON dict to elkjs, return the result dict.
|
|
47
|
+
|
|
48
|
+
graph_dict should follow ELK input format:
|
|
49
|
+
{ "id": "root", "children": [...], "edges": [...], "layoutOptions": {...} }
|
|
50
|
+
"""
|
|
51
|
+
if node_modules:
|
|
52
|
+
env = os.environ.copy()
|
|
53
|
+
env['NODE_PATH'] = str(Path(node_modules).resolve())
|
|
54
|
+
else:
|
|
55
|
+
env = os.environ.copy()
|
|
56
|
+
|
|
57
|
+
proc = subprocess.run(
|
|
58
|
+
['node', '-e', _ELK_BOOTSTRAP],
|
|
59
|
+
input=json.dumps(graph_dict),
|
|
60
|
+
capture_output=True, text=True, timeout=timeout,
|
|
61
|
+
env=env,
|
|
62
|
+
)
|
|
63
|
+
if proc.returncode != 0:
|
|
64
|
+
raise RuntimeError(f'elkjs failed: {proc.stderr}')
|
|
65
|
+
return json.loads(proc.stdout)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _elk_id(obj):
|
|
69
|
+
"""Return a stable, unique ELK node id for a Node, View, or Comment.
|
|
70
|
+
|
|
71
|
+
Node and View expose ``.name`` (assumed unique); Comment exposes only
|
|
72
|
+
``.text`` and has no name, so a unique id is derived from ``id()``.
|
|
73
|
+
"""
|
|
74
|
+
name = getattr(obj, 'name', None)
|
|
75
|
+
if name:
|
|
76
|
+
return name
|
|
77
|
+
return f'comment_{id(obj)}'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _elk_label(obj):
|
|
81
|
+
"""Return the label text for a Node, View, or Comment."""
|
|
82
|
+
if not hasattr(obj, 'name'):
|
|
83
|
+
# Comment — just its text
|
|
84
|
+
return getattr(obj, 'text', '')
|
|
85
|
+
label_parts = []
|
|
86
|
+
for s in getattr(obj, 'stereotypes', []) or []:
|
|
87
|
+
label_parts.append(f'\u00ab{s}\u00bb')
|
|
88
|
+
label_parts.append(obj.name)
|
|
89
|
+
return '\n'.join(label_parts)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_SIDE_TO_ELK = {
|
|
93
|
+
'left': 'WEST',
|
|
94
|
+
'right': 'EAST',
|
|
95
|
+
'top': 'NORTH',
|
|
96
|
+
'bottom': 'SOUTH',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _user_port_pos(side, offset, nw, nh):
|
|
101
|
+
"""ELK port (x, y) relative to a node so the routed edge endpoint lands on
|
|
102
|
+
the same place the renderer draws the port box (see ``Port.update_pos``).
|
|
103
|
+
|
|
104
|
+
Port boxes are 8x8 and sit *outside* the node boundary on the chosen side;
|
|
105
|
+
the port centre is offset 4 px inwards from the port's outer edge.
|
|
106
|
+
|
|
107
|
+
A ``None`` offset falls back to 0.5 (middle of the side).
|
|
108
|
+
"""
|
|
109
|
+
off = 0.5 if offset is None else float(offset)
|
|
110
|
+
if side == 'left':
|
|
111
|
+
return -8, int(nh * off) - 4
|
|
112
|
+
if side == 'right':
|
|
113
|
+
return nw, int(nh * off) - 4
|
|
114
|
+
if side == 'top':
|
|
115
|
+
return int(nw * off) - 4, -8
|
|
116
|
+
if side == 'bottom':
|
|
117
|
+
return int(nw * off) - 4, nh
|
|
118
|
+
return 0, 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _distribute_sides(count, primary, avoid=None):
|
|
122
|
+
"""Distribute `count` ports across sides, starting with `primary`.
|
|
123
|
+
|
|
124
|
+
Returns a list of side names, one per port.
|
|
125
|
+
"""
|
|
126
|
+
sides = ['SOUTH', 'EAST', 'WEST', 'NORTH']
|
|
127
|
+
if primary in sides:
|
|
128
|
+
sides.remove(primary)
|
|
129
|
+
sides.insert(0, primary)
|
|
130
|
+
if avoid and avoid in sides:
|
|
131
|
+
sides.remove(avoid)
|
|
132
|
+
|
|
133
|
+
result = []
|
|
134
|
+
for i in range(count):
|
|
135
|
+
result.append(sides[i % len(sides)])
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def to_elk_json(diagram):
|
|
140
|
+
"""Convert a drawille Diagram to ELK JSON.
|
|
141
|
+
|
|
142
|
+
Maps every addressable element — regular nodes, comments, and views —
|
|
143
|
+
to ELK child nodes, and edges (including those anchored on a Comment
|
|
144
|
+
or View) to ELK edges with port references. Arrow styles are passed
|
|
145
|
+
through as custom properties for post-processing.
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
(graph_dict, id_map) : tuple
|
|
150
|
+
``graph_dict`` is the ELK input JSON; ``id_map`` maps each ELK
|
|
151
|
+
child id back to the originating Diagram object so that
|
|
152
|
+
:func:`apply_elk_result` can reposition it.
|
|
153
|
+
"""
|
|
154
|
+
children = []
|
|
155
|
+
edges = []
|
|
156
|
+
port_index = {}
|
|
157
|
+
id_map = {} # elk_id -> Diagram object (Node / Comment / View)
|
|
158
|
+
|
|
159
|
+
# Every element that can be an edge endpoint or needs positioning.
|
|
160
|
+
elements = list(diagram.nodes) + list(diagram.comments) + list(diagram.views)
|
|
161
|
+
|
|
162
|
+
# Group edges per node/role. Edges with an explicit user Port
|
|
163
|
+
# (``source_port``/``target_port``) are routed through that port and
|
|
164
|
+
# keep its side; edges without one fall back to the legacy
|
|
165
|
+
# auto-distribution, which picks sides not already claimed by the
|
|
166
|
+
# explicit ports on the same node.
|
|
167
|
+
by_eid_out_auto = defaultdict(list) # eid -> [edges with no source_port]
|
|
168
|
+
by_eid_in_auto = defaultdict(list) # eid -> [edges with no target_port]
|
|
169
|
+
explicit_ports = {} # id(user_port) -> (node_eid, port, [(role, edge), ...])
|
|
170
|
+
|
|
171
|
+
for e in diagram.edges:
|
|
172
|
+
sid = _elk_id(e.source)
|
|
173
|
+
tid = _elk_id(e.target)
|
|
174
|
+
sp = getattr(e, 'source_port', None)
|
|
175
|
+
tp = getattr(e, 'target_port', None)
|
|
176
|
+
if sp is not None:
|
|
177
|
+
key = id(sp)
|
|
178
|
+
rec = explicit_ports.get(key)
|
|
179
|
+
if rec is None:
|
|
180
|
+
rec = explicit_ports[key] = [sid, sp, []]
|
|
181
|
+
rec[1] = sp
|
|
182
|
+
rec[2].append(('source', e))
|
|
183
|
+
else:
|
|
184
|
+
by_eid_out_auto[sid].append(e)
|
|
185
|
+
if tp is not None:
|
|
186
|
+
key = id(tp)
|
|
187
|
+
rec = explicit_ports.get(key)
|
|
188
|
+
if rec is None:
|
|
189
|
+
rec = explicit_ports[key] = [tid, tp, []]
|
|
190
|
+
rec[2].append(('target', e))
|
|
191
|
+
else:
|
|
192
|
+
by_eid_in_auto[tid].append(e)
|
|
193
|
+
|
|
194
|
+
for n in elements:
|
|
195
|
+
eid = _elk_id(n)
|
|
196
|
+
id_map[eid] = n
|
|
197
|
+
node_w = n.w if n.w else 100
|
|
198
|
+
node_h = n.h if n.h else 50
|
|
199
|
+
node_ports = []
|
|
200
|
+
|
|
201
|
+
# 1) Explicit user ports on this node. Multiple ports may share a
|
|
202
|
+
# side; auto-distribute them along the side if any has offset=None,
|
|
203
|
+
# otherwise honour each port's proportional offset (matching what
|
|
204
|
+
# ``Port.update_pos`` will render).
|
|
205
|
+
explicit_by_side = defaultdict(list) # elk_side -> [(port, [(role, edge), ...])]
|
|
206
|
+
for (port_eid, port, refs) in explicit_ports.values():
|
|
207
|
+
if port_eid != eid:
|
|
208
|
+
continue
|
|
209
|
+
elk_side = _SIDE_TO_ELK.get(port.side, 'WEST')
|
|
210
|
+
explicit_by_side[elk_side].append((port, refs))
|
|
211
|
+
|
|
212
|
+
for elk_side, items in explicit_by_side.items():
|
|
213
|
+
n_items = len(items)
|
|
214
|
+
auto_dist = any(p.offset is None for p, _ in items)
|
|
215
|
+
for i, (port, refs) in enumerate(items):
|
|
216
|
+
pid = f'{eid}_p{id(port)}'
|
|
217
|
+
if auto_dist:
|
|
218
|
+
# Spread evenly along the side using the same rule as
|
|
219
|
+
# ``_update_port_positions`` so the rendered marker lands
|
|
220
|
+
# on the same spot the edge endpoint snaps to.
|
|
221
|
+
if elk_side in ('WEST', 'EAST'):
|
|
222
|
+
px = -8 if elk_side == 'WEST' else node_w
|
|
223
|
+
py = (i + 1) * node_h // (n_items + 1) - 4
|
|
224
|
+
else: # NORTH / SOUTH
|
|
225
|
+
py = -8 if elk_side == 'NORTH' else node_h
|
|
226
|
+
px = (i + 1) * node_w // (n_items + 1) - 4
|
|
227
|
+
else:
|
|
228
|
+
side_name = port.side
|
|
229
|
+
px, py = _user_port_pos(side_name, port.offset, node_w, node_h)
|
|
230
|
+
port_props = {'port.side': elk_side, 'port.index': str(i)}
|
|
231
|
+
node_ports.append({
|
|
232
|
+
'id': pid, 'width': 8, 'height': 8,
|
|
233
|
+
'x': px, 'y': py,
|
|
234
|
+
'properties': port_props,
|
|
235
|
+
})
|
|
236
|
+
for role, e in refs:
|
|
237
|
+
port_index[(eid, e, role)] = pid
|
|
238
|
+
|
|
239
|
+
# 2) Auto ports for edges that did not specify an explicit port.
|
|
240
|
+
# Avoid sides already used by explicit ports on this node.
|
|
241
|
+
claimed = set(explicit_by_side.keys())
|
|
242
|
+
out_needed = by_eid_out_auto.get(eid, [])
|
|
243
|
+
in_needed = by_eid_in_auto.get(eid, [])
|
|
244
|
+
|
|
245
|
+
def _auto_geometry(side, count, idx):
|
|
246
|
+
if side == 'SOUTH':
|
|
247
|
+
return (idx + 1) * node_w // (count + 1) - 4, node_h
|
|
248
|
+
if side == 'NORTH':
|
|
249
|
+
return (idx + 1) * node_w // (count + 1) - 4, -8
|
|
250
|
+
if side == 'EAST':
|
|
251
|
+
return node_w, (idx + 1) * node_h // (count + 1) - 4
|
|
252
|
+
if side == 'WEST':
|
|
253
|
+
return -8, (idx + 1) * node_h // (count + 1) - 4
|
|
254
|
+
return 0, 0
|
|
255
|
+
|
|
256
|
+
# Local helper that names the loop variable consistently.
|
|
257
|
+
def _add_auto(role, edges, primary_side, avoid_side):
|
|
258
|
+
sides = _distribute_sides(
|
|
259
|
+
len(edges), primary_side,
|
|
260
|
+
avoid=avoid_side if avoid_side else None,
|
|
261
|
+
)
|
|
262
|
+
# Skip sides already used by explicit ports on this node by
|
|
263
|
+
# rotating to the next available side in the default cycle.
|
|
264
|
+
pick = [s for s in sides if s not in claimed] or sides
|
|
265
|
+
for idx, e in enumerate(edges):
|
|
266
|
+
side = pick[idx % len(pick)]
|
|
267
|
+
pid = f'{eid}_{role}_{idx}'
|
|
268
|
+
port_index[(eid, e, role)] = pid
|
|
269
|
+
port_props = {'port.side': side, 'port.index': str(idx)}
|
|
270
|
+
gx, gy = _auto_geometry(side, len(edges), idx)
|
|
271
|
+
node_ports.append({
|
|
272
|
+
'id': pid, 'width': 8, 'height': 8,
|
|
273
|
+
'x': gx, 'y': gy,
|
|
274
|
+
'properties': port_props,
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
if out_needed:
|
|
278
|
+
_add_auto('source', out_needed, 'SOUTH',
|
|
279
|
+
'NORTH' if in_needed else None)
|
|
280
|
+
if in_needed:
|
|
281
|
+
_add_auto('target', in_needed, 'NORTH',
|
|
282
|
+
'SOUTH' if out_needed else None)
|
|
283
|
+
|
|
284
|
+
child = {
|
|
285
|
+
'id': eid,
|
|
286
|
+
'width': n.w if n.w else 100,
|
|
287
|
+
'height': n.h if n.h else 50,
|
|
288
|
+
'labels': [{'text': _elk_label(n), 'properties': {'label.placement': 'INSIDE'}}],
|
|
289
|
+
}
|
|
290
|
+
if node_ports:
|
|
291
|
+
child['ports'] = node_ports
|
|
292
|
+
children.append(child)
|
|
293
|
+
|
|
294
|
+
# Build edge entries
|
|
295
|
+
for e in diagram.edges:
|
|
296
|
+
sid = _elk_id(e.source)
|
|
297
|
+
tid = _elk_id(e.target)
|
|
298
|
+
src_pid = port_index.get((sid, e, 'source'), sid)
|
|
299
|
+
tgt_pid = port_index.get((tid, e, 'target'), tid)
|
|
300
|
+
|
|
301
|
+
edge = {
|
|
302
|
+
'id': f'{sid}__{tid}__{id(e)}',
|
|
303
|
+
'sources': [src_pid],
|
|
304
|
+
'targets': [tgt_pid],
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
# Pass through our styling for post-processing
|
|
308
|
+
props = {}
|
|
309
|
+
if e.source_style:
|
|
310
|
+
props['source_style'] = e.source_style
|
|
311
|
+
if e.target_style:
|
|
312
|
+
props['target_style'] = e.target_style
|
|
313
|
+
if e.line_style:
|
|
314
|
+
props['line_style'] = e.line_style
|
|
315
|
+
if e.label:
|
|
316
|
+
props['edge_label'] = e.label
|
|
317
|
+
edge['labels'] = [{
|
|
318
|
+
'text': e.label,
|
|
319
|
+
'properties': {'label.placement': 'CENTER'}
|
|
320
|
+
}]
|
|
321
|
+
if props:
|
|
322
|
+
edge['properties'] = props
|
|
323
|
+
|
|
324
|
+
edges.append(edge)
|
|
325
|
+
|
|
326
|
+
# Layout options for UML/SysML-style block diagrams
|
|
327
|
+
layout_options = {
|
|
328
|
+
'elk.algorithm': 'layered',
|
|
329
|
+
'elk.direction': 'DOWN',
|
|
330
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
331
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '60',
|
|
332
|
+
'elk.spacing.nodeNode': '20',
|
|
333
|
+
'elk.spacing.edgeNode': '15',
|
|
334
|
+
'elk.spacing.portPort': '15',
|
|
335
|
+
'elk.layered.nodePlacement.strategy': 'SIMPLE',
|
|
336
|
+
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
|
337
|
+
'elk.portConstraints': 'FIXED_POS',
|
|
338
|
+
'elk.portAlignment.default': 'CENTER',
|
|
339
|
+
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
graph = {
|
|
343
|
+
'id': 'root',
|
|
344
|
+
'layoutOptions': layout_options,
|
|
345
|
+
'children': children,
|
|
346
|
+
'edges': edges,
|
|
347
|
+
}
|
|
348
|
+
return graph, id_map
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _snap_to_node(wp, node):
|
|
352
|
+
"""Move a waypoint just outside *node* to the node's edge boundary.
|
|
353
|
+
|
|
354
|
+
ELK places edge endpoints at the port center, which can be several
|
|
355
|
+
pixels off the node boundary. This projects the waypoint onto the
|
|
356
|
+
nearest node edge so arrowheads sit flush.
|
|
357
|
+
"""
|
|
358
|
+
x, y = wp
|
|
359
|
+
nx1, ny1 = node.x, node.y
|
|
360
|
+
nx2, ny2 = node.x + node.w, node.y + node.h
|
|
361
|
+
|
|
362
|
+
if x < nx1:
|
|
363
|
+
return (nx1, y)
|
|
364
|
+
if x > nx2:
|
|
365
|
+
return (nx2, y)
|
|
366
|
+
if y < ny1:
|
|
367
|
+
return (x, ny1)
|
|
368
|
+
if y > ny2:
|
|
369
|
+
return (x, ny2)
|
|
370
|
+
return wp
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def apply_elk_result(diagram, result, id_map=None):
|
|
374
|
+
"""Update Diagram element positions and edge waypoints from ELK result.
|
|
375
|
+
|
|
376
|
+
Parameters
|
|
377
|
+
----------
|
|
378
|
+
diagram : Diagram
|
|
379
|
+
result : dict
|
|
380
|
+
ELK layout output.
|
|
381
|
+
id_map : dict, optional
|
|
382
|
+
Mapping ``{elk_id: element}`` as returned by :func:`to_elk_json`.
|
|
383
|
+
When provided, every element (nodes, comments, views) is positioned.
|
|
384
|
+
When omitted, falls back to name-based lookup over ``diagram.nodes``
|
|
385
|
+
only (legacy behaviour).
|
|
386
|
+
"""
|
|
387
|
+
# Build lookup: node id → ELK result node
|
|
388
|
+
elk_nodes = {}
|
|
389
|
+
for child in result.get('children', []):
|
|
390
|
+
elk_nodes[child['id']] = child
|
|
391
|
+
|
|
392
|
+
if id_map is not None:
|
|
393
|
+
for eid, obj in id_map.items():
|
|
394
|
+
if eid in elk_nodes:
|
|
395
|
+
en = elk_nodes[eid]
|
|
396
|
+
obj.x = int(en['x'])
|
|
397
|
+
obj.y = int(en['y'])
|
|
398
|
+
obj.w = int(en['width'])
|
|
399
|
+
obj.h = int(en['height'])
|
|
400
|
+
else:
|
|
401
|
+
for n in diagram.nodes:
|
|
402
|
+
if n.name in elk_nodes:
|
|
403
|
+
en = elk_nodes[n.name]
|
|
404
|
+
n.x = int(en['x'])
|
|
405
|
+
n.y = int(en['y'])
|
|
406
|
+
n.w = int(en['width'])
|
|
407
|
+
n.h = int(en['height'])
|
|
408
|
+
|
|
409
|
+
# Re-position port markers so explicit ``source_port``/``target_port``
|
|
410
|
+
# references resolve to the same spot we'll snap the edge endpoints to.
|
|
411
|
+
update_ports = getattr(diagram, '_update_port_positions', None)
|
|
412
|
+
if callable(update_ports):
|
|
413
|
+
update_ports()
|
|
414
|
+
|
|
415
|
+
elk_edges = {}
|
|
416
|
+
for edge in result.get('edges', []):
|
|
417
|
+
elk_edges[edge['id']] = edge
|
|
418
|
+
|
|
419
|
+
edge_id_map = {}
|
|
420
|
+
for e in diagram.edges:
|
|
421
|
+
eid = f'{_elk_id(e.source)}__{_elk_id(e.target)}__{id(e)}'
|
|
422
|
+
edge_id_map[eid] = e
|
|
423
|
+
|
|
424
|
+
# Update edge waypoints
|
|
425
|
+
for eid, ee in elk_edges.items():
|
|
426
|
+
if eid not in edge_id_map:
|
|
427
|
+
continue
|
|
428
|
+
e = edge_id_map[eid]
|
|
429
|
+
|
|
430
|
+
waypoints = []
|
|
431
|
+
for section in ee.get('sections', []):
|
|
432
|
+
sp = section.get('startPoint', {})
|
|
433
|
+
waypoints.append((int(sp['x']), int(sp['y'])))
|
|
434
|
+
for bp in section.get('bendPoints', []):
|
|
435
|
+
waypoints.append((int(bp['x']), int(bp['y'])))
|
|
436
|
+
ep = section.get('endPoint', {})
|
|
437
|
+
waypoints.append((int(ep['x']), int(ep['y'])))
|
|
438
|
+
|
|
439
|
+
if waypoints:
|
|
440
|
+
# Clean consecutive duplicates
|
|
441
|
+
cleaned = [waypoints[0]]
|
|
442
|
+
for p in waypoints[1:]:
|
|
443
|
+
if p != cleaned[-1]:
|
|
444
|
+
cleaned.append(p)
|
|
445
|
+
|
|
446
|
+
# Snap first/last waypoint to the relevant port centre for
|
|
447
|
+
# edges that explicitly named one — this is what the renderer
|
|
448
|
+
# actually draws, so the arrowhead sits flush on the marker
|
|
449
|
+
# instead of floating off where ELK routed the line. For
|
|
450
|
+
# port-less edges fall back to the node-boundary snap.
|
|
451
|
+
sp = getattr(e, 'source_port', None)
|
|
452
|
+
tp = getattr(e, 'target_port', None)
|
|
453
|
+
if sp is not None:
|
|
454
|
+
cleaned[0] = (sp.cx, sp.cy)
|
|
455
|
+
elif len(cleaned) >= 1:
|
|
456
|
+
cleaned[0] = _snap_to_node(cleaned[0], e.source)
|
|
457
|
+
if tp is not None:
|
|
458
|
+
cleaned[-1] = (tp.cx, tp.cy)
|
|
459
|
+
else:
|
|
460
|
+
cleaned[-1] = _snap_to_node(cleaned[-1], e.target)
|
|
461
|
+
|
|
462
|
+
e.waypoints = cleaned
|
|
463
|
+
|
|
464
|
+
# Post-process: offset overlapping arrowheads at shared source/target nodes.
|
|
465
|
+
# Some layout engines (notably pyelk) may route multiple edges from the same
|
|
466
|
+
# node to the same start point, causing arrowheads to overlap.
|
|
467
|
+
source_starts = {}
|
|
468
|
+
target_ends = {}
|
|
469
|
+
for e in diagram.edges:
|
|
470
|
+
if e.waypoints:
|
|
471
|
+
src_key = (id(e.source), e.waypoints[0])
|
|
472
|
+
source_starts.setdefault(src_key, []).append(e)
|
|
473
|
+
tgt_key = (id(e.target), e.waypoints[-1])
|
|
474
|
+
target_ends.setdefault(tgt_key, []).append(e)
|
|
475
|
+
|
|
476
|
+
# Diamond width is ARROW_SIZE * 0.7; use 2x that for separation
|
|
477
|
+
from diagramboxes.primitives import ARROW_SIZE
|
|
478
|
+
diamond_width = ARROW_SIZE * 0.7
|
|
479
|
+
offset = diamond_width * 2
|
|
480
|
+
for (node_id, pt), edges in source_starts.items():
|
|
481
|
+
if len(edges) > 1:
|
|
482
|
+
for i, e in enumerate(edges):
|
|
483
|
+
shift = (i - (len(edges) - 1) / 2) * offset
|
|
484
|
+
ox, oy = e.waypoints[0]
|
|
485
|
+
e.waypoints[0] = (int(ox + shift), oy)
|
|
486
|
+
if len(e.waypoints) >= 2:
|
|
487
|
+
nx, ny = e.waypoints[1]
|
|
488
|
+
e.waypoints[1] = (int(nx + shift), ny)
|
|
489
|
+
|
|
490
|
+
for (node_id, pt), edges in target_ends.items():
|
|
491
|
+
if len(edges) > 1:
|
|
492
|
+
for i, e in enumerate(edges):
|
|
493
|
+
shift = (i - (len(edges) - 1) / 2) * offset
|
|
494
|
+
ox, oy = e.waypoints[-1]
|
|
495
|
+
e.waypoints[-1] = (int(ox + shift), oy)
|
|
496
|
+
if len(e.waypoints) >= 2:
|
|
497
|
+
px, py = e.waypoints[-2]
|
|
498
|
+
e.waypoints[-2] = (int(px + shift), py)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def layout_with_elk(diagram, node_modules=None):
|
|
502
|
+
"""Full pipeline: convert Diagram → ELK JSON → run elkjs → apply results."""
|
|
503
|
+
graph, id_map = to_elk_json(diagram)
|
|
504
|
+
result = elk_layout(graph, node_modules=node_modules)
|
|
505
|
+
apply_elk_result(diagram, result, id_map=id_map)
|
|
506
|
+
return diagram
|