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.
- sysatlas/LLM_GUIDE.md +151 -0
- sysatlas/__init__.py +50 -0
- sysatlas/_bpmn_layout.py +157 -0
- sysatlas/_bpmn_render.py +154 -0
- sysatlas/_connectors.py +28 -0
- sysatlas/_hub_layout.py +111 -0
- sysatlas/_layout.py +883 -0
- sysatlas/_ontology/__init__.py +1 -0
- sysatlas/_ontology/architecture.py +117 -0
- sysatlas/_ontology/bpmn.py +101 -0
- sysatlas/_ontology/er.py +54 -0
- sysatlas/_ontology/iso42010.py +138 -0
- sysatlas/_ontology/model_kind.py +34 -0
- sysatlas/_ontology/model_kinds.py +82 -0
- sysatlas/_ontology/qualities.py +63 -0
- sysatlas/_ontology/sequence.py +71 -0
- sysatlas/_ontology/state_machine.py +58 -0
- sysatlas/_ontology/trace.py +99 -0
- sysatlas/_ontology/tree.py +64 -0
- sysatlas/_ontology/uml_class.py +69 -0
- sysatlas/_place.py +337 -0
- sysatlas/_reflection/__init__.py +0 -0
- sysatlas/_reflection/hints.py +38 -0
- sysatlas/_reflection/layers.py +38 -0
- sysatlas/_reflection/merge.py +77 -0
- sysatlas/_reflection/parser.py +118 -0
- sysatlas/_reflection/parser_rust.py +148 -0
- sysatlas/_reflection/reflection.py +208 -0
- sysatlas/_reflection/resolve.py +20 -0
- sysatlas/_render.py +652 -0
- sysatlas/_route.py +758 -0
- sysatlas/_sequence_layout.py +70 -0
- sysatlas/_sequence_render.py +181 -0
- sysatlas/_trace_matrix.py +126 -0
- sysatlas/_tree_layout.py +102 -0
- sysatlas/_tree_render.py +135 -0
- sysatlas/_vendor.py +35 -0
- sysatlas/bpmn_map.py +89 -0
- sysatlas/class_map.py +225 -0
- sysatlas/er_map.py +172 -0
- sysatlas/sequence_map.py +90 -0
- sysatlas/state_map.py +194 -0
- sysatlas/sysatlas.json +73 -0
- sysatlas/system.py +241 -0
- sysatlas/system_map.py +114 -0
- sysatlas/tree_map.py +60 -0
- sysatlas-0.2.0.dist-info/METADATA +131 -0
- sysatlas-0.2.0.dist-info/RECORD +50 -0
- sysatlas-0.2.0.dist-info/WHEEL +4 -0
- 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)
|
sysatlas/_bpmn_layout.py
ADDED
|
@@ -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
|
sysatlas/_bpmn_render.py
ADDED
|
@@ -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)
|
sysatlas/_connectors.py
ADDED
|
@@ -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
|
sysatlas/_hub_layout.py
ADDED
|
@@ -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
|