structviz 0.2__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.
- structviz/__init__.py +1 -0
- structviz/cli.py +92 -0
- structviz/d2_render.py +169 -0
- structviz/excalidraw_render.py +213 -0
- structviz/generators/__init__.py +4 -0
- structviz/generators/d2.py +196 -0
- structviz/generators/equity.py +38 -0
- structviz/generators/excalidraw.py +281 -0
- structviz/generators/org.py +300 -0
- structviz/html_wrapper.py +31 -0
- structviz/models.py +82 -0
- structviz/render.py +20 -0
- structviz/themes.py +99 -0
- structviz-0.2.dist-info/METADATA +205 -0
- structviz-0.2.dist-info/RECORD +18 -0
- structviz-0.2.dist-info/WHEEL +5 -0
- structviz-0.2.dist-info/entry_points.txt +2 -0
- structviz-0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from ..models import EquityChart
|
|
3
|
+
from ..themes import THEMES, parse_paper_size
|
|
4
|
+
|
|
5
|
+
def generate_equity_dot(chart: EquityChart, paper_size: Optional[str] = None, dpi: Optional[int] = None) -> str:
|
|
6
|
+
theme = THEMES.get(chart.meta.theme, THEMES["corporate"])
|
|
7
|
+
lines = ['digraph G {']
|
|
8
|
+
lines.append(f'label="{chart.meta.title}"; labelloc=t; fontsize=14;')
|
|
9
|
+
lines.append(f'rankdir=TB;')
|
|
10
|
+
ps = parse_paper_size(paper_size) if paper_size else None
|
|
11
|
+
if ps:
|
|
12
|
+
lines.append(f'size="{ps["size"]}"; ratio={ps["ratio"]}; margin=0;')
|
|
13
|
+
lines.append(f'ranksep=0.3; nodesep=0.15;')
|
|
14
|
+
else:
|
|
15
|
+
lines.append(f'ranksep={theme["ranksep"]}; nodesep={theme["nodesep"]};')
|
|
16
|
+
if dpi:
|
|
17
|
+
lines.append(f'dpi={dpi};')
|
|
18
|
+
nd = theme["node_default"]
|
|
19
|
+
lines.append(f'node [shape={nd["shape"]} fontname="{nd["fontname"]}" fontsize={nd["fontsize"]}];')
|
|
20
|
+
lines.append(f'edge [color="{theme["edge_color"]}" penwidth={theme["edge_penwidth"]}];')
|
|
21
|
+
|
|
22
|
+
for node in chart.nodes:
|
|
23
|
+
attrs = []
|
|
24
|
+
if node.shape: attrs.append(f'shape={node.shape}')
|
|
25
|
+
if node.style: attrs.append(f'style="{node.style}"')
|
|
26
|
+
if node.fillcolor: attrs.append(f'fillcolor="{node.fillcolor}"')
|
|
27
|
+
if node.color: attrs.append(f'color="{node.color}"')
|
|
28
|
+
if node.fontcolor: attrs.append(f'fontcolor="{node.fontcolor}"')
|
|
29
|
+
attr_str = ", ".join(attrs)
|
|
30
|
+
lines.append(f'"{node.id}" [label="{node.label}" {attr_str}];')
|
|
31
|
+
|
|
32
|
+
for edge in chart.edges:
|
|
33
|
+
attrs = [f'label="{edge.label}"', f'penwidth={edge.penwidth}',
|
|
34
|
+
f'color="{edge.color}"', f'style={edge.style}']
|
|
35
|
+
lines.append(f'"{edge.from_id}" -> "{edge.to}" [{", ".join(attrs)}];')
|
|
36
|
+
|
|
37
|
+
lines.append('}')
|
|
38
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Excalidraw generator. YAML model → .excalidraw JSON with manual layout."""
|
|
2
|
+
import json, math
|
|
3
|
+
from typing import Optional, List, Dict, Any
|
|
4
|
+
from ..models import OrgChart, OrgNode, OrgBlock
|
|
5
|
+
from ..themes import THEMES
|
|
6
|
+
|
|
7
|
+
RECT = "rectangle"; TEXT = "text"; ARROW = "arrow"
|
|
8
|
+
|
|
9
|
+
# Layout constants (Excalidraw units ≈ px)
|
|
10
|
+
LARGE_W, LARGE_H = 120, 38
|
|
11
|
+
CARD_W, CARD_H = 130, 48
|
|
12
|
+
DEPT_W, DEPT_H = 110, 34
|
|
13
|
+
SIDE_W, SIDE_H = 110, 34 # side block node (committee)
|
|
14
|
+
GAP_Y = 60 # inter-level vertical gap
|
|
15
|
+
GAP_X = 16 # inter-node horizontal gap
|
|
16
|
+
ROW_GAP = 8 # intra-block row gap
|
|
17
|
+
PAD_X = 8; PAD_Y = 20 # block padding
|
|
18
|
+
PAGE_W = 1600; PAGE_MARGIN = 40
|
|
19
|
+
FONT = 13; FONT_S = 11
|
|
20
|
+
|
|
21
|
+
def _tc(name):
|
|
22
|
+
t = THEMES.get(name, THEMES["corporate"])
|
|
23
|
+
return {"hb": t["card_header_bg"], "hf": t["card_header_font"],
|
|
24
|
+
"bb": t["card_body_bg"], "br": t["card_border_color"],
|
|
25
|
+
"db": t["dept_fillcolor"], "dc": t["dept_border"],
|
|
26
|
+
"cb": t["cluster_bg"], "ec": t["edge_color"]}
|
|
27
|
+
|
|
28
|
+
def _el(eid, tp, x, y, w, h, **kw):
|
|
29
|
+
return {"id": eid, "type": tp, "x": round(x,1), "y": round(y,1),
|
|
30
|
+
"width": round(w,1), "height": round(h,1),
|
|
31
|
+
"strokeColor": kw.get("sc","#1e1e1e"),
|
|
32
|
+
"backgroundColor": kw.get("bg","#ffffff"),
|
|
33
|
+
"fillStyle": kw.get("fs","solid"),
|
|
34
|
+
"strokeWidth": kw.get("sw",1),
|
|
35
|
+
"strokeStyle": kw.get("ss","solid"),
|
|
36
|
+
"roughness": 0, "opacity": 100,
|
|
37
|
+
"roundness": {"type":3} if tp==RECT else None,
|
|
38
|
+
"groupIds": [], "isDeleted": False}
|
|
39
|
+
|
|
40
|
+
def _tx(eid, x, y, w, h, text, fs=FONT, color="#1e1e1e", bold=False):
|
|
41
|
+
return {"id": eid, "type": TEXT, "x": round(x,1), "y": round(y,1),
|
|
42
|
+
"width": round(w,1), "height": round(h,1),
|
|
43
|
+
"text": text, "fontSize": fs, "fontFamily": 1,
|
|
44
|
+
"textAlign": "center", "verticalAlign": "middle",
|
|
45
|
+
"strokeColor": color, "backgroundColor": "transparent",
|
|
46
|
+
"fillStyle": "solid", "strokeWidth": 0, "roughness": 0,
|
|
47
|
+
"opacity": 100, "groupIds": [], "isDeleted": False}
|
|
48
|
+
|
|
49
|
+
def _ar(eid, pts, sc="#7F8C8D"):
|
|
50
|
+
x0, y0 = pts[0]
|
|
51
|
+
return {"id": eid, "type": ARROW,
|
|
52
|
+
"x": round(x0,1), "y": round(y0,1), "width": 0, "height": 0,
|
|
53
|
+
"points": [[round(p[0]-x0,1), round(p[1]-y0,1)] for p in pts],
|
|
54
|
+
"startArrowhead": None, "endArrowhead": "arrow",
|
|
55
|
+
"strokeColor": sc, "strokeWidth": 2, "roughness": 0,
|
|
56
|
+
"opacity": 100, "groupIds": [], "isDeleted": False,
|
|
57
|
+
"startBinding": None, "endBinding": None}
|
|
58
|
+
|
|
59
|
+
def generate_org_excalidraw(chart: OrgChart, paper_size=None, dpi=None):
|
|
60
|
+
tc = _tc(chart.meta.theme)
|
|
61
|
+
layout = chart.meta.layout
|
|
62
|
+
els = []; ei = [0]; nr = {}
|
|
63
|
+
|
|
64
|
+
def _nid(): ei[0]+=1; return f"e{ei[0]}"
|
|
65
|
+
|
|
66
|
+
def _large(node, x, y):
|
|
67
|
+
e = _el(_nid(), RECT, x, y, LARGE_W, LARGE_H, bg=tc["hb"], sc=tc["br"])
|
|
68
|
+
els.append(e); els.append(_tx(_nid(), x, y, LARGE_W, LARGE_H,
|
|
69
|
+
node.title, FONT+2, "#ffffff", True))
|
|
70
|
+
return {"eid": e["id"], "x": x, "y": y, "w": LARGE_W, "h": LARGE_H}
|
|
71
|
+
|
|
72
|
+
def _card(node, x, y):
|
|
73
|
+
w = CARD_W; h = CARD_H
|
|
74
|
+
lines = max(1, node.title.count('\n') + 1)
|
|
75
|
+
h = 22 + lines * 16
|
|
76
|
+
e = _el(_nid(), RECT, x, y, w, h, bg=tc["bb"], sc=tc["br"])
|
|
77
|
+
els.append(e)
|
|
78
|
+
els.append(_el(_nid(), RECT, x, y, w, 20, bg=tc["hb"], sc=tc["hb"]))
|
|
79
|
+
title = node.title.replace('\n', ' ')
|
|
80
|
+
els.append(_tx(_nid(), x, y+1, w, 18, title, FONT, tc["hf"], True))
|
|
81
|
+
return {"eid": e["id"], "x": x, "y": y, "w": w, "h": h}
|
|
82
|
+
|
|
83
|
+
def _side(node, x, y):
|
|
84
|
+
e = _el(_nid(), RECT, x, y, SIDE_W, SIDE_H, bg=tc["hb"], sc=tc["br"])
|
|
85
|
+
els.append(e)
|
|
86
|
+
els.append(_tx(_nid(), x, y, SIDE_W, SIDE_H, node.title, FONT_S, "#ffffff"))
|
|
87
|
+
return {"eid": e["id"], "x": x, "y": y, "w": SIDE_W, "h": SIDE_H}
|
|
88
|
+
|
|
89
|
+
def _dept(node, x, y):
|
|
90
|
+
e = _el(_nid(), RECT, x, y, DEPT_W, DEPT_H, bg=tc["db"], sc=tc["dc"])
|
|
91
|
+
els.append(e)
|
|
92
|
+
els.append(_tx(_nid(), x, y, DEPT_W, DEPT_H, node.title, FONT_S, "#1e1e1e"))
|
|
93
|
+
return {"eid": e["id"], "x": x, "y": y, "w": DEPT_W, "h": DEPT_H}
|
|
94
|
+
|
|
95
|
+
def _chunk_layout(nodes, start_x, y, node_w, node_h, gap_x, max_cols, render_fn):
|
|
96
|
+
"""Layout nodes in max_cols per row. Returns list of (x,y,info) and total_h."""
|
|
97
|
+
rows = [nodes[i:i+max_cols] for i in range(0, len(nodes), max_cols)]
|
|
98
|
+
results = []
|
|
99
|
+
for row in rows:
|
|
100
|
+
rw = len(row) * (node_w + gap_x) - gap_x
|
|
101
|
+
rx = start_x + (max_cols * (node_w + gap_x) - gap_x - rw) / 2
|
|
102
|
+
for j, n in enumerate(row):
|
|
103
|
+
info = render_fn(n, rx + j * (node_w + gap_x), y)
|
|
104
|
+
results.append(info)
|
|
105
|
+
y += node_h + ROW_GAP
|
|
106
|
+
return results, y
|
|
107
|
+
|
|
108
|
+
cur_y = PAGE_MARGIN
|
|
109
|
+
|
|
110
|
+
for group in chart.groups:
|
|
111
|
+
right_blocks = [b for b in group.blocks if b.position == "right"]
|
|
112
|
+
bottom_blocks = [b for b in group.blocks if b.position == "bottom"]
|
|
113
|
+
main_nodes = group.nodes
|
|
114
|
+
right_nodes = group.right_nodes
|
|
115
|
+
|
|
116
|
+
# Level starting Y
|
|
117
|
+
level_start_y = cur_y
|
|
118
|
+
if getattr(group, 'gap', None):
|
|
119
|
+
cur_y += group.gap * 15
|
|
120
|
+
|
|
121
|
+
# ── Collect ALL same-rank nodes (main + right_nodes + side block nodes) ──
|
|
122
|
+
all_same = list(main_nodes) + list(right_nodes)
|
|
123
|
+
for b in right_blocks:
|
|
124
|
+
all_same.extend(b.nodes)
|
|
125
|
+
|
|
126
|
+
# Determine if we need multi-row for side block wrapping
|
|
127
|
+
side_mc = right_blocks[0].max_columns if right_blocks else 0
|
|
128
|
+
side_wrap = side_mc > 0 and len([n for b in right_blocks for n in b.nodes]) > side_mc
|
|
129
|
+
|
|
130
|
+
if side_wrap:
|
|
131
|
+
# Layout: main node on left, side block nodes to the right, wrapped
|
|
132
|
+
total_nodes = len(main_nodes) + len(right_nodes)
|
|
133
|
+
total_side = len([n for b in right_blocks for n in b.nodes])
|
|
134
|
+
side_chunks = [[n for b in right_blocks for n in b.nodes][i:i+side_mc]
|
|
135
|
+
for i in range(0, total_side, side_mc)]
|
|
136
|
+
|
|
137
|
+
# Center the combined content
|
|
138
|
+
main_w = len(main_nodes) * (LARGE_W + GAP_X) - GAP_X if main_nodes else 0
|
|
139
|
+
side_total_w = side_mc * (SIDE_W + GAP_X)
|
|
140
|
+
total_w = main_w + GAP_X * 3 + side_total_w
|
|
141
|
+
start_x = (PAGE_W - total_w) / 2
|
|
142
|
+
|
|
143
|
+
# Main nodes at left
|
|
144
|
+
m_cur_y = cur_y
|
|
145
|
+
for i, node in enumerate(main_nodes):
|
|
146
|
+
x = start_x + i * (LARGE_W + GAP_X)
|
|
147
|
+
nr[node.id] = _large(node, x, m_cur_y)
|
|
148
|
+
m_cur_y = max(m_cur_y, cur_y)
|
|
149
|
+
|
|
150
|
+
# Side block nodes wrapping
|
|
151
|
+
s_cur_y = cur_y
|
|
152
|
+
side_info = []
|
|
153
|
+
side_x = start_x + main_w + GAP_X * 3
|
|
154
|
+
for chunk in side_chunks:
|
|
155
|
+
for j, node in enumerate(chunk):
|
|
156
|
+
x = side_x + j * (SIDE_W + GAP_X)
|
|
157
|
+
info = _card(node, x, s_cur_y)
|
|
158
|
+
nr[node.id] = info
|
|
159
|
+
side_info.append(info)
|
|
160
|
+
s_cur_y += SIDE_H + ROW_GAP
|
|
161
|
+
|
|
162
|
+
# Dashed block around side nodes
|
|
163
|
+
if side_info:
|
|
164
|
+
bx = min(i["x"] for i in side_info) - PAD_X
|
|
165
|
+
by = min(i["y"] for i in side_info) - PAD_Y
|
|
166
|
+
bw = max(i["x"] + i["w"] for i in side_info) - bx + PAD_X
|
|
167
|
+
bh = max(i["y"] + i["h"] for i in side_info) - by + PAD_Y
|
|
168
|
+
els.append(_el(_nid(), RECT, bx, by, bw, bh,
|
|
169
|
+
bg=tc["cb"], sc=tc["br"], ss="dashed"))
|
|
170
|
+
|
|
171
|
+
cur_y = max(s_cur_y, cur_y + LARGE_H) + GAP_Y
|
|
172
|
+
|
|
173
|
+
else:
|
|
174
|
+
# Standard: all nodes on one row, centered
|
|
175
|
+
n_total = len(all_same) if all_same else 1
|
|
176
|
+
total_w = n_total * (LARGE_W + GAP_X) - GAP_X
|
|
177
|
+
start_x = (PAGE_W - total_w) / 2
|
|
178
|
+
if group.offset and group.offset > 0:
|
|
179
|
+
start_x += group.offset * 72
|
|
180
|
+
|
|
181
|
+
for i, node in enumerate(all_same):
|
|
182
|
+
x = start_x + i * (LARGE_W + GAP_X)
|
|
183
|
+
nr[node.id] = _large(node, x, cur_y) if node.style == "large" else _card(node, x, cur_y)
|
|
184
|
+
|
|
185
|
+
# Dashed block for right blocks (non-wrapping)
|
|
186
|
+
for b in right_blocks:
|
|
187
|
+
if not b.nodes: continue
|
|
188
|
+
xs = [nr[n.id]["x"] for n in b.nodes if n.id in nr]
|
|
189
|
+
if not xs: continue
|
|
190
|
+
bx = min(xs) - PAD_X
|
|
191
|
+
bw = max(nr[n.id]["x"] + nr[n.id]["w"] for n in b.nodes if n.id in nr) - bx + PAD_X
|
|
192
|
+
by = min(nr[n.id]["y"] for n in b.nodes if n.id in nr) - PAD_Y
|
|
193
|
+
bh = max(nr[n.id]["y"] + nr[n.id]["h"] for n in b.nodes if n.id in nr) - by + PAD_Y
|
|
194
|
+
els.append(_el(_nid(), RECT, bx, by, bw, bh,
|
|
195
|
+
bg=tc["cb"], sc=tc["br"], ss="dashed"))
|
|
196
|
+
|
|
197
|
+
cur_y += LARGE_H + GAP_Y
|
|
198
|
+
|
|
199
|
+
# ── Bottom blocks ──
|
|
200
|
+
if bottom_blocks:
|
|
201
|
+
max_cols = group.max_columns or (layout.max_columns if layout else 3)
|
|
202
|
+
if max_cols <= 0: max_cols = len(bottom_blocks)
|
|
203
|
+
eff_cols = min(max_cols, len(bottom_blocks))
|
|
204
|
+
|
|
205
|
+
# Column width per block
|
|
206
|
+
col_w = DEPT_W * 2 + GAP_X # max_columns=2 → two depts per row
|
|
207
|
+
total_block_w = eff_cols * col_w + (eff_cols - 1) * GAP_X * 2
|
|
208
|
+
block_start_x = (PAGE_W - total_block_w) / 2
|
|
209
|
+
|
|
210
|
+
max_block_h = 0
|
|
211
|
+
block_anchors = []
|
|
212
|
+
|
|
213
|
+
for bi, b in enumerate(bottom_blocks):
|
|
214
|
+
col = bi % eff_cols
|
|
215
|
+
bx = block_start_x + col * (col_w + GAP_X * 2)
|
|
216
|
+
dept_y = cur_y
|
|
217
|
+
block_info = []
|
|
218
|
+
|
|
219
|
+
depts = [n for n in b.nodes if not n.is_cluster_head]
|
|
220
|
+
heads = [n for n in b.nodes if n.is_cluster_head]
|
|
221
|
+
mc = b.max_columns or 2
|
|
222
|
+
|
|
223
|
+
# Heads
|
|
224
|
+
for h in heads:
|
|
225
|
+
info = _large(h, bx + (col_w - LARGE_W)/2, dept_y)
|
|
226
|
+
nr[h.id] = info; block_info.append(info)
|
|
227
|
+
dept_y += LARGE_H + ROW_GAP
|
|
228
|
+
|
|
229
|
+
# Departments in rows
|
|
230
|
+
for ri in range(0, len(depts), mc):
|
|
231
|
+
row = depts[ri:ri+mc]
|
|
232
|
+
rw = len(row) * (DEPT_W + GAP_X) - GAP_X
|
|
233
|
+
rx = bx + (col_w - rw) / 2
|
|
234
|
+
for j, d in enumerate(row):
|
|
235
|
+
info = _dept(d, rx + j * (DEPT_W + GAP_X), dept_y)
|
|
236
|
+
nr[d.id] = info; block_info.append(info)
|
|
237
|
+
dept_y += DEPT_H + ROW_GAP
|
|
238
|
+
|
|
239
|
+
# Dashed block rect
|
|
240
|
+
if block_info:
|
|
241
|
+
minx = min(i["x"] for i in block_info) - PAD_X
|
|
242
|
+
miny = min(i["y"] for i in block_info) - PAD_Y
|
|
243
|
+
maxx = max(i["x"] + i["w"] for i in block_info) + PAD_X
|
|
244
|
+
maxy = max(i["y"] + i["h"] for i in block_info) + PAD_Y
|
|
245
|
+
els.append(_el(_nid(), RECT, minx, miny, maxx-minx, maxy-miny,
|
|
246
|
+
bg=tc["cb"], sc=tc["br"], ss="dashed"))
|
|
247
|
+
block_anchors.append((minx, miny, maxx, maxy))
|
|
248
|
+
max_block_h = max(max_block_h, maxy - cur_y)
|
|
249
|
+
|
|
250
|
+
cur_y += max_block_h + GAP_Y
|
|
251
|
+
|
|
252
|
+
# ── Arrows ──
|
|
253
|
+
for group in chart.groups:
|
|
254
|
+
for node in group.nodes + group.right_nodes:
|
|
255
|
+
if node.reports_to and node.reports_to in nr:
|
|
256
|
+
src, dst = nr[node.reports_to], nr[node.id]
|
|
257
|
+
els.append(_ar(_nid(), [
|
|
258
|
+
(src["x"]+src["w"]/2, src["y"]+src["h"]),
|
|
259
|
+
(dst["x"]+dst["w"]/2, dst["y"])
|
|
260
|
+
], tc["ec"]))
|
|
261
|
+
for b in group.blocks:
|
|
262
|
+
for node in b.nodes:
|
|
263
|
+
if node.reports_to and node.reports_to in nr:
|
|
264
|
+
src, dst = nr[node.reports_to], nr[node.id]
|
|
265
|
+
els.append(_ar(_nid(), [
|
|
266
|
+
(src["x"]+src["w"]/2, src["y"]+src["h"]),
|
|
267
|
+
(dst["x"]+dst["w"]/2, dst["y"])
|
|
268
|
+
], tc["ec"]))
|
|
269
|
+
if b.edge_from and b.nodes and b.edge_from in nr:
|
|
270
|
+
src, dst = nr[b.edge_from], nr[b.nodes[0].id]
|
|
271
|
+
els.append(_ar(_nid(), [
|
|
272
|
+
(src["x"]+src["w"]/2, src["y"]+src["h"]),
|
|
273
|
+
(dst["x"]+dst["w"]/2, dst["y"])
|
|
274
|
+
], tc["ec"]))
|
|
275
|
+
|
|
276
|
+
return json.dumps({
|
|
277
|
+
"type": "excalidraw", "version": 2, "source": "https://structviz",
|
|
278
|
+
"elements": els,
|
|
279
|
+
"appState": {"viewBackgroundColor": "#ffffff"},
|
|
280
|
+
"files": {}
|
|
281
|
+
}, ensure_ascii=False, indent=2)
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
from typing import Optional, List, Tuple
|
|
2
|
+
from ..models import OrgChart, OrgNode, OrgBlock
|
|
3
|
+
from ..themes import THEMES, parse_paper_size
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _make_card(node: OrgNode, theme: dict, style_override: Optional[str] = None) -> str:
|
|
7
|
+
def _br(s):
|
|
8
|
+
return s.replace('\n', '<br/>')
|
|
9
|
+
style = style_override if style_override else node.style
|
|
10
|
+
if style == "external":
|
|
11
|
+
header_bg = theme["external_header_bg"]
|
|
12
|
+
elif style == "large":
|
|
13
|
+
header_bg = theme["card_header_bg"]
|
|
14
|
+
return (f'< <table border="0" cellborder="0" cellspacing="0" cellpadding="6">'
|
|
15
|
+
f'<tr><td bgcolor="{header_bg}"><font color="{theme["card_header_font"]}"><b>{_br(node.title)}</b></font></td></tr>'
|
|
16
|
+
f'</table> >')
|
|
17
|
+
else:
|
|
18
|
+
header_bg = theme["card_header_bg"]
|
|
19
|
+
body_bg = theme["card_body_bg"]
|
|
20
|
+
header_font = theme["card_header_font"]
|
|
21
|
+
label = f'< <table border="0" cellborder="0" cellspacing="0" cellpadding="3">'
|
|
22
|
+
label += f'<tr><td bgcolor="{header_bg}"><font color="{header_font}"><b>{_br(node.title)}</b></font></td></tr>'
|
|
23
|
+
if node.name:
|
|
24
|
+
label += f'<tr><td bgcolor="{body_bg}">{_br(node.name)}</td></tr>'
|
|
25
|
+
if node.role:
|
|
26
|
+
label += f'<tr><td bgcolor="{body_bg}"><font point-size="9">{_br(node.role)}</font></td></tr>'
|
|
27
|
+
label += '</table> >'
|
|
28
|
+
return label
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _emit_node_def(node: OrgNode, theme: dict, lines: List[str], all_edges: set,
|
|
32
|
+
in_block: bool = False):
|
|
33
|
+
card = _make_card(node, theme, style_override="large" if node.is_cluster_head else None)
|
|
34
|
+
if in_block and not node.is_cluster_head:
|
|
35
|
+
lines.append(f'"{node.id}" [label={card}, shape=box, '
|
|
36
|
+
f'style="rounded,filled", fillcolor="{theme["dept_fillcolor"]}", '
|
|
37
|
+
f'color="{theme["dept_border"]}"];')
|
|
38
|
+
else:
|
|
39
|
+
lines.append(f'"{node.id}" [label={card}, shape=plaintext];')
|
|
40
|
+
if node.reports_to:
|
|
41
|
+
all_edges.add((node.reports_to, node.id))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _emit_ids_rank(ids: List[str], lines: List[str], prev_last: Optional[str] = None) -> Optional[str]:
|
|
45
|
+
if not ids:
|
|
46
|
+
return prev_last
|
|
47
|
+
lines.append('{ rank=same; ' + ' '.join(f'"{x}"' for x in ids) + ' }')
|
|
48
|
+
for i in range(len(ids) - 1):
|
|
49
|
+
lines.append(f'"{ids[i]}" -> "{ids[i+1]}" [style=invis, weight=10];')
|
|
50
|
+
if prev_last:
|
|
51
|
+
lines.append(f'"{prev_last}" -> "{ids[0]}" [style=invis, weight=100];')
|
|
52
|
+
return ids[-1]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _group_anchor_ids(group) -> Tuple[Optional[str], Optional[str]]:
|
|
56
|
+
first = last = None
|
|
57
|
+
for lst in [group.nodes, group.right_nodes]:
|
|
58
|
+
if lst:
|
|
59
|
+
first = first or lst[0].id
|
|
60
|
+
last = lst[-1].id
|
|
61
|
+
for b in group.blocks:
|
|
62
|
+
if b.nodes:
|
|
63
|
+
first = first or b.nodes[0].id
|
|
64
|
+
last = b.nodes[-1].id
|
|
65
|
+
return first, last
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _emit_block_subgraph(block: OrgBlock, theme: dict, lines: List[str],
|
|
69
|
+
all_edges: set, is_right: bool, pad_w: float,
|
|
70
|
+
row_gap: float = 1.0):
|
|
71
|
+
lines.append(f'subgraph cluster_{block.id} {{')
|
|
72
|
+
lines.append(f'label="{block.label}"; fontsize=12; '
|
|
73
|
+
f'style="rounded,dashed"; color="{theme["card_border_color"]}"; '
|
|
74
|
+
f'bgcolor="{theme["cluster_bg"]}";')
|
|
75
|
+
in_blk = not is_right
|
|
76
|
+
heads = [n for n in block.nodes if n.is_cluster_head]
|
|
77
|
+
depts = [n for n in block.nodes if not n.is_cluster_head]
|
|
78
|
+
|
|
79
|
+
for n in heads:
|
|
80
|
+
_emit_node_def(n, theme, lines, all_edges, in_block=in_blk)
|
|
81
|
+
|
|
82
|
+
mc = block.max_columns or 0
|
|
83
|
+
if mc > 0 and len(depts) > mc and not is_right:
|
|
84
|
+
chunks = [depts[i:i + mc] for i in range(0, len(depts), mc)]
|
|
85
|
+
prev_anchor = None
|
|
86
|
+
for chunk in chunks:
|
|
87
|
+
anchor = f"_anc_{block.id}_{len(lines)}"
|
|
88
|
+
lines.append(f'"{anchor}" [shape=point, width=0, style=invis];')
|
|
89
|
+
if prev_anchor:
|
|
90
|
+
lines.append(f'"{prev_anchor}" -> "{anchor}" [style=invis, weight=100];')
|
|
91
|
+
lines.append(f'"{prev_anchor}" -> "{chunk[0].id}" [style=invis, minlen={row_gap}];')
|
|
92
|
+
lines.append('{ rank=same;')
|
|
93
|
+
lines.append(f'"{anchor}";')
|
|
94
|
+
for n in chunk:
|
|
95
|
+
_emit_node_def(n, theme, lines, all_edges, in_block=in_blk)
|
|
96
|
+
lines.append('}')
|
|
97
|
+
lines.append(f'"{anchor}" -> "{chunk[0].id}" [style=invis, weight=100];')
|
|
98
|
+
for i in range(len(chunk) - 1):
|
|
99
|
+
lines.append(f'"{chunk[i].id}" -> "{chunk[i+1].id}" [style=invis, weight=10];')
|
|
100
|
+
prev_anchor = anchor
|
|
101
|
+
else:
|
|
102
|
+
for n in depts:
|
|
103
|
+
_emit_node_def(n, theme, lines, all_edges, in_block=in_blk)
|
|
104
|
+
lines.append('}')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def generate_org_dot(chart: OrgChart, paper_size: Optional[str] = None,
|
|
108
|
+
dpi: Optional[int] = None) -> str:
|
|
109
|
+
theme = THEMES.get(chart.meta.theme, THEMES["corporate"])
|
|
110
|
+
layout = chart.meta.layout
|
|
111
|
+
nodesep = layout.nodesep if layout else theme["nodesep"]
|
|
112
|
+
ranksep = layout.ranksep if layout else theme["ranksep"]
|
|
113
|
+
pad_w = layout.block_pad if layout else 0.15
|
|
114
|
+
row_gap_cfg = layout.row_gap if layout else 1.0
|
|
115
|
+
row_gap = max(1, round(row_gap_cfg))
|
|
116
|
+
def _to_minlen(v):
|
|
117
|
+
return max(1, round(v)) if v is not None else row_gap
|
|
118
|
+
global_max_cols = layout.max_columns if layout else 0
|
|
119
|
+
|
|
120
|
+
has_edge_from = any(b.edge_from for g in chart.groups for b in g.blocks)
|
|
121
|
+
compound = ' compound=true;' if has_edge_from else ''
|
|
122
|
+
|
|
123
|
+
lines = ['digraph G {']
|
|
124
|
+
lines.append(f'label="{chart.meta.title}"; labelloc=t; fontsize=14;')
|
|
125
|
+
lines.append(f'rankdir=TB; splines=ortho; newrank=true; center=true; ordering=out;{compound}')
|
|
126
|
+
ps = parse_paper_size(paper_size) if paper_size else None
|
|
127
|
+
if ps:
|
|
128
|
+
lines.append(f'size="{ps["size"]}"; ratio={ps["ratio"]}; margin=0;')
|
|
129
|
+
lines.append(f'ranksep=0.3; nodesep=0.15;')
|
|
130
|
+
else:
|
|
131
|
+
lines.append(f'ranksep={ranksep}; nodesep={nodesep};')
|
|
132
|
+
lines.append(f'dpi={dpi};') if dpi else None
|
|
133
|
+
lines.append(f'node [shape=plaintext fontname="{theme["node_default"]["fontname"]}" '
|
|
134
|
+
f'fontsize={theme["node_default"]["fontsize"]}];')
|
|
135
|
+
lines.append(f'edge [color="{theme["edge_color"]}" penwidth={theme["edge_penwidth"]} arrowsize=0.8];')
|
|
136
|
+
|
|
137
|
+
all_edges = set()
|
|
138
|
+
gap_edges = []
|
|
139
|
+
|
|
140
|
+
for group in chart.groups:
|
|
141
|
+
left_blocks = [b for b in group.blocks if b.position == "left"]
|
|
142
|
+
right_blocks = [b for b in group.blocks if b.position == "right"]
|
|
143
|
+
bottom_blocks = [b for b in group.blocks if b.position == "bottom"]
|
|
144
|
+
max_rc = group.max_right_columns or 0
|
|
145
|
+
|
|
146
|
+
# ── side blocks 子图 (left + right) ──
|
|
147
|
+
lb_nodes, rb_nodes = [], []
|
|
148
|
+
for b in left_blocks + right_blocks:
|
|
149
|
+
brg = _to_minlen(b.row_gap)
|
|
150
|
+
_emit_block_subgraph(b, theme, lines, all_edges, is_right=True, pad_w=pad_w, row_gap=brg)
|
|
151
|
+
if b.edge_from and b.nodes:
|
|
152
|
+
if b.position == "left":
|
|
153
|
+
lines.append(f'"{b.edge_from}" -> "{b.nodes[0].id}";')
|
|
154
|
+
else:
|
|
155
|
+
lines.append(f'"{b.edge_from}" -> "{b.nodes[0].id}" [lhead=cluster_{b.id}];')
|
|
156
|
+
lb_nodes = [n for b in left_blocks for n in b.nodes]
|
|
157
|
+
rb_nodes = [n for b in right_blocks for n in b.nodes]
|
|
158
|
+
|
|
159
|
+
# ── 主节点 + right_nodes rank ──
|
|
160
|
+
m_ids = [n.id for n in group.nodes]
|
|
161
|
+
r_ids = [n.id for n in group.right_nodes]
|
|
162
|
+
|
|
163
|
+
if group.offset and group.offset > 0:
|
|
164
|
+
sid = f"_shift_L{group.level}"
|
|
165
|
+
lines.append(f'"{sid}" [shape=point, width={group.offset}, style=invis];')
|
|
166
|
+
first = m_ids[0] if m_ids else (r_ids[0] if r_ids else None)
|
|
167
|
+
if first:
|
|
168
|
+
lines.append(f'"{sid}" -> "{first}" [style=invis, weight=100];')
|
|
169
|
+
m_ids = [sid] + m_ids
|
|
170
|
+
|
|
171
|
+
if max_rc > 0 and len(group.right_nodes) > max_rc:
|
|
172
|
+
if m_ids:
|
|
173
|
+
_emit_ids_rank(m_ids, lines)
|
|
174
|
+
r_chunks = [r_ids[i:i + max_rc] for i in range(0, len(r_ids), max_rc)]
|
|
175
|
+
prev_last = m_ids[-1] if m_ids else None
|
|
176
|
+
for chunk in r_chunks:
|
|
177
|
+
prev_last = _emit_ids_rank(chunk, lines, prev_last)
|
|
178
|
+
else:
|
|
179
|
+
if m_ids + r_ids and not (lb_nodes or rb_nodes):
|
|
180
|
+
ids = list(m_ids + r_ids)
|
|
181
|
+
nsep = group.nodesep if group.nodesep is not None else (layout.nodesep if layout else None)
|
|
182
|
+
if nsep and nsep > 0 and len(ids) > 1:
|
|
183
|
+
spaced = [ids[0]]
|
|
184
|
+
for k in range(1, len(ids)):
|
|
185
|
+
gid = f"_gap_{group.level}_{k}"
|
|
186
|
+
lines.append(f'"{gid}" [shape=point, width={nsep}, style=invis];')
|
|
187
|
+
spaced.append(gid)
|
|
188
|
+
spaced.append(ids[k])
|
|
189
|
+
_emit_ids_rank(spaced, lines)
|
|
190
|
+
else:
|
|
191
|
+
_emit_ids_rank(ids, lines)
|
|
192
|
+
|
|
193
|
+
# ── left blocks rank 约束 ──
|
|
194
|
+
if lb_nodes:
|
|
195
|
+
lb_ids = [n.id for n in lb_nodes]
|
|
196
|
+
lb_mc = left_blocks[0].max_columns if left_blocks else 0
|
|
197
|
+
if lb_mc > 0 and len(lb_ids) > lb_mc:
|
|
198
|
+
chunks = [lb_ids[i:i + lb_mc] for i in range(0, len(lb_ids), lb_mc)]
|
|
199
|
+
prev_last = None
|
|
200
|
+
for ci, chunk in enumerate(chunks):
|
|
201
|
+
ids = chunk
|
|
202
|
+
if ci == len(chunks) - 1 and (m_ids or r_ids):
|
|
203
|
+
ids = ids + (m_ids if m_ids else r_ids)
|
|
204
|
+
# 只输出 rank=same,不加不可见边(靠自然排序)
|
|
205
|
+
lines.append('{ rank=same; ' + ' '.join(f'"{x}"' for x in ids) + ' }')
|
|
206
|
+
if prev_last:
|
|
207
|
+
lines.append(f'"{prev_last}" -> "{chunk[0]}" [style=invis, minlen={row_gap}];')
|
|
208
|
+
prev_last = chunk[-1]
|
|
209
|
+
else:
|
|
210
|
+
if m_ids or r_ids:
|
|
211
|
+
ids = lb_ids + (m_ids if m_ids else r_ids)
|
|
212
|
+
else:
|
|
213
|
+
ids = lb_ids
|
|
214
|
+
lines.append('{ rank=same; ' + ' '.join(f'"{x}"' for x in ids) + ' }')
|
|
215
|
+
|
|
216
|
+
# ── right blocks: 同 rank=same 但不加行内边 (交给 Graphviz) ──
|
|
217
|
+
if rb_nodes:
|
|
218
|
+
rb_ids = [n.id for n in rb_nodes]
|
|
219
|
+
rb_mc = right_blocks[0].max_columns if right_blocks else 0
|
|
220
|
+
rb_gap = _to_minlen(right_blocks[0].row_gap) if right_blocks else row_gap
|
|
221
|
+
all_rb_ids = list(rb_ids)
|
|
222
|
+
if rb_mc > 0 and len(rb_ids) > rb_mc:
|
|
223
|
+
chunks = [rb_ids[i:i + rb_mc] for i in range(0, len(rb_ids), rb_mc)]
|
|
224
|
+
prev_last = None
|
|
225
|
+
for ci, chunk in enumerate(chunks):
|
|
226
|
+
ids = chunk
|
|
227
|
+
if ci == 0 and (m_ids or r_ids):
|
|
228
|
+
ids = (m_ids if m_ids else r_ids) + chunk
|
|
229
|
+
lines.append('{ rank=same; ' + ' '.join(f'"{x}"' for x in ids) + ' }')
|
|
230
|
+
if prev_last:
|
|
231
|
+
lines.append(f'"{prev_last}" -> "{chunk[0]}" [style=invis, minlen={rb_gap}];')
|
|
232
|
+
prev_last = chunk[-1]
|
|
233
|
+
else:
|
|
234
|
+
ids = (m_ids if m_ids else r_ids) + rb_ids if (m_ids or r_ids) else rb_ids
|
|
235
|
+
lines.append('{ rank=same; ' + ' '.join(f'"{x}"' for x in ids) + ' }')
|
|
236
|
+
|
|
237
|
+
# ── bottom blocks ──
|
|
238
|
+
if bottom_blocks:
|
|
239
|
+
max_cols = group.max_columns if group.max_columns is not None else global_max_cols
|
|
240
|
+
eff_cols = max_cols if max_cols > 0 else len(bottom_blocks)
|
|
241
|
+
chunks = [bottom_blocks[i:i + eff_cols] for i in range(0, len(bottom_blocks), eff_cols)]
|
|
242
|
+
prev_row_heads = []
|
|
243
|
+
for chunk in chunks:
|
|
244
|
+
row_heads = [n for b in chunk for n in b.nodes if n.is_cluster_head]
|
|
245
|
+
if not row_heads:
|
|
246
|
+
# 无 cluster head → 取每个 block 首节点对齐
|
|
247
|
+
row_heads = [b.nodes[0] for b in chunk if b.nodes]
|
|
248
|
+
if row_heads:
|
|
249
|
+
off_w = group.offset if group.offset else 0
|
|
250
|
+
gap_w = group.nodesep if group.nodesep is not None else (layout.nodesep if layout else 0)
|
|
251
|
+
p_l = f"_bal_L{group.level}"
|
|
252
|
+
p_r = f"_bal_R{group.level}"
|
|
253
|
+
lines.append(f'"{p_l}" [shape=point, width={off_w}, style=invis];')
|
|
254
|
+
lines.append(f'"{p_r}" [shape=point, width={off_w}, style=invis];')
|
|
255
|
+
# 插入间隔节点控制 block 间距
|
|
256
|
+
rank_ids = [p_l]
|
|
257
|
+
for j, h in enumerate(row_heads):
|
|
258
|
+
rank_ids.append(h.id)
|
|
259
|
+
if j < len(row_heads) - 1 and gap_w > 0:
|
|
260
|
+
gid = f"_gap_{group.level}_{j}"
|
|
261
|
+
lines.append(f'"{gid}" [shape=point, width={gap_w}, style=invis];')
|
|
262
|
+
rank_ids.append(gid)
|
|
263
|
+
rank_ids.append(p_r)
|
|
264
|
+
_emit_ids_rank(rank_ids, lines)
|
|
265
|
+
if prev_row_heads and row_heads:
|
|
266
|
+
lines.append(f'"{prev_row_heads[-1].id}" -> "{row_heads[0].id}" [style=invis, weight=100];')
|
|
267
|
+
prev_row_heads = row_heads
|
|
268
|
+
for b in chunk:
|
|
269
|
+
brg = _to_minlen(b.row_gap)
|
|
270
|
+
_emit_block_subgraph(b, theme, lines, all_edges, is_right=False, pad_w=pad_w, row_gap=brg)
|
|
271
|
+
if b.edge_from and b.nodes:
|
|
272
|
+
lines.append(f'"{b.edge_from}" -> "{b.nodes[0].id}" [lhead=cluster_{b.id}];')
|
|
273
|
+
|
|
274
|
+
# ── reports_to edges ──
|
|
275
|
+
for group in chart.groups:
|
|
276
|
+
for node in group.nodes + group.right_nodes:
|
|
277
|
+
if node.reports_to:
|
|
278
|
+
all_edges.add((node.reports_to, node.id))
|
|
279
|
+
|
|
280
|
+
# ── gap edges ──
|
|
281
|
+
for i in range(len(chart.groups) - 1):
|
|
282
|
+
g_curr, g_next = chart.groups[i], chart.groups[i + 1]
|
|
283
|
+
if g_curr.gap:
|
|
284
|
+
_, last_id = _group_anchor_ids(g_curr)
|
|
285
|
+
next_ids = [n.id for n in g_next.nodes + g_next.right_nodes]
|
|
286
|
+
for b in g_next.blocks:
|
|
287
|
+
next_ids.extend(n.id for n in b.nodes if n.is_cluster_head)
|
|
288
|
+
first_id = next_ids[len(next_ids) // 2] if next_ids else None
|
|
289
|
+
if not first_id:
|
|
290
|
+
first_id, _ = _group_anchor_ids(g_next)
|
|
291
|
+
if last_id and first_id:
|
|
292
|
+
gap_edges.append((last_id, first_id, g_curr.gap))
|
|
293
|
+
|
|
294
|
+
for src, dst in all_edges:
|
|
295
|
+
lines.append(f'"{src}" -> "{dst}";')
|
|
296
|
+
for src, dst, gap in gap_edges:
|
|
297
|
+
lines.append(f'"{src}" -> "{dst}" [style=invis, minlen={gap}];')
|
|
298
|
+
|
|
299
|
+
lines.append('}')
|
|
300
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
HTML_TEMPLATE = """
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html>
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<title>{{ title }}</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { margin: 0; display: flex; justify-content: center; background: #f5f5f5; }
|
|
9
|
+
.container { max-width: 100%; }
|
|
10
|
+
svg { max-height: 100vh; }
|
|
11
|
+
.node:hover { filter: brightness(0.95); cursor: pointer; }
|
|
12
|
+
.edge:hover { stroke-width: 2.5; }
|
|
13
|
+
text { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<div class="container">
|
|
18
|
+
{{ svg_content | safe }}
|
|
19
|
+
</div>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def generate_html(svg_path: str, title: str, output_html: str):
|
|
25
|
+
with open(svg_path, 'r', encoding='utf-8') as f:
|
|
26
|
+
svg = f.read()
|
|
27
|
+
from jinja2 import Template
|
|
28
|
+
template = Template(HTML_TEMPLATE)
|
|
29
|
+
html = template.render(title=title, svg_content=svg)
|
|
30
|
+
with open(output_html, 'w', encoding='utf-8') as f:
|
|
31
|
+
f.write(html)
|