sysatlas 0.2.0__py3-none-any.whl

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.
Files changed (50) hide show
  1. sysatlas/LLM_GUIDE.md +151 -0
  2. sysatlas/__init__.py +50 -0
  3. sysatlas/_bpmn_layout.py +157 -0
  4. sysatlas/_bpmn_render.py +154 -0
  5. sysatlas/_connectors.py +28 -0
  6. sysatlas/_hub_layout.py +111 -0
  7. sysatlas/_layout.py +883 -0
  8. sysatlas/_ontology/__init__.py +1 -0
  9. sysatlas/_ontology/architecture.py +117 -0
  10. sysatlas/_ontology/bpmn.py +101 -0
  11. sysatlas/_ontology/er.py +54 -0
  12. sysatlas/_ontology/iso42010.py +138 -0
  13. sysatlas/_ontology/model_kind.py +34 -0
  14. sysatlas/_ontology/model_kinds.py +82 -0
  15. sysatlas/_ontology/qualities.py +63 -0
  16. sysatlas/_ontology/sequence.py +71 -0
  17. sysatlas/_ontology/state_machine.py +58 -0
  18. sysatlas/_ontology/trace.py +99 -0
  19. sysatlas/_ontology/tree.py +64 -0
  20. sysatlas/_ontology/uml_class.py +69 -0
  21. sysatlas/_place.py +337 -0
  22. sysatlas/_reflection/__init__.py +0 -0
  23. sysatlas/_reflection/hints.py +38 -0
  24. sysatlas/_reflection/layers.py +38 -0
  25. sysatlas/_reflection/merge.py +77 -0
  26. sysatlas/_reflection/parser.py +118 -0
  27. sysatlas/_reflection/parser_rust.py +148 -0
  28. sysatlas/_reflection/reflection.py +208 -0
  29. sysatlas/_reflection/resolve.py +20 -0
  30. sysatlas/_render.py +652 -0
  31. sysatlas/_route.py +758 -0
  32. sysatlas/_sequence_layout.py +70 -0
  33. sysatlas/_sequence_render.py +181 -0
  34. sysatlas/_trace_matrix.py +126 -0
  35. sysatlas/_tree_layout.py +102 -0
  36. sysatlas/_tree_render.py +135 -0
  37. sysatlas/_vendor.py +35 -0
  38. sysatlas/bpmn_map.py +89 -0
  39. sysatlas/class_map.py +225 -0
  40. sysatlas/er_map.py +172 -0
  41. sysatlas/sequence_map.py +90 -0
  42. sysatlas/state_map.py +194 -0
  43. sysatlas/sysatlas.json +73 -0
  44. sysatlas/system.py +241 -0
  45. sysatlas/system_map.py +114 -0
  46. sysatlas/tree_map.py +60 -0
  47. sysatlas-0.2.0.dist-info/METADATA +131 -0
  48. sysatlas-0.2.0.dist-info/RECORD +50 -0
  49. sysatlas-0.2.0.dist-info/WHEEL +4 -0
  50. sysatlas-0.2.0.dist-info/licenses/LICENSE +201 -0
