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.
@@ -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