sysatlas/LLM_GUIDE.md ADDED
@@ -0,0 +1,151 @@
1
+ # sysatlas — LLM guide
2
+
3
+ You are a coding assistant working in a project that has `sysatlas`
4
+ installed. This file tells you how to use it. Read all of it before
5
+ writing or editing any sysatlas-related code.
6
+
7
+ ## What sysatlas is
8
+
9
+ A Python library for **interactive architecture diagrams**. Diagrams
10
+ are defined in code (Pydantic-backed builders) and rendered to a
11
+ self-contained HTML file with an embedded draw.io viewer.
12
+
13
+ Two halves of the workflow exist in the same library:
14
+
15
+ - **Forward flow** — a human (or you) writes builder calls to describe
16
+ a system that does not exist yet, or that exists only as a design.
17
+ - **Backward flow** — `sysatlas.reflect(path)` reads existing Python
18
+ code and produces a diagram from its real structure.
19
+
20
+ The realistic case is a mix: structure comes from reflection,
21
+ semantics (qualities, traces, viewpoints, stakeholders) come from a
22
+ human-authored "annotation overlay" that gets merged in.
23
+
24
+ ## Your responsibility in this project
25
+
26
+ After you add, remove, or rename a module, class, or component, the
27
+ diagrams under `docs/reflection/` (or wherever this project keeps
28
+ them — check the project README) are stale. Regenerate them. The
29
+ canonical commands:
30
+
31
+ ```python
32
+ import sysatlas
33
+ r = sysatlas.reflect("path/to/source/root")
34
+ m = r.to_system_map(title="…")
35
+ m.save("docs/reflection/module-map.html")
36
+ ```
37
+
38
+ If the project has a hints file at the source root, `reflect()` will
39
+ pick it up automatically — do not edit hints unless the user asks.
40
+ Lookup order: `sysatlas.json` (always supported), then
41
+ `sysatlas.yaml` / `sysatlas.yml` (only if PyYAML is installed).
42
+
43
+ If the project has an "annotation overlay" `.py` file (typical
44
+ location: `docs/reflection/_overlay.py`), call `r.merge_with(overlay)`
45
+ before `save()` so user-authored qualities and traces are preserved.
46
+
47
+ ## Public API — the only symbols you should use
48
+
49
+ ```python
50
+ import sysatlas
51
+
52
+ sysatlas.SystemMap # one architecture diagram
53
+ sysatlas.System # multi-view Architecture Description (ISO 42010)
54
+ sysatlas.TreeMap # tree / org-chart / mindmap
55
+ sysatlas.SequenceMap # UML sequence diagram
56
+ sysatlas.ERMap # Entity-Relationship diagram
57
+ sysatlas.StateMap # state machine / state chart
58
+ sysatlas.ClassMap # UML class diagram
59
+ sysatlas.BPMNMap # BPMN process diagram
60
+ sysatlas.reflect(path) # reverse flow: code → Reflection → SystemMap
61
+ sysatlas.llm_guide() # returns this file as a string
62
+ ```
63
+
64
+ Do **not** import from `sysatlas._*` (private modules — `_ontology`,
65
+ `_layout`, `_route`, etc.). The Pydantic schemas under
66
+ `sysatlas._ontology` are the source of truth for field names, but you
67
+ should reach them only through the builder methods.
68
+
69
+ Each `*Map` is fluent and follows the same shape: chainable methods,
70
+ `.diagram` for the validated Pydantic instance, `.show()` / `.save()`
71
+ to render. For the per-kind **builder methods** check `docs/builders.md`
72
+ and the matching `docs/ontology/<kind>.md` § Builder. The
73
+ `sysatlas/_ontology/<kind>.py` files are the source of truth for
74
+ *schema field names*, not for builder method names.
75
+
76
+ ## Forward flow — minimal example
77
+
78
+ ```python
79
+ import sysatlas
80
+
81
+ m = sysatlas.SystemMap(title="Storefront")
82
+ m.group("services", color="#dcfce7")
83
+ m.add_component("api", group="services", layer="services", tech="Envoy")
84
+ m.add_component("catalog", group="services", layer="services", tech="Python")
85
+ m.connect("api", "catalog", label="REST")
86
+ m.save("storefront.html")
87
+ ```
88
+
89
+ The builder is fluent. `layer` is a free-form string — the ontology
90
+ does not enforce a fixed set. Recommended defaults for the default
91
+ (`layered`) strategy are `edge`, `services`, `data`, `infra`,
92
+ `external`; pick whatever vocabulary fits the diagram. Note that
93
+ `strategy="hub"` reserves five layer names with specific placement
94
+ meaning (`interfaces`, `write`, `hub`, `read`, `external`) — any
95
+ other layer under the hub strategy is treated as `external`.
96
+ `tech=` is metadata-only and is not rendered — if you want the tech
97
+ visible, put it in `label=`.
98
+
99
+ ## Multi-view (`System`) — when one diagram is too dense
100
+
101
+ Use `System` when you have more than ~10 components or more than one
102
+ clear bounded context. Each view is a separate `SystemMap`. Components
103
+ referenced across views auto-render as stub nodes.
104
+
105
+ ```python
106
+ s = sysatlas.System(title="E-Commerce")
107
+ s.viewpoint("container", model_kinds=["architecture"])
108
+ sf = s.architecture_model("storefront")
109
+ sf.add_component("Cart").add_component("Catalog").connect("Cart", "Catalog")
110
+ pm = s.architecture_model("payments")
111
+ pm.add_component("Payments")
112
+ s.view("storefront-view", viewpoint="container", models=["storefront"])
113
+ s.view("payments-view", viewpoint="container", models=["payments"])
114
+ s.trace("storefront#Cart", "payments#Payments", kind="depends_on")
115
+ s.save("system.html")
116
+ ```
117
+
118
+ ## Backward flow — reflection
119
+
120
+ ```python
121
+ r = sysatlas.reflect("src/") # AST-only, never imports the code
122
+ r.exclude("tests/*", "_vendor.py")
123
+ m = r.to_system_map(title="src internals")
124
+ m.save("docs/reflection/module-map.html")
125
+ ```
126
+
127
+ `reflect()` does not execute the target code. Dynamic imports are
128
+ invisible to it — accept the trade-off, do not try to work around it
129
+ by adding `importlib` calls.
130
+
131
+ ## Hard rules
132
+
133
+ - Use the public symbols above; do not import from `sysatlas._*`.
134
+ - Use Pydantic; never `@dataclass`.
135
+ - Field names must match the ontology exactly. If unsure, check the
136
+ matching Pydantic class under `sysatlas/_ontology/` (read-only):
137
+ `architecture.py`, `sequence.py`, `er.py`, `state_machine.py`,
138
+ `uml_class.py`, `bpmn.py`, `tree.py`.
139
+ - One view = 5–10 components. If a diagram is bigger, split it across
140
+ views with `System`, do not fight the layout engine.
141
+ - After any structural code change, regenerate the affected reflection
142
+ diagrams. This is your job, not the user's.
143
+ - Do not invent fields. The ontology is closed (`ConfigDict(extra="forbid")`
144
+ on most models).
145
+
146
+ ## Where to find more
147
+
148
+ - Full builder reference: `docs/builders.md` in the source repo.
149
+ - Per-ontology specs: `docs/ontology/` in the source repo.
150
+ - Design principles (bounded complexity, multi-view): `docs/design-principles.md`.
151
+ - This guide on disk: `sysatlas.llm_guide_path()`.
sysatlas/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """sysatlas — interactive architecture diagrams in two directions.
2
+
3
+ Forward flow: write builder calls (`SystemMap`, `System`, `TreeMap`)
4
+ to describe a system as a design. Backward flow: `sysatlas.reflect(path)`
5
+ reads existing Python and produces the same diagram types from real code.
6
+
7
+ If you are an LLM helping a user with this library, call
8
+ `sysatlas.llm_guide()` first — it is the canonical usage contract.
9
+ """
10
+
11
+ from pathlib import Path
12
+
13
+ from sysatlas._reflection.reflection import Reflection, reflect, reflect_rust
14
+ from sysatlas.bpmn_map import BPMNMap
15
+ from sysatlas.class_map import ClassMap
16
+ from sysatlas.er_map import ERMap
17
+ from sysatlas.sequence_map import SequenceMap
18
+ from sysatlas.state_map import StateMap
19
+ from sysatlas.system import System
20
+ from sysatlas.system_map import SystemMap
21
+ from sysatlas.tree_map import TreeMap
22
+
23
+ __all__ = [
24
+ "SystemMap",
25
+ "System",
26
+ "TreeMap",
27
+ "SequenceMap",
28
+ "ERMap",
29
+ "StateMap",
30
+ "ClassMap",
31
+ "BPMNMap",
32
+ "Reflection",
33
+ "reflect",
34
+ "reflect_rust",
35
+ "llm_guide",
36
+ "llm_guide_path",
37
+ ]
38
+ __version__ = "0.2.0"
39
+
40
+ _LLM_GUIDE = Path(__file__).parent / "LLM_GUIDE.md"
41
+
42
+
43
+ def llm_guide() -> str:
44
+ """Return the bundled LLM usage guide as a markdown string."""
45
+ return _LLM_GUIDE.read_text(encoding="utf-8")
46
+
47
+
48
+ def llm_guide_path() -> str:
49
+ """Return the absolute path of the bundled LLM usage guide."""
50
+ return str(_LLM_GUIDE)
@@ -0,0 +1,157 @@
1
+ """BPMN diagram layout.
2
+
3
+ Pools contain horizontal lanes. Inside each lane, nodes (events,
4
+ activities, gateways) are placed left-to-right in BFS order from start
5
+ events. Pools stack vertically.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections import defaultdict, deque
10
+
11
+ POOL_HEADER_W = 30
12
+ LANE_HEADER_W = 24
13
+ LANE_H = 120
14
+ NODE_GAP = 60
15
+ LANE_PAD_X = 20
16
+ LANE_PAD_Y = 16
17
+ MARGIN = 30
18
+
19
+ ACTIVITY_W = 110
20
+ ACTIVITY_H = 60
21
+ EVENT_R = 28
22
+ GATEWAY_W = 44
23
+
24
+
25
+ def node_size(kind: str) -> tuple[int, int]:
26
+ if kind in ("start", "end", "intermediate", "timer", "message", "error"):
27
+ return EVENT_R * 2, EVENT_R * 2
28
+ if kind in ("exclusive", "parallel", "inclusive", "event_based"):
29
+ return GATEWAY_W, GATEWAY_W
30
+ return ACTIVITY_W, ACTIVITY_H
31
+
32
+
33
+ def compute_bpmn_layout(diagram):
34
+ """Return (positions, sizes, pool_rects, lane_rects, routes, all_nodes)."""
35
+ pools = list(diagram.pools.values())
36
+ lanes_by_pool: dict[str, list] = defaultdict(list)
37
+ for lane in diagram.lanes.values():
38
+ lanes_by_pool[lane.pool].append(lane)
39
+
40
+ all_nodes: dict[str, tuple[str, str]] = {} # name -> (kind_category, kind)
41
+ node_lane: dict[str, str | None] = {}
42
+ for e in diagram.events.values():
43
+ all_nodes[e.name] = ("event", e.kind)
44
+ node_lane[e.name] = e.lane
45
+ for a in diagram.activities.values():
46
+ all_nodes[a.name] = ("activity", a.kind)
47
+ node_lane[a.name] = a.lane
48
+ for g in diagram.gateways.values():
49
+ all_nodes[g.name] = ("gateway", g.kind)
50
+ node_lane[g.name] = g.lane
51
+
52
+ nodes_by_lane: dict[str | None, list[str]] = defaultdict(list)
53
+ for name, lane in node_lane.items():
54
+ nodes_by_lane[lane].append(name)
55
+
56
+ adj: dict[str, list[str]] = defaultdict(list)
57
+ in_deg: dict[str, int] = defaultdict(int)
58
+ for f in diagram.flows:
59
+ if f.kind == "sequence" or f.kind == "default" or f.kind == "conditional":
60
+ adj[f.source].append(f.target)
61
+ in_deg[f.target] += 1
62
+
63
+ order: dict[str, int] = {}
64
+ starts = [n for n in all_nodes if in_deg[n] == 0 and all_nodes[n][0] == "event"]
65
+ if not starts:
66
+ starts = [n for n in all_nodes if in_deg[n] == 0]
67
+ queue: deque[str] = deque()
68
+ for s in starts:
69
+ order[s] = 0
70
+ queue.append(s)
71
+ while queue:
72
+ cur = queue.popleft()
73
+ for nb in adj.get(cur, []):
74
+ new_order = order[cur] + 1
75
+ if nb not in order or new_order > order[nb]:
76
+ order[nb] = new_order
77
+ queue.append(nb)
78
+ next_o = max(order.values(), default=-1) + 1
79
+ for n in all_nodes:
80
+ if n not in order:
81
+ order[n] = next_o
82
+ next_o += 1
83
+
84
+ pool_rects: list[dict] = []
85
+ lane_rects: list[dict] = []
86
+ pos: dict[str, tuple[int, int]] = {}
87
+ size: dict[str, tuple[int, int]] = {}
88
+
89
+ pool_y = MARGIN
90
+ inner_x = LANE_HEADER_W + LANE_PAD_X
91
+ column_pitch = max(ACTIVITY_W, EVENT_R * 2, GATEWAY_W) + NODE_GAP
92
+ max_order = max(order.values(), default=0)
93
+ lane_inner_w = inner_x + (max_order + 1) * column_pitch + LANE_PAD_X
94
+
95
+ for pool in pools:
96
+ lanes = lanes_by_pool.get(pool.name, [])
97
+ if not lanes:
98
+ from sysatlas._ontology.bpmn import Lane
99
+ lanes = [Lane(name=f"__default_{pool.name}__", pool=pool.name)]
100
+ lanes_by_pool[pool.name] = lanes
101
+
102
+ pool_h = LANE_H * len(lanes)
103
+ pool_w = POOL_HEADER_W + lane_inner_w
104
+ pool_x = MARGIN
105
+ pool_rects.append({
106
+ "name": pool.name,
107
+ "label": pool.label or pool.name,
108
+ "x": pool_x, "y": pool_y,
109
+ "w": pool_w, "h": pool_h,
110
+ })
111
+
112
+ for i, lane in enumerate(lanes):
113
+ lx = pool_x + POOL_HEADER_W
114
+ ly = pool_y + i * LANE_H
115
+ lane_rects.append({
116
+ "name": lane.name,
117
+ "label": lane.label or lane.name,
118
+ "x": lx, "y": ly,
119
+ "w": lane_inner_w, "h": LANE_H,
120
+ })
121
+ lane_center_y = ly + LANE_H // 2
122
+ nodes_here = sorted(nodes_by_lane.get(lane.name, []), key=lambda n: order[n])
123
+ for n in nodes_here:
124
+ cat, kind = all_nodes[n]
125
+ w, h = node_size(kind)
126
+ nx = lx + LANE_HEADER_W + LANE_PAD_X + order[n] * column_pitch
127
+ ny = lane_center_y - h // 2
128
+ pos[n] = (nx, ny)
129
+ size[n] = (w, h)
130
+ pool_y += pool_h + MARGIN
131
+
132
+ # Place nodes with no lane assignment into a fallback row
133
+ orphans = nodes_by_lane.get(None, [])
134
+ if orphans:
135
+ ly = pool_y
136
+ lane_rects.append({
137
+ "name": "__orphan__", "label": "(no lane)",
138
+ "x": MARGIN, "y": ly,
139
+ "w": lane_inner_w + POOL_HEADER_W, "h": LANE_H,
140
+ })
141
+ for n in sorted(orphans, key=lambda x: order[x]):
142
+ cat, kind = all_nodes[n]
143
+ w, h = node_size(kind)
144
+ nx = MARGIN + POOL_HEADER_W + LANE_PAD_X + order[n] * column_pitch
145
+ ny = ly + LANE_H // 2 - h // 2
146
+ pos[n] = (nx, ny)
147
+ size[n] = (w, h)
148
+
149
+ routes: list[dict] = []
150
+ for f in diagram.flows:
151
+ if f.source not in pos or f.target not in pos:
152
+ continue
153
+ routes.append({
154
+ "source": f.source, "target": f.target,
155
+ "kind": f.kind, "label": f.label,
156
+ })
157
+ return pos, size, pool_rects, lane_rects, routes, all_nodes
@@ -0,0 +1,154 @@
1
+ """Render a BPMNDiagram to draw.io / mxGraph XML."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import xml.etree.ElementTree as ET
6
+
7
+ from sysatlas._bpmn_layout import compute_bpmn_layout
8
+ from sysatlas._render import _FIT_JS, _VIEWER_CONFIG, _html_shell, _viewer_tag
9
+
10
+ _POOL_STYLE = (
11
+ "shape=swimlane;startSize=30;horizontal=0;fillColor=#f8fafc;"
12
+ "strokeColor=#475569;fontSize=11;fontStyle=1;align=center;"
13
+ )
14
+ _LANE_STYLE = (
15
+ "shape=swimlane;startSize=24;horizontal=0;fillColor=#ffffff;"
16
+ "strokeColor=#94a3b8;fontSize=10;align=center;"
17
+ )
18
+ _ACTIVITY_STYLE = {
19
+ "task": "rounded=1;arcSize=18;whiteSpace=wrap;html=1;fillColor=#dbeafe;strokeColor=#1e40af;fontSize=10;",
20
+ "user_task": "rounded=1;arcSize=18;whiteSpace=wrap;html=1;fillColor=#dcfce7;strokeColor=#15803d;fontSize=10;",
21
+ "service_task": "rounded=1;arcSize=18;whiteSpace=wrap;html=1;fillColor=#fef3c7;strokeColor=#a16207;fontSize=10;",
22
+ "subprocess": "rounded=1;arcSize=18;whiteSpace=wrap;html=1;fillColor=#e9d5ff;strokeColor=#7e22ce;fontSize=10;strokeWidth=2;",
23
+ "call_activity":"rounded=1;arcSize=18;whiteSpace=wrap;html=1;fillColor=#fce7f3;strokeColor=#be185d;fontSize=10;strokeWidth=2;",
24
+ }
25
+ _EVENT_STYLE = {
26
+ "start": "ellipse;whiteSpace=wrap;html=1;fillColor=#dcfce7;strokeColor=#15803d;strokeWidth=2;fontSize=9;",
27
+ "end": "ellipse;whiteSpace=wrap;html=1;fillColor=#fecaca;strokeColor=#b91c1c;strokeWidth=3;fontSize=9;",
28
+ "intermediate": "ellipse;whiteSpace=wrap;html=1;fillColor=#fef3c7;strokeColor=#a16207;strokeWidth=2;fontSize=9;",
29
+ "timer": "ellipse;whiteSpace=wrap;html=1;fillColor=#fef3c7;strokeColor=#a16207;strokeWidth=2;fontSize=9;",
30
+ "message": "ellipse;whiteSpace=wrap;html=1;fillColor=#dbeafe;strokeColor=#1e40af;strokeWidth=2;fontSize=9;",
31
+ "error": "ellipse;whiteSpace=wrap;html=1;fillColor=#fecaca;strokeColor=#b91c1c;strokeWidth=2;fontSize=9;",
32
+ }
33
+ _GATEWAY_STYLE = {
34
+ "exclusive": "rhombus;whiteSpace=wrap;html=1;fillColor=#fef9c3;strokeColor=#a16207;fontSize=14;fontStyle=1;",
35
+ "parallel": "rhombus;whiteSpace=wrap;html=1;fillColor=#dcfce7;strokeColor=#15803d;fontSize=14;fontStyle=1;",
36
+ "inclusive": "rhombus;whiteSpace=wrap;html=1;fillColor=#fce7f3;strokeColor=#be185d;fontSize=14;fontStyle=1;",
37
+ "event_based": "rhombus;whiteSpace=wrap;html=1;fillColor=#dbeafe;strokeColor=#1e40af;fontSize=14;fontStyle=1;",
38
+ }
39
+ _GATEWAY_GLYPH = {
40
+ "exclusive": "✕", "parallel": "+", "inclusive": "O", "event_based": "◇",
41
+ }
42
+ _FLOW_STYLE = {
43
+ "sequence": "endArrow=block;endFill=1;html=1;rounded=0;strokeColor=#1f2937;fontSize=10;",
44
+ "message": "endArrow=open;dashed=1;html=1;rounded=0;strokeColor=#6366f1;fontSize=10;",
45
+ "default": "endArrow=block;endFill=1;html=1;rounded=0;strokeColor=#1f2937;startArrow=oval;startFill=0;startSize=8;fontSize=10;",
46
+ "conditional": "endArrow=block;endFill=1;html=1;rounded=0;strokeColor=#1f2937;startArrow=diamondThin;startFill=0;startSize=10;fontSize=10;",
47
+ }
48
+
49
+
50
+ def build_bpmn_xml(diagram) -> str:
51
+ pos, size, pool_rects, lane_rects, routes, all_nodes = compute_bpmn_layout(diagram)
52
+
53
+ root_el = ET.Element(
54
+ "mxGraphModel",
55
+ dx="1422", dy="762", grid="1", gridSize="10", guides="1", tooltips="1",
56
+ connect="1", arrows="1", fold="1", page="1", pageScale="1",
57
+ pageWidth="1169", pageHeight="827", math="0", shadow="0",
58
+ )
59
+ root = ET.SubElement(root_el, "root")
60
+ ET.SubElement(root, "mxCell", id="0")
61
+ ET.SubElement(root, "mxCell", id="1", parent="0")
62
+
63
+ cell_id = 2
64
+
65
+ for p in pool_rects:
66
+ c = ET.SubElement(root, "mxCell", id=str(cell_id), value=p["label"],
67
+ style=_POOL_STYLE, vertex="1", parent="1")
68
+ ET.SubElement(c, "mxGeometry", x=str(p["x"]), y=str(p["y"]),
69
+ width=str(p["w"]), height=str(p["h"]), **{"as": "geometry"})
70
+ cell_id += 1
71
+
72
+ for l in lane_rects:
73
+ c = ET.SubElement(root, "mxCell", id=str(cell_id), value=l["label"],
74
+ style=_LANE_STYLE, vertex="1", parent="1")
75
+ ET.SubElement(c, "mxGeometry", x=str(l["x"]), y=str(l["y"]),
76
+ width=str(l["w"]), height=str(l["h"]), **{"as": "geometry"})
77
+ cell_id += 1
78
+
79
+ node_cell_ids: dict[str, str] = {}
80
+ for name, (cat, kind) in all_nodes.items():
81
+ if name not in pos:
82
+ continue
83
+ x, y = pos[name]
84
+ w, h = size[name]
85
+ if cat == "event":
86
+ style = _EVENT_STYLE.get(kind, _EVENT_STYLE["intermediate"])
87
+ label = diagram.events[name].label or name
88
+ elif cat == "activity":
89
+ style = _ACTIVITY_STYLE.get(kind, _ACTIVITY_STYLE["task"])
90
+ label = diagram.activities[name].label or name
91
+ else:
92
+ style = _GATEWAY_STYLE.get(kind, _GATEWAY_STYLE["exclusive"])
93
+ label = _GATEWAY_GLYPH.get(kind, "")
94
+ c = ET.SubElement(root, "mxCell", id=str(cell_id), value=label,
95
+ style=style, vertex="1", parent="1")
96
+ ET.SubElement(c, "mxGeometry", x=str(x), y=str(y),
97
+ width=str(w), height=str(h), **{"as": "geometry"})
98
+ node_cell_ids[name] = str(cell_id)
99
+ cell_id += 1
100
+
101
+ for r in routes:
102
+ s_id = node_cell_ids.get(r["source"])
103
+ t_id = node_cell_ids.get(r["target"])
104
+ if not s_id or not t_id:
105
+ continue
106
+ style = _FLOW_STYLE.get(r["kind"], _FLOW_STYLE["sequence"])
107
+ c = ET.SubElement(root, "mxCell", id=str(cell_id), value=r["label"],
108
+ style=style, edge="1",
109
+ source=s_id, target=t_id, parent="1")
110
+ ET.SubElement(c, "mxGeometry", relative="1", **{"as": "geometry"})
111
+ cell_id += 1
112
+
113
+ return ET.tostring(root_el, encoding="unicode", xml_declaration=False)
114
+
115
+
116
+ def render_bpmn(diagram, title: str = "", viewer: str = "cdn") -> str:
117
+ xml = build_bpmn_xml(diagram)
118
+ xml_json = json.dumps(xml)
119
+ config_json = json.dumps(_VIEWER_CONFIG)
120
+ css = """
121
+ body { display: flex; align-items: center; justify-content: center; }
122
+ #diagram {
123
+ border: 1px solid #cbd5e1;
124
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
125
+ background: #ffffff;
126
+ overflow: hidden;
127
+ position: relative;
128
+ }
129
+ """
130
+ body = '<div id="diagram"></div>'
131
+ script = f"""
132
+ {_FIT_JS}
133
+ var xmlStr = {xml_json};
134
+ var config = {config_json};
135
+ var container = document.getElementById('diagram');
136
+ function setSize() {{
137
+ container.style.width = Math.round(window.innerWidth * 0.85) + 'px';
138
+ container.style.height = Math.round(window.innerHeight * 0.85) + 'px';
139
+ }}
140
+ setSize();
141
+ if (typeof GraphViewer === 'undefined' || typeof mxUtils === 'undefined') {{
142
+ container.innerHTML = '<div style="padding:32px;font-family:sans-serif;color:#7f1d1d;">' +
143
+ '<strong>draw.io viewer not loaded.</strong></div>';
144
+ }} else {{
145
+ var xmlDoc = mxUtils.parseXml(xmlStr);
146
+ var viewer = new GraphViewer(container, xmlDoc.documentElement, config);
147
+ setTimeout(function() {{ fitGraph(viewer, container); }}, 50);
148
+ window.addEventListener('resize', function() {{
149
+ setSize();
150
+ fitGraph(viewer, container);
151
+ }});
152
+ }}
153
+ """
154
+ return _html_shell(title, body, _viewer_tag(viewer), script, css)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ LONG_LAYER_THRESHOLD = 3 # edges spanning >= this many ranks become connectors
4
+
5
+
6
+ def classify_edges(
7
+ edges: list[dict],
8
+ rank: dict[str, int],
9
+ ) -> tuple[list[dict], list[dict]]:
10
+ """Split edges into (direct, connector_pairs).
11
+
12
+ direct: regular edges to be routed normally.
13
+ connector_pairs: edges that should be drawn as two off-page connector glyphs
14
+ instead of a single long line.
15
+ """
16
+ direct: list[dict] = []
17
+ connectors: list[dict] = []
18
+ for e in edges:
19
+ s, t = e["source"], e["target"]
20
+ if s not in rank or t not in rank:
21
+ direct.append(e)
22
+ continue
23
+ span = abs(rank[t] - rank[s])
24
+ if span >= LONG_LAYER_THRESHOLD and not e.get("no_connector"):
25
+ connectors.append(e)
26
+ else:
27
+ direct.append(e)
28
+ return direct, connectors
@@ -0,0 +1,111 @@
1
+ """Hub-and-spoke placement for read/write-loop architectures.
2
+
3
+ This module only computes node positions. Edge routing (A*, port
4
+ assignment, obstacle avoidance, label placement) is delegated to the
5
+ strategy-agnostic `_layout.finalize_routing` so the hub strategy gets
6
+ the same routing quality as the layered strategy without duplicating
7
+ work.
8
+
9
+ Region assignment by `Component.layer`:
10
+
11
+ "interfaces" → top band, horizontal stack
12
+ "write" → left column, vertical stack (writes into hub)
13
+ "hub" → centre, single component expected (rendered taller)
14
+ "read" → right column, vertical stack (reads from hub)
15
+ "external" → bottom band, horizontal stack
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from collections import defaultdict
20
+
21
+ from sysatlas._layout import NODE_W, NODE_H, finalize_routing
22
+
23
+ _GAP_X = 120
24
+ _GAP_Y = 80
25
+ _MARGIN = 100
26
+
27
+ _HUB_W = 220
28
+ _HUB_H = 140
29
+
30
+
31
+ def _spread(xs_centre: int, names: list[str], gap: int = _GAP_X) -> list[int]:
32
+ if not names:
33
+ return []
34
+ total = len(names) * NODE_W + (len(names) - 1) * gap
35
+ start = xs_centre - total // 2
36
+ return [start + i * (NODE_W + gap) for i in range(len(names))]
37
+
38
+
39
+ _RESERVED = ("interfaces", "write", "hub", "read", "external")
40
+
41
+
42
+ def _place(nodes: dict[str, dict]) -> tuple[dict[str, tuple[int, int]], dict[str, int]]:
43
+ by_layer: dict[str, list[str]] = defaultdict(list)
44
+ for n, data in nodes.items():
45
+ layer = data.get("layer")
46
+ # Unknown layers fall into "external" so every node gets a position.
47
+ if layer not in _RESERVED:
48
+ layer = "external"
49
+ by_layer[layer].append(n)
50
+
51
+ interfaces = by_layer.get("interfaces", [])
52
+ writes = by_layer.get("write", [])
53
+ hubs = by_layer.get("hub", [])
54
+ reads = by_layer.get("read", [])
55
+ externals = by_layer.get("external", [])
56
+
57
+ rows_mid = max(1, len(writes), len(reads))
58
+ mid_band_h = rows_mid * NODE_H + (rows_mid - 1) * _GAP_Y
59
+
60
+ cx = _MARGIN + max(
61
+ NODE_W + _GAP_X + _HUB_W // 2,
62
+ len(interfaces) * (NODE_W + _GAP_X) // 2,
63
+ len(externals) * (NODE_W + _GAP_X) // 2,
64
+ )
65
+
66
+ top_y = _MARGIN
67
+ mid_top = top_y + (NODE_H + _GAP_Y if interfaces else 0)
68
+ hub_y = mid_top + (mid_band_h - _HUB_H) // 2
69
+ mid_end = mid_top + mid_band_h
70
+ bot_y = mid_end + _GAP_Y
71
+
72
+ pos: dict[str, tuple[int, int]] = {}
73
+ node_heights: dict[str, int] = {}
74
+
75
+ for x, name in zip(_spread(cx, interfaces), interfaces):
76
+ pos[name] = (x, top_y)
77
+ for x, name in zip(_spread(cx, externals), externals):
78
+ pos[name] = (x, bot_y)
79
+
80
+ write_x = cx - (_HUB_W // 2 + _GAP_X + NODE_W)
81
+ for i, name in enumerate(writes):
82
+ pos[name] = (write_x, mid_top + i * (NODE_H + _GAP_Y))
83
+
84
+ read_x = cx + _HUB_W // 2 + _GAP_X
85
+ for i, name in enumerate(reads):
86
+ pos[name] = (read_x, mid_top + i * (NODE_H + _GAP_Y))
87
+
88
+ if hubs:
89
+ hub_x = cx - _HUB_W // 2
90
+ pos[hubs[0]] = (hub_x, hub_y)
91
+ node_heights[hubs[0]] = _HUB_H
92
+ for k, name in enumerate(hubs[1:], start=1):
93
+ pos[name] = (hub_x, hub_y + k * (_HUB_H + _GAP_Y))
94
+ node_heights[name] = _HUB_H
95
+
96
+ return pos, node_heights
97
+
98
+
99
+ def compute_hub_layout(
100
+ nodes: dict[str, dict],
101
+ edges: list[dict],
102
+ layer_order: list[str],
103
+ debug: bool = False,
104
+ ) -> tuple[dict[str, tuple[int, int]], dict[tuple[str, str], dict], dict[str, int]]:
105
+ """Place nodes hub-and-spoke, then route edges with the shared A* pipeline."""
106
+ pos, hub_heights = _place(nodes)
107
+ routes, node_heights = finalize_routing(pos, nodes, edges,
108
+ layer_order=layer_order,
109
+ fixed_heights=hub_heights,
110
+ debug=debug)
111
+ return pos, routes, node_heights