Setting the file. One moment. Generator · Azure Architecture Autopilot · github/awesome-copilot · Skills Docsscripts/generator.py
scripts/generator.py
Python·3,047 lines·137 KB
# icon: 48x48 viewBox SVG path (fallback)
15# azure_icon_key: key in icons.py (official Azure icon)
16SERVICE_ICONS = {
17 "openai": {
18 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#0078D4"/><text x="24" y="30" text-anchor="middle" font-size="18" fill="white" font-weight="700">AI</text>',
19 "color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
20 "azure_icon_key": "azure_openai"
21 },
22 "ai_foundry": {
23 "icon_svg": '<rect x="6" y="10" width="36" height="28" rx="4" fill="#0078D4"/><rect x="12" y="16" width="10" height="8" rx="2" fill="white" opacity="0.9"/><rect x="26" y="16" width="10" height="8" rx="2" fill="white" opacity="0.9"/><rect x="12" y="27" width="24" height="5" rx="2" fill="white" opacity="0.6"/>',
24 "color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
25 "azure_icon_key": "ai_foundry"
26 },
27 "ai_hub": {
28 "icon_svg": '<rect x="6" y="10" width="36" height="28" rx="4" fill="#0078D4"/><circle cx="24" cy="24" r="8" fill="white" opacity="0.9"/><circle cx="24" cy="24" r="4" fill="#0078D4"/>',
29 "color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
30 "azure_icon_key": "machine_learning"
31 },
32 "search": {
33 "icon_svg": '<circle cx="20" cy="20" r="12" fill="none" stroke="#0078D4" stroke-width="3.5"/><line x1="29" y1="29" x2="40" y2="40" stroke="#0078D4" stroke-width="3.5" stroke-linecap="round"/><circle cx="20" cy="20" r="5" fill="#0078D4" opacity="0.3"/>',
34 "color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
35 "azure_icon_key": "cognitive_search"
36 },
37 "ai_search": {
38 "icon_svg": '<circle cx="20" cy="20" r="12" fill="none" stroke="#0078D4" stroke-width="3.5"/><line x1="29" y1="29" x2="40" y2="40" stroke="#0078D4" stroke-width="3.5" stroke-linecap="round"/><circle cx="20" cy="20" r="5" fill="#0078D4" opacity="0.3"/>',
39 "color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
40 "azure_icon_key": "cognitive_search"
41 },
42 "aml": {
43 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><path d="M14 32 L20 18 L26 26 L32 14" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
44 "color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
45 "azure_icon_key": "machine_learning"
46 },
47 "storage": {
48 "icon_svg": '<rect x="8" y="8" width="32" height="8" rx="3" fill="#0078D4"/><rect x="8" y="20" width="32" height="8" rx="3" fill="#0078D4" opacity="0.7"/><rect x="8" y="32" width="32" height="8" rx="3" fill="#0078D4" opacity="0.4"/>',
49 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
50 "azure_icon_key": "storage_accounts"
51 },
52 "adls": {
53 "icon_svg": '<rect x="8" y="8" width="32" height="8" rx="3" fill="#0078D4"/><rect x="8" y="20" width="32" height="8" rx="3" fill="#0078D4" opacity="0.7"/><rect x="8" y="32" width="32" height="8" rx="3" fill="#0078D4" opacity="0.4"/>',
54 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
55 "azure_icon_key": "data_lake_storage_gen1"
56 },
57 "fabric": {
58 "icon_svg": '<polygon points="24,6 42,18 42,34 24,46 6,34 6,18" fill="#E8740C" opacity="0.9"/><text x="24" y="30" text-anchor="middle" font-size="14" fill="white" font-weight="700">F</text>',
59 "color": "#E8740C", "bg": "#FEF3E8", "category": "Data",
60 "azure_icon_key": "microsoft_fabric"
61 },
62 "synapse": {
63 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#0078D4"/><path d="M15 24 L24 15 L33 24 L24 33 Z" fill="white" opacity="0.9"/>',
64 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
65 "azure_icon_key": "azure_synapse_analytics"
66 },
67 "adf": {
68 "icon_svg": '<rect x="6" y="12" width="36" height="24" rx="4" fill="#0078D4"/><path d="M16 24 L28 24 M24 18 L30 24 L24 30" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
69 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
70 "azure_icon_key": "data_factory"
71 },
72 "data_factory": {
73 "icon_svg": '<rect x="6" y="12" width="36" height="24" rx="4" fill="#0078D4"/><path d="M16 24 L28 24 M24 18 L30 24 L24 30" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
74 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
75 "azure_icon_key": "data_factory"
76 },
77 "keyvault": {
78 "icon_svg": '<rect x="10" y="6" width="28" height="36" rx="4" fill="#E8A000"/><circle cx="24" cy="22" r="6" fill="white"/><rect x="22" y="26" width="4" height="10" rx="1" fill="white"/>',
79 "color": "#E8A000", "bg": "#FEF7E0", "category": "Security",
80 "azure_icon_key": "key_vaults"
81 },
82 "kv": {
83 "icon_svg": '<rect x="10" y="6" width="28" height="36" rx="4" fill="#E8A000"/><circle cx="24" cy="22" r="6" fill="white"/><rect x="22" y="26" width="4" height="10" rx="1" fill="white"/>',
84 "color": "#E8A000", "bg": "#FEF7E0", "category": "Security",
85 "azure_icon_key": "key_vaults"
86 },
87 "vnet": {
88 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="none" stroke="#5C2D91" stroke-width="2.5"/><circle cx="16" cy="18" r="4" fill="#5C2D91"/><circle cx="32" cy="18" r="4" fill="#5C2D91"/><circle cx="24" cy="32" r="4" fill="#5C2D91"/><line x1="16" y1="18" x2="32" y2="18" stroke="#5C2D91" stroke-width="1.5"/><line x1="16" y1="18" x2="24" y2="32" stroke="#5C2D91" stroke-width="1.5"/><line x1="32" y1="18" x2="24" y2="32" stroke="#5C2D91" stroke-width="1.5"/>',
89 "color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
90 "azure_icon_key": "virtual_networks"
91 },
92 "pe": {
93 "icon_svg": '<circle cx="24" cy="24" r="14" fill="none" stroke="#5C2D91" stroke-width="2"/><circle cx="24" cy="24" r="6" fill="#5C2D91"/><line x1="24" y1="10" x2="24" y2="4" stroke="#5C2D91" stroke-width="2"/><line x1="24" y1="38" x2="24" y2="44" stroke="#5C2D91" stroke-width="2"/>',
94 "color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
95 "azure_icon_key": "private_endpoints"
96 },
97 "nsg": {
98 "icon_svg": '<rect x="8" y="8" width="32" height="32" rx="4" fill="#5C2D91"/><path d="M18 20 L24 14 L30 20 M18 28 L24 34 L30 28" stroke="white" stroke-width="2" fill="none"/>',
99 "color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
100 "azure_icon_key": "network_security_groups"
101 },
102 "acr": {
103 "icon_svg": '<rect x="8" y="10" width="32" height="28" rx="4" fill="#0078D4"/><rect x="14" y="16" width="20" height="16" rx="2" fill="white" opacity="0.3"/><text x="24" y="30" text-anchor="middle" font-size="12" fill="white" font-weight="600">ACR</text>',
104 "color": "#0078D4", "bg": "#E8F4FD", "category": "Compute"
105 },
106 "aks": {
107 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#326CE5"/><text x="24" y="30" text-anchor="middle" font-size="16" fill="white" font-weight="700">K</text>',
108 "color": "#326CE5", "bg": "#EBF0FC", "category": "Compute",
109 "azure_icon_key": "kubernetes_services"
110 },
111 "appservice": {
112 "icon_svg": '<rect x="8" y="8" width="32" height="32" rx="6" fill="#0078D4"/><polygon points="24,14 34,34 14,34" fill="white" opacity="0.9"/>',
113 "color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
114 "azure_icon_key": "app_services"
115 },
116 "appinsights": {
117 "icon_svg": '<circle cx="24" cy="24" r="16" fill="#773ADC"/><path d="M16 28 L20 20 L24 24 L28 16 L32 22" stroke="white" stroke-width="2" fill="none" stroke-linecap="round"/>',
118 "color": "#773ADC", "bg": "#F0EAFA", "category": "Monitor",
119 "azure_icon_key": "application_insights"
120 },
121 "monitor": {
122 "icon_svg": '<rect x="6" y="10" width="36" height="24" rx="4" fill="#773ADC"/><path d="M14 28 L20 20 L26 24 L34 16" stroke="white" stroke-width="2" fill="none" stroke-linecap="round"/><rect x="14" y="36" width="20" height="3" rx="1" fill="#773ADC" opacity="0.5"/>',
123 "color": "#773ADC", "bg": "#F0EAFA", "category": "Monitor",
124 "azure_icon_key": "monitor"
125 },
126 "vm": {
127 "icon_svg": '<rect x="6" y="8" width="36" height="26" rx="3" fill="#0078D4"/><rect x="10" y="12" width="28" height="18" rx="1" fill="white" opacity="0.2"/><rect x="16" y="36" width="16" height="4" rx="1" fill="#0078D4"/>',
128 "color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
129 "azure_icon_key": "virtual_machine"
130 },
131 "bastion": {
132 "icon_svg": '<rect x="8" y="6" width="32" height="36" rx="4" fill="#5C2D91"/><rect x="14" y="12" width="20" height="14" rx="2" fill="white" opacity="0.3"/><circle cx="24" cy="34" r="4" fill="white" opacity="0.7"/>',
133 "color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
134 "azure_icon_key": "bastions"
135 },
136 "jumpbox": {
137 "icon_svg": '<rect x="8" y="8" width="32" height="32" rx="4" fill="#5C2D91"/><text x="24" y="30" text-anchor="middle" font-size="14" fill="white" font-weight="600">JB</text>',
138 "color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
139 "azure_icon_key": "virtual_machine"
140 },
141 "vpn": {
142 "icon_svg": '<rect x="6" y="12" width="36" height="24" rx="4" fill="#5C2D91"/><path d="M16 24 L24 16 L32 24 L24 32 Z" fill="white" opacity="0.8"/>',
143 "color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
144 "azure_icon_key": "virtual_network_gateways"
145 },
146 "user": {
147 "icon_svg": '<circle cx="24" cy="16" r="8" fill="#0078D4"/><path d="M10 42 Q10 30 24 30 Q38 30 38 42" fill="#0078D4"/>',
148 "color": "#0078D4", "bg": "#E8F4FD", "category": "External"
149 },
150 "app": {
151 "icon_svg": '<rect x="8" y="6" width="32" height="36" rx="6" fill="#666"/><rect x="14" y="12" width="20" height="20" rx="2" fill="white" opacity="0.3"/><circle cx="24" cy="40" r="2" fill="white" opacity="0.7"/>',
152 "color": "#666666", "bg": "#F5F5F5", "category": "External"
153 },
154 "default": {
155 "icon_svg": '<circle cx="24" cy="24" r="16" fill="#0078D4"/><text x="24" y="30" text-anchor="middle" font-size="14" fill="white" font-weight="600">?</text>',
156 "color": "#0078D4", "bg": "#E8F4FD", "category": "Azure"
157 },
158 "cdn": {
159 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#0078D4"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">CDN</text>',
160 "color": "#0078D4", "bg": "#E8F4FD", "category": "Network",
161 "azure_icon_key": "cdn_profiles"
162 },
163 "event_hub": {
164 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="8" fill="white" font-weight="700">Event</text><text x="24" y="33" text-anchor="middle" font-size="8" fill="white">Hub</text>',
165 "color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
166 "azure_icon_key": "event_hubs"
167 },
168 "redis": {
169 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#D83B01"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">Redis</text>',
170 "color": "#D83B01", "bg": "#FEF0E8", "category": "Data",
171 "azure_icon_key": "cache_redis"
172 },
173 "devops": {
174 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="8" fill="white" font-weight="700">Dev</text><text x="24" y="33" text-anchor="middle" font-size="8" fill="white">Ops</text>',
175 "color": "#0078D4", "bg": "#E8F4FD", "category": "DevOps",
176 "azure_icon_key": "azure_devops"
177 },
178 "acr": {
179 "icon_svg": '<rect x="8" y="10" width="32" height="28" rx="4" fill="#0078D4"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">ACR</text>',
180 "color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
181 "azure_icon_key": "container_registries"
182 },
183 "container_registry": {
184 "icon_svg": '<rect x="8" y="10" width="32" height="28" rx="4" fill="#0078D4"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">ACR</text>',
185 "color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
186 "azure_icon_key": "container_registries"
187 },
188 "app_gateway": {
189 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="8" fill="white" font-weight="700">App</text><text x="24" y="33" text-anchor="middle" font-size="8" fill="white">GW</text>',
190 "color": "#0078D4", "bg": "#E8F4FD", "category": "Network",
191 "azure_icon_key": "application_gateways"
192 },
193 "iot_hub": {
194 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="8" fill="white" font-weight="700">IoT</text><text x="24" y="33" text-anchor="middle" font-size="8" fill="white">Hub</text>',
195 "color": "#0078D4", "bg": "#E8F4FD", "category": "IoT",
196 "azure_icon_key": "iot_hub"
197 },
198 "stream_analytics": {
199 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="7" fill="white" font-weight="700">Stream</text><text x="24" y="33" text-anchor="middle" font-size="7" fill="white">Analytics</text>',
200 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
201 "azure_icon_key": "stream_analytics_jobs"
202 },
203 "vpn_gateway": {
204 "icon_svg": '<rect x="6" y="12" width="36" height="24" rx="4" fill="#5C2D91"/><path d="M16 24 L24 16 L32 24 L24 32 Z" fill="white" opacity="0.8"/>',
205 "color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
206 "azure_icon_key": "virtual_network_gateways"
207 },
208 "front_door": {
209 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="7" fill="white" font-weight="700">Front</text><text x="24" y="33" text-anchor="middle" font-size="7" fill="white">Door</text>',
210 "color": "#0078D4", "bg": "#E8F4FD", "category": "Network",
211 "azure_icon_key": "front_door_and_cdn_profiles"
212 },
213 "ai_hub": {
214 "icon_svg": '<rect x="6" y="10" width="36" height="28" rx="4" fill="#0078D4"/><circle cx="24" cy="24" r="8" fill="white" opacity="0.9"/><circle cx="24" cy="24" r="4" fill="#0078D4"/>',
215 "color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
216 "azure_icon_key": "ai_studio"
217 },
218 "firewall": {
219 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#E8A000"/><text x="24" y="22" text-anchor="middle" font-size="7" fill="white" font-weight="700">Fire</text><text x="24" y="33" text-anchor="middle" font-size="7" fill="white">wall</text>',
220 "color": "#E8A000", "bg": "#FFF8E1", "category": "Network",
221 "azure_icon_key": "firewalls"
222 },
223 "document_intelligence": {
224 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="9" fill="white" font-weight="700">Doc</text><text x="24" y="33" text-anchor="middle" font-size="9" fill="white">Intel</text>',
225 "color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
226 "azure_icon_key": "form_recognizer"
227 },
228 "form_recognizer": {
229 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="9" fill="white" font-weight="700">Doc</text><text x="24" y="33" text-anchor="middle" font-size="9" fill="white">Intel</text>',
230 "color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
231 "azure_icon_key": "form_recognizer"
232 },
233 "databricks": {
234 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="6" fill="#FF3621"/><text x="24" y="30" text-anchor="middle" font-size="16" fill="white" font-weight="700">DB</text>',
235 "color": "#FF3621", "bg": "#FFF0EE", "category": "Data",
236 "azure_icon_key": "azure_databricks"
237 },
238 "sql_server": {
239 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="11" fill="white" font-weight="700">SQL</text><rect x="12" y="28" width="24" height="8" rx="2" fill="white" opacity="0.3"/>',
240 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
241 "azure_icon_key": "sql_server"
242 },
243 "sql_database": {
244 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="11" fill="white" font-weight="700">SQL</text><rect x="12" y="28" width="24" height="8" rx="2" fill="white" opacity="0.3"/>',
245 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
246 "azure_icon_key": "sql_database"
247 },
248 "cosmos_db": {
249 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="9" fill="white" font-weight="700">Cosmos</text><text x="24" y="33" text-anchor="middle" font-size="9" fill="white">DB</text>',
250 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
251 "azure_icon_key": "azure_cosmos_db"
252 },
253 "app_service": {
254 "icon_svg": '<rect x="6" y="10" width="36" height="28" rx="6" fill="#0078D4"/><text x="24" y="28" text-anchor="middle" font-size="11" fill="white" font-weight="700">App</text>',
255 "color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
256 "azure_icon_key": "app_services"
257 },
258 "aks": {
259 "icon_svg": '<polygon points="24,4 44,20 38,44 10,44 4,20" fill="#326CE5" stroke="#fff" stroke-width="1"/><text x="24" y="30" text-anchor="middle" font-size="11" fill="white" font-weight="700">K8s</text>',
260 "color": "#326CE5", "bg": "#EBF0FA", "category": "Compute",
261 "azure_icon_key": "kubernetes_services"
262 },
263 "function_app": {
264 "icon_svg": '<polygon points="24,6 42,42 6,42" fill="#F0AD4E"/><text x="24" y="36" text-anchor="middle" font-size="14" fill="white" font-weight="700">ƒ</text>',
265 "color": "#F0AD4E", "bg": "#FFF8ED", "category": "Compute",
266 "azure_icon_key": "function_apps"
267 },
268 "synapse": {
269 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#0078D4"/><text x="24" y="22" text-anchor="middle" font-size="8" fill="white" font-weight="700">Syn</text><text x="24" y="32" text-anchor="middle" font-size="8" fill="white">apse</text>',
270 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
271 "azure_icon_key": "azure_synapse_analytics"
272 },
273 "log_analytics": {
274 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#5C2D91"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">Log</text>',
275 "color": "#5C2D91", "bg": "#F3EDF7", "category": "Monitoring",
276 "azure_icon_key": "log_analytics_workspaces"
277 },
278 "app_insights": {
279 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#5C2D91"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">AI</text>',
280 "color": "#5C2D91", "bg": "#F3EDF7", "category": "Monitoring",
281 "azure_icon_key": "application_insights"
282 },
283 "nsg": {
284 "icon_svg": '<rect x="6" y="6" width="36" height="36" rx="4" fill="#E8A000"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">NSG</text>',
285 "color": "#E8A000", "bg": "#FFF8E1", "category": "Network",
286 "azure_icon_key": "network_security_groups"
287 },
288 "apim": {
289 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><path d="M16 20 L32 20 M16 28 L32 28 M24 14 L24 34" stroke="white" stroke-width="2" fill="none" stroke-linecap="round"/>',
290 "color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
291 "azure_icon_key": "api_management_services"
292 },
293 "api_management": {
294 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><path d="M16 20 L32 20 M16 28 L32 28 M24 14 L24 34" stroke="white" stroke-width="2" fill="none" stroke-linecap="round"/>',
295 "color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
296 "azure_icon_key": "api_management_services"
297 },
298 "service_bus": {
299 "icon_svg": '<rect x="6" y="10" width="36" height="28" rx="4" fill="#0078D4"/><path d="M14 24 L22 24 M26 24 L34 24" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/><circle cx="24" cy="24" r="4" fill="white"/>',
300 "color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
301 "azure_icon_key": "azure_service_bus"
302 },
303 "logic_apps": {
304 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><path d="M14 18 L24 28 L34 18" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
305 "color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
306 "azure_icon_key": "logic_apps"
307 },
308 "logic_app": {
309 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><path d="M14 18 L24 28 L34 18" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
310 "color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
311 "azure_icon_key": "logic_apps"
312 },
313 "event_grid": {
314 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><circle cx="16" cy="18" r="3" fill="white"/><circle cx="32" cy="18" r="3" fill="white"/><circle cx="16" cy="30" r="3" fill="white"/><circle cx="32" cy="30" r="3" fill="white"/><line x1="16" y1="18" x2="32" y2="30" stroke="white" stroke-width="1.5"/><line x1="32" y1="18" x2="16" y2="30" stroke="white" stroke-width="1.5"/>',
315 "color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
316 "azure_icon_key": "event_grid_topics"
317 },
318 "container_apps": {
319 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><rect x="12" y="14" width="10" height="10" rx="2" fill="white" opacity="0.9"/><rect x="26" y="14" width="10" height="10" rx="2" fill="white" opacity="0.9"/><rect x="12" y="28" width="24" height="6" rx="2" fill="white" opacity="0.6"/>',
320 "color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
321 "azure_icon_key": "container_apps_environments"
322 },
323 "container_app": {
324 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><rect x="12" y="14" width="10" height="10" rx="2" fill="white" opacity="0.9"/><rect x="26" y="14" width="10" height="10" rx="2" fill="white" opacity="0.9"/><rect x="12" y="28" width="24" height="6" rx="2" fill="white" opacity="0.6"/>',
325 "color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
326 "azure_icon_key": "container_apps_environments"
327 },
328 "postgresql": {
329 "icon_svg": '<rect x="8" y="8" width="32" height="32" rx="4" fill="#0078D4"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">PG</text>',
330 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
331 "azure_icon_key": "azure_database_postgresql_server"
332 },
333 "mysql": {
334 "icon_svg": '<rect x="8" y="8" width="32" height="32" rx="4" fill="#0078D4"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">My</text>',
335 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
336 "azure_icon_key": "azure_database_mysql_server"
337 },
338 "load_balancer": {
339 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#5C2D91"/><path d="M16 18 L32 18 M16 24 L32 24 M16 30 L32 30" stroke="white" stroke-width="2" fill="none" stroke-linecap="round"/>',
340 "color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
341 "azure_icon_key": "load_balancers"
342 },
343 "nat_gateway": {
344 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#5C2D91"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">NAT</text>',
345 "color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
346 "azure_icon_key": "nat"
347 },
348 "expressroute": {
349 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#5C2D91"/><path d="M14 24 L34 24" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><circle cx="14" cy="24" r="4" fill="white"/><circle cx="34" cy="24" r="4" fill="white"/>',
350 "color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
351 "azure_icon_key": "expressroute_circuits"
352 },
353 "sentinel": {
354 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#0078D4"/><path d="M24 12 L24 24 L32 28" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="24" cy="24" r="3" fill="white"/>',
355 "color": "#0078D4", "bg": "#E8F4FD", "category": "Security",
356 "azure_icon_key": "azure_sentinel"
357 },
358 "data_explorer": {
359 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><path d="M14 30 L20 18 L26 26 L34 14" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
360 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
361 "azure_icon_key": "azure_data_explorer_clusters"
362 },
363 "kusto": {
364 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><path d="M14 30 L20 18 L26 26 L34 14" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
365 "color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
366 "azure_icon_key": "azure_data_explorer_clusters"
367 },
368 "signalr": {
369 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#0078D4"/><path d="M16 20 Q24 12 32 20 M16 28 Q24 36 32 28" stroke="white" stroke-width="2" fill="none" stroke-linecap="round"/>',
370 "color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
371 "azure_icon_key": "signalr"
372 },
373 "notification_hub": {
374 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><path d="M18 16 L24 12 L30 16 L30 28 L18 28 Z" stroke="white" stroke-width="2" fill="white" opacity="0.9"/><circle cx="24" cy="32" r="3" fill="white"/>',
375 "color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
376 "azure_icon_key": "notification_hubs"
377 },
378 "spring_apps": {
379 "icon_svg": '<circle cx="24" cy="24" r="18" fill="#6DB33F"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">🌱</text>',
380 "color": "#6DB33F", "bg": "#EFF8E8", "category": "Compute",
381 "azure_icon_key": "azure_spring_apps"
382 },
383 "static_web_app": {
384 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><text x="24" y="28" text-anchor="middle" font-size="10" fill="white" font-weight="700">SWA</text>',
385 "color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
386 "azure_icon_key": "static_apps"
387 },
388 "digital_twins": {
389 "icon_svg": '<rect x="6" y="8" width="36" height="32" rx="4" fill="#0078D4"/><circle cx="18" cy="20" r="5" fill="white" opacity="0.9"/><circle cx="30" cy="20" r="5" fill="white" opacity="0.9"/><line x1="18" y1="25" x2="18" y2="34" stroke="white" stroke-width="2"/><line x1="30" y1="25" x2="30" y2="34" stroke="white" stroke-width="2"/>',
390 "color": "#0078D4", "bg": "#E8F4FD", "category": "IoT",
391 "azure_icon_key": "digital_twins"
392 },
393 "backup": {
394 "icon_svg": '<rect x="8" y="8" width="32" height="32" rx="4" fill="#0078D4"/><path d="M16 28 L24 16 L32 28 Z" stroke="white" stroke-width="2" fill="white" opacity="0.8"/>',
395 "color": "#0078D4", "bg": "#E8F4FD", "category": "Management",
396 "azure_icon_key": "backup_vault"
397 },
398}
399
400CONNECTION_STYLES = {
401 "api": {"color": "#0078D4", "dash": "0"},
402 "data": {"color": "#0F9D58", "dash": "0"},
403 "security": {"color": "#E8A000", "dash": "5,5"},
404 "private": {"color": "#5C2D91", "dash": "3,3"},
405 "network": {"color": "#5C2D91", "dash": "5,5"},
406 "default": {"color": "#999999", "dash": "0"},
407}
408
409
410_TYPE_ALIASES = {
411 # Azure ARM resource names → canonical diagram type
412 # Network
413 "private_endpoints": "pe", "private_endpoint": "pe",
414 "virtual_networks": "vnet", "virtual_network": "vnet",
415 "network_security_groups": "nsg", "network_security_group": "nsg",
416 "bastion_hosts": "bastion", "bastion_host": "bastion",
417 "application_gateways": "app_gateway", "application_gateway": "app_gateway",
418 "front_doors": "front_door", "front_door_and_cdn_profiles": "front_door",
419 "virtual_network_gateways": "vpn", "vpn_gateways": "vpn",
420 "load_balancers": "load_balancer",
421 "nat_gateways": "nat_gateway",
422 "expressroute_circuits": "expressroute",
423 "firewalls": "firewall",
424 "cdn_profiles": "cdn",
425 # Data
426 "data_factories": "adf", "data_factory": "adf",
427 "storage_accounts": "storage", "storage_account": "storage",
428 "data_lake": "adls", "adls_gen2": "adls", "data_lake_storage": "adls",
429 "fabric_capacities": "fabric", "fabric_capacity": "fabric", "microsoft_fabric": "fabric",
430 "synapse_workspaces": "synapse", "synapse_workspace": "synapse", "synapse_analytics": "synapse",
431 "cosmos": "cosmos_db", "cosmosdb": "cosmos_db", "documentdb": "cosmos_db",
432 "sql_databases": "sql_database", "sql_db": "sql_database",
433 "sql_servers": "sql_server",
434 "redis_caches": "redis", "redis_cache": "redis", "cache_redis": "redis",
435 "stream_analytics_jobs": "stream_analytics",
436 "databricks_workspaces": "databricks",
437 "data_explorer_clusters": "data_explorer", "azure_data_explorer": "data_explorer",
438 "postgresql_server": "postgresql", "postgresql_servers": "postgresql",
439 "mysql_server": "mysql", "mysql_servers": "mysql",
440 # AI
441 "cognitive_services": "ai_foundry", "ai_services": "ai_foundry", "foundry": "ai_foundry",
442 "azure_openai": "openai",
443 "cognitive_search": "search", "search_services": "search", "search_service": "search",
444 "machine_learning": "aml", "ml": "aml", "machine_learning_workspaces": "aml",
445 "form_recognizers": "document_intelligence",
446 "ai_studio": "ai_hub", "foundry_project": "ai_hub",
447 # Security
448 "key_vault": "keyvault", "key_vaults": "keyvault",
449 "sentinel": "sentinel", "azure_sentinel": "sentinel",
450 # Compute
451 "virtual_machines": "vm", "virtual_machine": "vm",
452 "app_services": "appservice", "web_apps": "appservice", "web_app": "appservice",
453 "function_apps": "function_app", "functions": "function_app",
454 "kubernetes_services": "aks", "managed_clusters": "aks", "kubernetes": "aks",
455 "container_registries": "acr",
456 "container_apps_environments": "container_apps",
457 "spring_apps": "spring_apps", "azure_spring_apps": "spring_apps",
458 "static_apps": "static_web_app", "static_web_apps": "static_web_app",
459 # Integration
460 "event_hubs": "event_hub",
461 "event_grid_topics": "event_grid", "event_grid_domains": "event_grid",
462 "api_management_services": "apim",
463 "service_bus_namespaces": "service_bus",
464 "logic_app": "logic_apps",
465 "notification_hubs": "notification_hub",
466 # Monitoring
467 "log_analytics_workspaces": "log_analytics",
468 "application_insights": "appinsights", "app_insight": "appinsights",
469 # IoT
470 "iot_hubs": "iot_hub",
471 # Management
472 "backup_vaults": "backup", "backup_vault": "backup",
473}
474
475def get_service_info(svc_type: str) -> dict:
476 t = svc_type.lower().replace("-", "_").replace(" ", "_")
477 t = _TYPE_ALIASES.get(t, t)
478 info = SERVICE_ICONS.get(t, SERVICE_ICONS["default"]).copy()
479 # Add official Azure icon data URI if available
480 azure_key = info.get("azure_icon_key", t)
481 icon_uri = get_icon_data_uri(azure_key)
482 info["icon_data_uri"] = icon_uri
483 return info
484
485
486def generate_html(services: list, connections: list, title: str, vnet_info: str = "", hierarchy: list = None) -> str:
487 def _norm(t):
488 t = t.lower().replace("-", "_").replace(" ", "_")
489 return _TYPE_ALIASES.get(t, t)
490
491 nodes_js = json.dumps([{
492 "id": svc["id"],
493 "name": svc["name"],
494 "type": _norm(svc.get("type", "default")),
495 "sku": svc.get("sku", ""),
496 "private": svc.get("private", False),
497 "details": svc.get("details", []),
498 "subscription": svc.get("subscription", ""),
499 "resourceGroup": svc.get("resourceGroup", ""),
500 "icon_svg": get_service_info(svc.get("type", "default"))["icon_svg"],
501 "icon_data_uri": get_service_info(svc.get("type", "default")).get("icon_data_uri", ""),
502 "color": get_service_info(svc.get("type", "default"))["color"],
503 "bg": get_service_info(svc.get("type", "default"))["bg"],
504 "category": get_service_info(svc.get("type", "default"))["category"],
505 } for svc in services], ensure_ascii=False)
506
507 hierarchy_js = json.dumps(hierarchy or [], ensure_ascii=False)
508
509 edges_js = json.dumps([{
510 "from": conn["from"],
511 "to": conn["to"],
512 "label": conn.get("label", ""),
513 "type": conn.get("type", "default"),
514 "color": CONNECTION_STYLES.get(conn.get("type", "default"), CONNECTION_STYLES["default"])["color"],
515 "dash": CONNECTION_STYLES.get(conn.get("type", "default"), CONNECTION_STYLES["default"])["dash"],
516 } for conn in connections], ensure_ascii=False)
517
518 pe_count = sum(1 for s in services if _norm(s.get("type", "default")) == "pe")
519 svc_count = len(services) - pe_count
520 generated_at = datetime.now().strftime("%Y-%m-%d %H:%M")
521 vnet_info_js = json.dumps(vnet_info, ensure_ascii=False)
522
523 html = f"""<!DOCTYPE html>
524<html lang="ko">
525<head>
526<meta charset="UTF-8">
527<meta name="viewport" content="width=device-width, initial-scale=1.0">
528<title>{title}</title>
529<style>
530 @import url('https://fonts.googleapis.com/css2?family=Segoe+UI:wght@400;600;700&family=Inter:wght@400;500;600&display=swap');
531 * {{ box-sizing: border-box; margin: 0; padding: 0; }}
532 body {{ font-family: 'Segoe UI', 'Inter', -apple-system, sans-serif; background: #f3f2f1; color: #323130; }}
533
534 .header {{
535 background: white; border-bottom: 1px solid #edebe9;
536 padding: 12px 24px; display: flex; align-items: center; gap: 14px;
537 box-shadow: 0 1px 4px rgba(0,0,0,0.06);
538 }}
539 .header-icon {{
540 width: 32px; height: 32px; border-radius: 4px;
541 background: linear-gradient(135deg, #0078D4, #00BCF2);
542 display: flex; align-items: center; justify-content: center;
543 }}
544 .header-icon svg {{ width: 20px; height: 20px; }}
545 .header h1 {{ font-size: 15px; font-weight: 600; color: #201f1e; }}
546 .header .meta {{ font-size: 11px; color: #a19f9d; }}
547 .header-right {{ margin-left: auto; display: flex; gap: 16px; align-items: center; }}
548 .stat {{ font-size: 11px; color: #605e5c; }}
549 .stat b {{ color: #323130; }}
550
551 .container {{ display: flex; height: calc(100vh - 56px); }}
552
553 .canvas-area {{
554 flex: 1; position: relative; overflow: hidden;
555 background: white;
556 background-image:
557 linear-gradient(#faf9f8 1px, transparent 1px),
558 linear-gradient(90deg, #faf9f8 1px, transparent 1px);
559 background-size: 24px 24px;
560 }}
561 #canvas {{ position: absolute; top: 0; left: 0; width: 100%; height: 100%; }}
562
563 .toolbar {{
564 position: absolute; top: 10px; left: 10px;
565 display: flex; gap: 1px; z-index: 10;
566 background: white; border: 1px solid #edebe9; border-radius: 6px;
567 padding: 2px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
568 }}
569 .tool-btn {{
570 background: transparent; border: none; border-radius: 4px;
571 padding: 5px 10px; font-size: 11px; cursor: pointer; color: #605e5c;
572 font-family: inherit; transition: all 0.1s;
573 }}
574 .tool-btn:hover {{ background: #f3f2f1; color: #323130; }}
575 .tool-sep {{ width: 1px; background: #edebe9; margin: 3px 1px; }}
576
577 .zoom-indicator {{
578 position: absolute; top: 10px; right: 286px;
579 background: white; border: 1px solid #edebe9; border-radius: 4px;
580 padding: 3px 8px; font-size: 10px; color: #a19f9d; z-index: 10;
581 }}
582
583 /* ── Sidebar ── */
584 .sidebar {{
585 width: 272px; background: #faf9f8; border-left: 1px solid #edebe9;
586 overflow-y: auto; display: flex; flex-direction: column;
587 }}
588 .sidebar::-webkit-scrollbar {{ width: 3px; }}
589 .sidebar::-webkit-scrollbar-thumb {{ background: #c8c6c4; border-radius: 3px; }}
590
591 .sidebar-header {{
592 padding: 12px 14px; border-bottom: 1px solid #edebe9;
593 font-weight: 600; font-size: 12px; color: #605e5c;
594 position: sticky; top: 0; background: #faf9f8; z-index: 1;
595 }}
596 .cat-label {{
597 padding: 10px 14px 4px; font-size: 10px; color: #a19f9d;
598 font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
599 }}
600 .service-card {{
601 margin: 2px 6px; border: 1px solid #edebe9; border-radius: 6px;
602 overflow: hidden; cursor: pointer; transition: all 0.1s;
603 background: white;
604 }}
605 .service-card:hover {{ border-color: #c8c6c4; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }}
606 .service-card.selected {{ border-color: #0078D4; box-shadow: 0 0 0 1px #0078D4; }}
607 .service-card-header {{
608 padding: 7px 10px; display: flex; align-items: center; gap: 8px;
609 }}
610 .sc-icon {{ width: 28px; height: 28px; flex-shrink: 0; }}
611 .sc-icon svg {{ width: 28px; height: 28px; }}
612 .service-name {{ font-size: 12px; font-weight: 600; color: #323130; }}
613 .service-sku {{ font-size: 10px; color: #a19f9d; }}
614 .service-card-body {{ padding: 2px 10px 6px; }}
615 .service-detail {{ font-size: 10px; color: #605e5c; padding: 1px 0; }}
616 .service-detail::before {{ content: "› "; color: #a19f9d; }}
617 .private-badge {{
618 font-size: 9px; background: #f3eef9; color: #5C2D91;
619 border-radius: 3px; padding: 1px 5px; margin-left: auto;
620 border: 1px solid #e0d4f5;
621 }}
622
623 .legend {{
624 padding: 10px 14px; border-top: 1px solid #edebe9; margin-top: auto;
625 }}
626 .legend-title {{ font-size: 10px; font-weight: 600; color: #a19f9d; margin-bottom: 5px; }}
627 .legend-item {{ display: flex; align-items: center; gap: 6px; font-size: 10px; color: #605e5c; margin-bottom: 2px; }}
628 .legend-line {{ width: 18px; height: 2px; border-radius: 1px; }}
629 .legend-line-dash {{ width: 18px; height: 0; border-top: 2px dashed; }}
630
631 /* ── SVG styles ── */
632 .node {{ cursor: grab; pointer-events: all; }}
633 .node:active {{ cursor: grabbing; }}
634 .node .node-bg {{ pointer-events: all; }}
635 .node.selected .node-bg {{ stroke: #0078D4; stroke-width: 2.5; }}
636 .node.selected {{ filter: drop-shadow(0 0 6px rgba(0,120,212,0.4)); }}
637
638 /* ── Edge highlight on node select ── */
639 .edge-path {{ transition: opacity 0.2s, stroke-width 0.2s; }}
640 .edge-label {{ transition: opacity 0.2s; }}
641 .edge-path.highlight {{ opacity: 1 !important; stroke-width: 2.5 !important; filter: drop-shadow(0 0 4px rgba(0,120,212,0.5)); }}
642 .edge-path.dimmed {{ opacity: 0.1 !important; }}
643 .edge-label.highlight {{ opacity: 1 !important; font-weight: 700; }}
644 .edge-label.dimmed {{ opacity: 0.15 !important; }}
645 .edge-label-bg.highlight {{ stroke: #0078D4 !important; stroke-width: 1.5 !important; }}
646 .edge-label-bg.dimmed {{ opacity: 0.15 !important; }}
647 .node.dimmed {{ opacity: 0.25; transition: opacity 0.2s; }}
648
649 .subnet-rect {{
650 rx: 6; ry: 6;
651 }}
652 .subnet-label {{
653 font-size: 11px; font-weight: 600; font-family: 'Segoe UI', sans-serif;
654 }}
655
656 .status-bar {{
657 position: absolute; bottom: 10px; left: 10px;
658 background: white; border: 1px solid #edebe9; border-radius: 4px;
659 padding: 4px 10px; font-size: 10px; color: #a19f9d;
660 box-shadow: 0 1px 4px rgba(0,0,0,0.06);
661 }}
662
663 .tooltip {{
664 position: absolute; background: white; color: #323130;
665 border: 1px solid #edebe9; padding: 8px 12px;
666 border-radius: 6px; font-size: 11px; pointer-events: none;
667 white-space: nowrap; z-index: 100; display: none;
668 box-shadow: 0 4px 16px rgba(0,0,0,0.12);
669 }}
670 .tooltip strong {{ color: #201f1e; }}
671 .tooltip-detail {{ color: #605e5c; margin-top: 1px; font-size: 10px; }}
672</style>
673</head>
674<body>
675
676<div class="header">
677 <div class="header-icon">
678 <svg viewBox="0 0 24 24"><path d="M12 2L2 7v10l10 5 10-5V7L12 2z" fill="white" opacity="0.9"/></svg>
679 </div>
680 <div>
681 <h1>{title}</h1>
682 <div class="meta">Azure Architecture · {generated_at}</div>
683 </div>
684 <div class="header-right">
685 <div class="stat"><b>{svc_count}</b> Services</div>
686 <div class="stat"><b>{pe_count}</b> Private Endpoints</div>
687 <div class="stat"><b>{len(connections)}</b> Connections</div>
688 </div>
689</div>
690
691<div class="container">
692 <div class="canvas-area">
693 <div class="toolbar">
694 <button class="tool-btn" onclick="fitToScreen()">Fit</button>
695 <div class="tool-sep"></div>
696 <button class="tool-btn" onclick="zoomIn()">+</button>
697 <button class="tool-btn" onclick="zoomOut()">−</button>
698 <div class="tool-sep"></div>
699 <button class="tool-btn" onclick="textBigger()" title="Bigger text" style="font-size:13px;">A+</button>
700 <button class="tool-btn" onclick="textSmaller()" title="Smaller text" style="font-size:10px;">A−</button>
701 <div class="tool-sep"></div>
702 <button class="tool-btn" onclick="downloadPNG()" title="Download PNG">📷 PNG</button>
703 </div>
704 <div class="zoom-indicator" id="zoom-level">100%</div>
705 <svg id="canvas">
706 <defs>
707 <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
708 <path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" opacity="0.7"/>
709 </marker>
710 <marker id="arr-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
711 <path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" opacity="0.7"/>
712 </marker>
713 <marker id="arr-sec" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
714 <path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" opacity="0.7"/>
715 </marker>
716 <marker id="arr-pe" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
717 <path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" opacity="0.7"/>
718 </marker>
719 <filter id="shadow">
720 <feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.08"/>
721 </filter>
722 </defs>
723 <g id="diagram-root"></g>
724 </svg>
725 <div id="tooltip" class="tooltip"></div>
726 <div class="status-bar">Drag nodes · Scroll to zoom · Drag empty space to pan</div>
727 </div>
728
729 <div class="sidebar">
730 <div class="sidebar-header">Resources</div>
731 <div id="service-list"></div>
732 <div class="legend">
733 <div class="legend-title">Connection Types</div>
734 <div class="legend-item"><div class="legend-line" style="background:#0078D4;"></div> API</div>
735 <div class="legend-item"><div class="legend-line" style="background:#0F9D58;"></div> Data</div>
736 <div class="legend-item"><div class="legend-line-dash" style="border-color:#E8A000;"></div> Security</div>
737 <div class="legend-item"><div class="legend-line-dash" style="border-color:#5C2D91;"></div> Private Endpoint</div>
738 </div>
739 </div>
740</div>
741
742<script>
743const NODES = {nodes_js};
744const EDGES = {edges_js};
745const VNET_INFO = {vnet_info_js};
746const HIERARCHY = {hierarchy_js};
747
748// ── Node sizing ──
749const SVC_W = 180, SVC_H = 120; // service node (icon above, name below) — 20% larger
750const PE_W = 120, PE_H = 84; // pe node (smaller) — 20% larger
751const GAP = 40;
752
753// ── Layout: Category Group Box style ──
754// Each category gets a labeled box, services arranged in a grid inside.
755// Groups arranged in 2D: main service groups on top, bottom groups below.
756// PE nodes in a separate PE subnet group.
757
758const positions = {{}};
759const useRgLayout = HIERARCHY.length > 0 && NODES.some(n => n.resourceGroup);
760const peNodes = useRgLayout ? [] : NODES.filter(n => n.type === 'pe'); // RG mode: PE included in mainNodes
761const mainNodes = useRgLayout ? NODES : NODES.filter(n => n.type !== 'pe');
762
763// Group box layout parameters
764const GROUP_PAD = 24;
765const GROUP_TITLE_H = 28;
766const GROUP_GAP = 60;
767const COLS_PER_GROUP = 3;
768const CELL_W = SVC_W + 100;
769const CELL_H = SVC_H + 90;
770
771function groupDimensions(nodeCount) {{
772 const cols = Math.min(nodeCount, COLS_PER_GROUP);
773 const rows = Math.ceil(nodeCount / COLS_PER_GROUP);
774 const w = cols * CELL_W + GROUP_PAD * 2;
775 const h = rows * CELL_H + GROUP_PAD + GROUP_TITLE_H;
776 return {{ w, h, cols, rows }};
777}}
778
779const groupBoxes = [];
780
781// ── Layout strategy: RG-based (if HIERARCHY) or Category-based (default) ──
782
783if (useRgLayout) {{
784 // ── RG-based layout: group by Subscription > ResourceGroup ──
785 let gx = 60, gy = 140;
786 let subStartX = 60;
787 const SUB_GAP = 80;
788 const RG_GAP = 60;
789
790 HIERARCHY.forEach((sub, subIdx) => {{
791 let rgX = gx;
792 let rgMaxH = 0;
793
794 const subRGs = sub.resourceGroups || [];
795 subRGs.forEach((rgName, rgIdx) => {{
796 const rgNodes = mainNodes.filter(n => n.subscription === sub.subscription && n.resourceGroup === rgName);
797 if (rgNodes.length === 0) return;
798
799 const dim = groupDimensions(rgNodes.length);
800
801 rgNodes.forEach((n, i) => {{
802 const col = i % dim.cols;
803 const row = Math.floor(i / dim.cols);
804 positions[n.id] = {{
805 x: rgX + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,
806 y: gy + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2
807 }};
808 }});
809
810 groupBoxes.push({{
811 cat: rgName, x: rgX, y: gy, w: dim.w, h: dim.h,
812 color: rgNodes[0]?.color || '#0078D4',
813 isRG: true, subscription: sub.subscription
814 }});
815
816 rgX += dim.w + RG_GAP;
817 rgMaxH = Math.max(rgMaxH, dim.h);
818 }});
819
820 // Next subscription row
821 if (subIdx < HIERARCHY.length - 1) {{
822 gy += rgMaxH + SUB_GAP;
823 gx = subStartX;
824 }}
825 }});
826
827 // Place unassigned main nodes (no subscription/RG) in a generic group
828 const unassigned = mainNodes.filter(n => !positions[n.id]);
829 if (unassigned.length > 0) {{
830 const allY = Object.values(positions).map(p => p.y);
831 const bottomY = allY.length > 0 ? Math.max(...allY) + SVC_H + GROUP_GAP : 140;
832 const dim = groupDimensions(unassigned.length);
833 unassigned.forEach((n, i) => {{
834 const col = i % dim.cols;
835 const row = Math.floor(i / dim.cols);
836 positions[n.id] = {{
837 x: 60 + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,
838 y: bottomY + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2
839 }};
840 }});
841 groupBoxes.push({{
842 cat: 'Other', x: 60, y: bottomY, w: dim.w, h: dim.h,
843 color: '#666'
844 }});
845 }}
846
847}} else {{
848 // ── Category-based layout (original) ──
849 const bottomCategories = ['Network', 'External', 'Monitor', 'Monitoring'];
850 const catOrder = ['AI', 'Data', 'Security', 'Compute', 'Integration', 'DevOps', 'IoT', 'Azure'];
851
852 const catGroups = {{}};
853 mainNodes.forEach(n => {{
854 const cat = n.category || 'Azure';
855 if (!catGroups[cat]) catGroups[cat] = [];
856 catGroups[cat].push(n);
857 }});
858
859// Dynamically include any categories not in catOrder or bottomCategories
860const extraCats = Object.keys(catGroups).filter(cat => !catOrder.includes(cat) && !bottomCategories.includes(cat));
861const fullCatOrder = [...catOrder, ...extraCats];
862
863// ── Place main service groups in a flowing grid ──
864const serviceGroups = fullCatOrder.filter(cat => catGroups[cat] && catGroups[cat].length > 0
865 && !bottomCategories.includes(cat));
866
867let gx = 60, gy = 140;
868let rowMaxH = 0;
869let rowStartX = 60;
870const MAX_ROW_W = Math.max(1600, serviceGroups.length * 400);
871
872serviceGroups.forEach(cat => {{
873 const nodes = catGroups[cat];
874 const dim = groupDimensions(nodes.length);
875
876 // Wrap to next row if too wide
877 if (gx + dim.w > rowStartX + MAX_ROW_W && gx > rowStartX) {{
878 gx = rowStartX;
879 gy += rowMaxH + GROUP_GAP;
880 rowMaxH = 0;
881 }}
882
883 // Place nodes inside group grid
884 nodes.forEach((n, i) => {{
885 const col = i % dim.cols;
886 const row = Math.floor(i / dim.cols);
887 positions[n.id] = {{
888 x: gx + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,
889 y: gy + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2
890 }};
891 }});
892
893 groupBoxes.push({{
894 cat, x: gx, y: gy, w: dim.w, h: dim.h,
895 color: nodes[0]?.color || '#0078D4'
896 }});
897
898 gx += dim.w + GROUP_GAP;
899 rowMaxH = Math.max(rowMaxH, dim.h);
900}});
901
902// ── Place bottom groups (Network, External, Monitor) ──
903const bottomGroupY = gy + rowMaxH + GROUP_GAP + 20;
904let bgx = 60;
905bottomCategories.forEach(cat => {{
906 const nodes = catGroups[cat];
907 if (!nodes || nodes.length === 0) return;
908 const dim = groupDimensions(nodes.length);
909
910 nodes.forEach((n, i) => {{
911 const col = i % dim.cols;
912 const row = Math.floor(i / dim.cols);
913 positions[n.id] = {{
914 x: bgx + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,
915 y: bottomGroupY + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2
916 }};
917 }});
918
919 groupBoxes.push({{
920 cat, x: bgx, y: bottomGroupY, w: dim.w, h: dim.h,
921 color: nodes[0]?.color || '#666',
922 isBottom: true
923 }});
924
925 bgx += dim.w + GROUP_GAP;
926}});
927
928}} // end of else (category-based layout)
929
930// ── PE nodes placement ──
931if (useRgLayout) {{
932 // RG mode: PE nodes go inside their respective RG boxes
933 // PE positions are already set by the RG layout if they have subscription/resourceGroup
934 // For PEs without RG assignment, place them in a separate group
935 const unplacedPEs = peNodes.filter(pe => !positions[pe.id]);
936 if (unplacedPEs.length > 0) {{
937 // Find the rightmost RG box position
938 const allGbRight = groupBoxes.length > 0 ? Math.max(...groupBoxes.map(gb => gb.x + gb.w)) : 0;
939 const peStartX = allGbRight + GROUP_GAP;
940 const peStartY = 140;
941 const peCols = Math.min(unplacedPEs.length, 4);
942 const peCellW = PE_W + 50;
943 const peCellH = PE_H + 30;
944 const peBoxW = peCols * peCellW + GROUP_PAD * 2;
945 const peRows = Math.ceil(unplacedPEs.length / peCols);
946 const peBoxH = peRows * peCellH + GROUP_PAD + GROUP_TITLE_H;
947
948 unplacedPEs.forEach((pe, i) => {{
949 const col = i % peCols;
950 const row = Math.floor(i / peCols);
951 positions[pe.id] = {{
952 x: peStartX + GROUP_PAD + col * peCellW + (peCellW - PE_W) / 2,
953 y: peStartY + GROUP_TITLE_H + row * peCellH + (peCellH - PE_H) / 2
954 }};
955 }});
956 groupBoxes.push({{
957 cat: 'Private Endpoints', x: peStartX, y: peStartY, w: peBoxW, h: peBoxH,
958 color: '#5C2D91', isPE: true
959 }});
960 }}
961}} else {{
962 // Category mode: PE nodes in separate group above service groups
963 const PE_Y = 40;
964 if (peNodes.length > 0) {{
965 const peCols = Math.min(peNodes.length, 6);
966 const peRows = Math.ceil(peNodes.length / peCols);
967 const peCellW = PE_W + 50;
968 const peCellH = PE_H + 30;
969 const peBoxW = peCols * peCellW + GROUP_PAD * 2;
970 const peBoxH = peRows * peCellH + GROUP_PAD + GROUP_TITLE_H;
971
972 peNodes.forEach((pe, i) => {{
973 const col = i % peCols;
974 const row = Math.floor(i / peCols);
975 positions[pe.id] = {{
976 x: 60 + GROUP_PAD + col * peCellW + (peCellW - PE_W) / 2,
977 y: PE_Y + GROUP_TITLE_H + row * peCellH + (peCellH - PE_H) / 2
978 }};
979 }});
980
981 groupBoxes.push({{
982 cat: 'Private Endpoints', x: 60, y: PE_Y, w: peBoxW, h: peBoxH,
983 color: '#5C2D91', isPE: true
984 }});
985
986 const peBottom = PE_Y + peBoxH + GROUP_GAP;
987 if (peBottom > 140) {{
988 const shift = peBottom - 140;
989 NODES.forEach(n => {{
990 if (n.type !== 'pe' && positions[n.id]) {{
991 positions[n.id].y += shift;
992 }}
993 }});
994 groupBoxes.forEach(gb => {{
995 if (!gb.isPE) gb.y += shift;
996 }});
997 }}
998 }}
999}}
1000
1001// ── Node → Group mapping (for edge routing) ──
1002const nodeGroupMap = {{}};
1003groupBoxes.forEach((gb, idx) => {{
1004 NODES.forEach(n => {{
1005 const pos = positions[n.id];
1006 if (!pos) return;
1007 const nw = n.type === 'pe' ? PE_W : SVC_W;
1008 const nh = n.type === 'pe' ? PE_H : SVC_H;
1009 const ncx = pos.x + nw / 2;
1010 const ncy = pos.y + nh / 2;
1011 if (ncx >= gb.x && ncx <= gb.x + gb.w && ncy >= gb.y && ncy <= gb.y + gb.h) {{
1012 nodeGroupMap[n.id] = idx;
1013 }}
1014 }});
1015}});
1016// Routing corridor margins (outside all group boxes)
1017const _rightMarginBase = groupBoxes.length > 0 ? Math.max(...groupBoxes.map(g => g.x + g.w)) + 40 : 800;
1018const _leftMarginBase = groupBoxes.length > 0 ? Math.min(...groupBoxes.map(g => g.x)) - 40 : -40;
1019
1020// ── State ──
1021let dragging = null, dragOffX = 0, dragOffY = 0;
1022let draggingGroup = null, groupDragNodes = []; // for RG/group box dragging
1023let _dragStartX = 0, _dragStartY = 0, _didDrag = false; // global so renderDiagram rebuilding DOM mid-drag doesn't reset them
1024let viewTransform = {{ x: 0, y: 0, scale: 1 }};
1025let isPanning = false, panSX = 0, panSY = 0, panSTx = 0, panSTy = 0;
1026let _routeCounter = 0;
1027
1028// ── Bidirectional highlight ──
1029let _selectedNodeId = null;
1030
1031function selectNode(nodeId) {{
1032 const wasSelected = _selectedNodeId === nodeId;
1033
1034 // Clear all selections
1035 clearSelection();
1036
1037 // Toggle off if clicking same node
1038 if (wasSelected) {{ _selectedNodeId = null; return; }}
1039
1040 _selectedNodeId = nodeId;
1041 applySelectionHighlight();
1042 // Scroll sidebar card into view on initial selection
1043 const sCard = document.getElementById('card-' + nodeId);
1044 if (sCard) sCard.scrollIntoView({{ behavior: 'smooth', block: 'nearest' }});
1045}}
1046
1047// Re-apply CSS classes for current _selectedNodeId (called after renderDiagram rebuilds DOM)
1048function applySelectionHighlight() {{
1049 const nodeId = _selectedNodeId;
1050 if (!nodeId) return;
1051
1052 // Highlight diagram node
1053 const svgNode = document.querySelector(`.node[data-id="${{nodeId}}"]`);
1054 if (svgNode) svgNode.classList.add('selected');
1055 // Highlight sidebar card
1056 const card = document.getElementById('card-' + nodeId);
1057 if (card) card.classList.add('selected');
1058
1059 // Find connected edges (where this node is from or to)
1060 const connectedNodeIds = new Set([nodeId]);
1061 document.querySelectorAll('.edge-path').forEach(p => {{
1062 const f = p.getAttribute('data-from'), t = p.getAttribute('data-to');
1063 if (f === nodeId || t === nodeId) {{
1064 p.classList.add('highlight');
1065 connectedNodeIds.add(f);
1066 connectedNodeIds.add(t);
1067 }} else {{
1068 p.classList.add('dimmed');
1069 }}
1070 }});
1071 document.querySelectorAll('.edge-label').forEach(g => {{
1072 const f = g.getAttribute('data-from'), t = g.getAttribute('data-to');
1073 if (f === nodeId || t === nodeId) {{
1074 g.classList.add('highlight');
1075 g.querySelector('.edge-label-bg')?.classList.add('highlight');
1076 }} else {{
1077 g.classList.add('dimmed');
1078 g.querySelector('.edge-label-bg')?.classList.add('dimmed');
1079 }}
1080 }});
1081 // Dim unconnected nodes
1082 document.querySelectorAll('.node').forEach(n => {{
1083 const nid = n.getAttribute('data-id');
1084 if (!connectedNodeIds.has(nid)) n.classList.add('dimmed');
1085 }});
1086}}
1087
1088function clearSelection() {{
1089 _selectedNodeId = null;
1090 document.querySelectorAll('.node').forEach(n => {{ n.classList.remove('selected', 'dimmed'); }});
1091 document.querySelectorAll('.service-card').forEach(c => c.classList.remove('selected'));
1092 document.querySelectorAll('.edge-path').forEach(p => {{ p.classList.remove('highlight', 'dimmed'); }});
1093 document.querySelectorAll('.edge-label').forEach(g => {{ g.classList.remove('highlight', 'dimmed'); }});
1094 document.querySelectorAll('.edge-label-bg').forEach(r => {{ r.classList.remove('highlight', 'dimmed'); }});
1095}}
1096
1097function markerFor(type) {{
1098 if (type === 'data') return 'arr-data';
1099 if (type === 'security') return 'arr-sec';
1100 if (type === 'private') return 'arr-pe';
1101 return 'arr';
1102}}
1103
1104function renderDiagram() {{
1105 const root = document.getElementById('diagram-root');
1106 root.innerHTML = '';
1107 _routeCounter = 0; // reset stagger counter each render
1108
1109 // ── VNet bounds (hoisted so avoidNodes can push detours outside VNet) ──
1110 let _vnetBounds = null;
1111 if (!useRgLayout) {{
1112 const _pg = groupBoxes.filter(gb => !gb.isBottom);
1113 const _hasPriv = NODES.some(n => n.private && n.type !== 'pe');
1114 const _hasVNI = VNET_INFO && VNET_INFO.length > 0;
1115 const _hasPe = NODES.some(n => n.type === 'pe');
1116 if (_pg.length > 0 && (_hasPriv || _hasVNI || _hasPe)) {{
1117 const vx = Math.min(..._pg.map(g => g.x)) - 16;
1118 const vy = Math.min(..._pg.map(g => g.y)) - 36;
1119 const vR = Math.max(..._pg.map(g => g.x + g.w)) + 16;
1120 const vB = Math.max(..._pg.map(g => g.y + g.h)) + 16;
1121 _vnetBounds = {{ x: vx, y: vy, w: vR - vx, h: vB - vy }};
1122 }}
1123 }}
1124
1125 // ── Draw VNet boundary (only in category-based layout, not RG layout) ──
1126 if (!useRgLayout) {{
1127 const privateGroups = groupBoxes.filter(gb => !gb.isBottom);
1128 const hasPrivateNodes = NODES.some(n => n.private && n.type !== 'pe');
1129 const hasVNetInfo = VNET_INFO && VNET_INFO.length > 0;
1130 const hasPeNodes = NODES.some(n => n.type === 'pe');
1131 const showVNet = hasPrivateNodes || hasVNetInfo || hasPeNodes;
1132
1133 if (privateGroups.length > 0 && showVNet) {{
1134 const vx = Math.min(...privateGroups.map(g => g.x)) - 16;
1135 const vy = Math.min(...privateGroups.map(g => g.y)) - 36;
1136 const vRight = Math.max(...privateGroups.map(g => g.x + g.w)) + 16;
1137 const vBottom = Math.max(...privateGroups.map(g => g.y + g.h)) + 16;
1138
1139 const vr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1140 vr.setAttribute('x', vx); vr.setAttribute('y', vy);
1141 vr.setAttribute('width', vRight - vx); vr.setAttribute('height', vBottom - vy);
1142 vr.setAttribute('fill', '#f8f7ff'); vr.setAttribute('stroke', '#5C2D91');
1143 vr.setAttribute('stroke-width', '2'); vr.setAttribute('stroke-dasharray', '8,4');
1144 vr.setAttribute('rx', '12');
1145 root.appendChild(vr);
1146
1147 const vnetLabel = VNET_INFO ? `Virtual Network (${{VNET_INFO}})` : 'Virtual Network';
1148 const vl = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1149 vl.setAttribute('class', 'vnet-boundary-label');
1150 vl.setAttribute('style', 'cursor: pointer;');
1151 vl.innerHTML = `<svg x="${{vx + 10}}" y="${{vy + 6}}" width="20" height="20" viewBox="0 0 48 48">
1152 <rect x="6" y="6" width="36" height="36" rx="4" fill="none" stroke="#5C2D91" stroke-width="3"/>
1153 <circle cx="16" cy="18" r="3" fill="#5C2D91"/><circle cx="32" cy="18" r="3" fill="#5C2D91"/><circle cx="24" cy="32" r="3" fill="#5C2D91"/>
1154 </svg>
1155 <text x="${{vx + 34}}" y="${{vy + 20}}" font-size="12" font-weight="600" fill="#5C2D91" font-family="Segoe UI, sans-serif">${{vnetLabel}}</text>`;
1156 root.appendChild(vl);
1157
1158 // Store VNet rect reference for highlight
1159 vr.setAttribute('id', 'vnet-rect');
1160 vl.addEventListener('click', () => {{ toggleVNetHighlight(); }});
1161 root.appendChild(vl);
1162 }}
1163 }} // end if(!useRgLayout) for VNet boundary
1164
1165 // ── Draw group boxes (category or RG — depends on layout mode) ──
1166 const _groupLabelElements = []; // store labels to re-render on top of edges
1167 groupBoxes.forEach(gb => {{
1168 if (gb.isPE) {{
1169 // PE group — always draw with dashed style
1170 const gr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1171 gr.setAttribute('x', gb.x); gr.setAttribute('y', gb.y);
1172 gr.setAttribute('width', gb.w); gr.setAttribute('height', gb.h);
1173 gr.setAttribute('rx', '8'); gr.setAttribute('fill', '#f3eef9');
1174 gr.setAttribute('stroke', '#c8b8e8'); gr.setAttribute('stroke-width', '1.2');
1175 gr.setAttribute('stroke-dasharray', '4,4');
1176 root.appendChild(gr);
1177 }} else {{
1178 // Service group (category or RG)
1179 const gr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1180 gr.setAttribute('x', gb.x); gr.setAttribute('y', gb.y);
1181 gr.setAttribute('width', gb.w); gr.setAttribute('height', gb.h);
1182 gr.setAttribute('rx', '8');
1183 gr.setAttribute('fill', gb.isRG ? '#fafafa' : 'white');
1184 gr.setAttribute('stroke', gb.isRG ? gb.color : '#c8c6c4');
1185 gr.setAttribute('stroke-width', gb.isRG ? '1.5' : '1.2');
1186 if (gb.isRG) gr.setAttribute('stroke-dasharray', '6,3');
1187 root.appendChild(gr);
1188 }}
1189
1190 // Title bar
1191 const titleBar = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1192 titleBar.setAttribute('x', gb.x); titleBar.setAttribute('y', gb.y);
1193 titleBar.setAttribute('width', gb.w); titleBar.setAttribute('height', GROUP_TITLE_H);
1194 titleBar.setAttribute('rx', '8');
1195 titleBar.setAttribute('fill', gb.color);
1196 titleBar.setAttribute('opacity', '0.1');
1197 root.appendChild(titleBar);
1198 const titleFill = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1199 titleFill.setAttribute('x', gb.x); titleFill.setAttribute('y', gb.y + GROUP_TITLE_H - 8);
1200 titleFill.setAttribute('width', gb.w); titleFill.setAttribute('height', '8');
1201 titleFill.setAttribute('fill', gb.color); titleFill.setAttribute('opacity', '0.1');
1202 root.appendChild(titleFill);
1203
1204 // Color accent line
1205 const accent = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1206 accent.setAttribute('x', gb.x); accent.setAttribute('y', gb.y);
1207 accent.setAttribute('width', gb.w); accent.setAttribute('height', '3');
1208 accent.setAttribute('rx', '8'); accent.setAttribute('fill', gb.color);
1209 root.appendChild(accent);
1210
1211 // Group label — RG uses 📁, PE uses "Private Endpoints", category uses category name
1212 const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
1213 label.setAttribute('x', gb.x + 12); label.setAttribute('y', gb.y + 18);
1214 label.setAttribute('font-size', '12'); label.setAttribute('font-weight', '600');
1215 label.setAttribute('fill', gb.color); label.setAttribute('font-family', 'Segoe UI, sans-serif');
1216 label.textContent = gb.isRG ? `📁 ${{gb.cat}}` : gb.cat;
1217 root.appendChild(label);
1218 _groupLabelElements.push(label);
1219
1220 // Make title bar draggable — drags all nodes inside
1221 titleBar.style.cursor = 'grab';
1222 const gbIdx = groupBoxes.indexOf(gb);
1223 titleBar.addEventListener('mousedown', e => {{
1224 if (e.button !== 0) return;
1225 e.stopPropagation(); e.preventDefault();
1226 draggingGroup = gbIdx;
1227 const svgPt = getSVGPoint(e);
1228 dragOffX = svgPt.x; dragOffY = svgPt.y;
1229 // Find all nodes inside this group box
1230 groupDragNodes = NODES.filter(n => {{
1231 const pos = positions[n.id];
1232 if (!pos) return false;
1233 const nw = n.type === 'pe' ? PE_W : SVC_W;
1234 const nh = n.type === 'pe' ? PE_H : SVC_H;
1235 const cx = pos.x + nw/2, cy = pos.y + nh/2;
1236 return cx >= gb.x && cx <= gb.x + gb.w && cy >= gb.y && cy <= gb.y + gb.h;
1237 }}).map(n => n.id);
1238 }});
1239 }});
1240
1241 // ── Draw Subscription boundaries (only if multiple subscriptions, rendered AFTER group boxes) ──
1242 if (HIERARCHY.length > 1 && useRgLayout) {{
1243 HIERARCHY.forEach((sub, subIdx) => {{
1244 // Find all RG boxes belonging to this subscription
1245 const subRgBoxes = groupBoxes.filter(gb => gb.isRG && gb.subscription === sub.subscription);
1246 if (subRgBoxes.length === 0) return;
1247
1248 const sx = Math.min(...subRgBoxes.map(gb => gb.x)) - 20;
1249 const sy = Math.min(...subRgBoxes.map(gb => gb.y)) - 40;
1250 const sRight = Math.max(...subRgBoxes.map(gb => gb.x + gb.w)) + 20;
1251 const sBottom = Math.max(...subRgBoxes.map(gb => gb.y + gb.h)) + 20;
1252
1253 const sr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1254 sr.setAttribute('x', sx); sr.setAttribute('y', sy);
1255 sr.setAttribute('width', sRight - sx); sr.setAttribute('height', sBottom - sy);
1256 sr.setAttribute('fill', 'none'); sr.setAttribute('stroke', '#0078D4');
1257 sr.setAttribute('stroke-width', '2.5'); sr.setAttribute('stroke-dasharray', '12,4');
1258 sr.setAttribute('rx', '16'); sr.setAttribute('opacity', '0.7');
1259 root.appendChild(sr);
1260
1261 const sl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
1262 sl.setAttribute('x', sx + 12); sl.setAttribute('y', sy + 16);
1263 sl.setAttribute('font-size', '12'); sl.setAttribute('font-weight', '700');
1264 sl.setAttribute('fill', '#0078D4'); sl.setAttribute('font-family', 'Segoe UI, sans-serif');
1265 sl.textContent = `📦 ${{sub.subscription}}`;
1266 root.appendChild(sl);
1267 }});
1268 }}
1269
1270 // ── Edge routing (obstacle-free) ──
1271 // Compute global bounds: the absolute bottom of ALL nodes
1272 function getGlobalBounds() {{
1273 let minY = Infinity, maxY = -Infinity;
1274 NODES.forEach(n => {{
1275 const pos = positions[n.id];
1276 if (!pos) return;
1277 const h = n.type === 'pe' ? PE_H : SVC_H;
1278 if (pos.y < minY) minY = pos.y;
1279 if (pos.y + h > maxY) maxY = pos.y + h;
1280 }});
1281 return {{ minY, maxY }};
1282 }}
1283
1284 function getNodeBox(node) {{
1285 const pos = positions[node.id];
1286 if (!pos) return null;
1287 const w = node.type === 'pe' ? PE_W : SVC_W;
1288 const h = node.type === 'pe' ? PE_H : SVC_H;
1289 return {{ x: pos.x, y: pos.y, w, h, cx: pos.x + w/2, cy: pos.y + h/2 }};
1290 }}
1291
1292 // Border point: exit/enter at edge of rectangle
1293 function borderExit(box, side) {{
1294 // side: 'top', 'bottom', 'left', 'right'
1295 if (side === 'top') return {{ x: box.cx, y: box.y }};
1296 if (side === 'bottom') return {{ x: box.cx, y: box.y + box.h }};
1297 if (side === 'left') return {{ x: box.x, y: box.cy }};
1298 if (side === 'right') return {{ x: box.x + box.w, y: box.cy }};
1299 }}
1300
1301 // Check if a line segment hits any group box (for edge routing)
1302 function hitsGroupBox(x1, y1, x2, y2, skipGroupIndices) {{
1303 for (let gi = 0; gi < groupBoxes.length; gi++) {{
1304 if (skipGroupIndices.includes(gi)) continue;
1305 const gb = groupBoxes[gi];
1306 const pad = 4;
1307 const left = gb.x - pad, right = gb.x + gb.w + pad;
1308 const top = gb.y - pad, bottom = gb.y + gb.h + pad;
1309 const dx = x2 - x1, dy = y2 - y1;
1310 let tmin = 0, tmax = 1;
1311 const edges = [[-dx, x1 - left], [dx, right - x1], [-dy, y1 - top], [dy, bottom - y1]];
1312 let hit = true;
1313 for (const [p, q] of edges) {{
1314 if (Math.abs(p) < 0.001) {{ if (q < 0) {{ hit = false; break; }} }}
1315 else {{
1316 const t = q / p;
1317 if (p < 0) {{ if (t > tmin) tmin = t; }}
1318 else {{ if (t < tmax) tmax = t; }}
1319 if (tmin > tmax) {{ hit = false; break; }}
1320 }}
1321 }}
1322 if (hit && tmin < tmax) return true;
1323 }}
1324 return false;
1325 }}
1326
1327 // Find gap between adjacent groups (same row)
1328 function findGapBetween(gi1, gi2) {{
1329 if (gi1 < 0 || gi2 < 0) return null;
1330 const g1 = groupBoxes[gi1], g2 = groupBoxes[gi2];
1331 // Same row: Y ranges overlap
1332 const yOverlap = g1.y < g2.y + g2.h && g2.y < g1.y + g1.h;
1333 if (!yOverlap) return null;
1334 // Gap between them
1335 if (g1.x + g1.w < g2.x) return {{ x: (g1.x + g1.w + g2.x) / 2 }};
1336 if (g2.x + g2.w < g1.x) return {{ x: (g2.x + g2.w + g1.x) / 2 }};
1337 return null;
1338 }}
1339
1340 // Build orthogonal path with rounded corners
1341 function buildOrthoPath(pts) {{
1342 let d = `M ${{pts[0].x}} ${{pts[0].y}}`;
1343 const radius = 6;
1344 for (let i = 1; i < pts.length - 1; i++) {{
1345 const prev = pts[i-1], curr = pts[i], next = pts[i+1];
1346 const dx1 = curr.x - prev.x, dy1 = curr.y - prev.y;
1347 const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
1348 const len1 = Math.sqrt(dx1*dx1 + dy1*dy1);
1349 const len2 = Math.sqrt(dx2*dx2 + dy2*dy2);
1350 if (len1 < 1 || len2 < 1) {{ d += ` L ${{curr.x}} ${{curr.y}}`; continue; }}
1351 const r = Math.min(radius, len1/2, len2/2);
1352 const bx = curr.x - (dx1/len1)*r, by = curr.y - (dy1/len1)*r;
1353 const ax = curr.x + (dx2/len2)*r, ay = curr.y + (dy2/len2)*r;
1354 d += ` L ${{bx}} ${{by}} Q ${{curr.x}} ${{curr.y}} ${{ax}} ${{ay}}`;
1355 }}
1356 d += ` L ${{pts[pts.length-1].x}} ${{pts[pts.length-1].y}}`;
1357 return d;
1358 }}
1359
1360 // Find crossing point between two orthogonal segments (H crosses V only)
1361 function findSegCrossing(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {{
1362 const aIsH = Math.abs(ay1 - ay2) < 1;
1363 const bIsH = Math.abs(by1 - by2) < 1;
1364 if (aIsH === bIsH) return null;
1365 let hx1, hx2, hy, vx, vy1, vy2;
1366 if (aIsH) {{
1367 hy = ay1; hx1 = Math.min(ax1, ax2); hx2 = Math.max(ax1, ax2);
1368 vx = bx1; vy1 = Math.min(by1, by2); vy2 = Math.max(by1, by2);
1369 }} else {{
1370 hy = by1; hx1 = Math.min(bx1, bx2); hx2 = Math.max(bx1, bx2);
1371 vx = ax1; vy1 = Math.min(ay1, ay2); vy2 = Math.max(ay1, ay2);
1372 }}
1373 const MM = 2;
1374 if (vx > hx1 + MM && vx < hx2 - MM &&
1375 hy > vy1 + MM && hy < vy2 - MM) {{
1376 return {{ x: vx, y: hy }};
1377 }}
1378 return null;
1379 }}
1380
1381 // Build orthogonal path with rounded corners AND bridge arcs at crossing points
1382 function buildPathWithBridges(pts, bridges) {{
1383 const CR = 6, BR = 12;
1384 if (pts.length <= 1) return '';
1385
1386 // Index bridges by segment, sort along travel direction
1387 const bySeg = {{}};
1388 (bridges || []).forEach(b => {{
1389 if (!bySeg[b.segIdx]) bySeg[b.segIdx] = [];
1390 bySeg[b.segIdx].push(b);
1391 }});
1392 for (const si in bySeg) {{
1393 const i = parseInt(si);
1394 if (i >= pts.length - 1) continue;
1395 const p1 = pts[i], p2 = pts[i + 1];
1396 const isH = Math.abs(p1.y - p2.y) < 1;
1397 if (isH) {{
1398 const dir = Math.sign(p2.x - p1.x) || 1;
1399 bySeg[si].sort((a, b) => (a.x - b.x) * dir);
1400 }} else {{
1401 const dir = Math.sign(p2.y - p1.y) || 1;
1402 bySeg[si].sort((a, b) => (a.y - b.y) * dir);
1403 }}
1404 }}
1405
1406 // Helper: append bridge arcs for a segment
1407 function appendBridges(d, segIdx, segP1, segP2) {{
1408 const segB = bySeg[segIdx] || [];
1409 if (segB.length === 0) return d;
1410 const isH = Math.abs(segP1.y - segP2.y) < 1;
1411 segB.forEach(b => {{
1412 if (isH) {{
1413 const dir = Math.sign(segP2.x - segP1.x) || 1;
1414 d += ` L ${{b.x - BR * dir}} ${{segP1.y}}`;
1415 d += ` A ${{BR}} ${{BR}} 0 0 ${{dir > 0 ? 1 : 0}} ${{b.x + BR * dir}} ${{segP1.y}}`;
1416 }} else {{
1417 const dir = Math.sign(segP2.y - segP1.y) || 1;
1418 d += ` L ${{segP1.x}} ${{b.y - BR * dir}}`;
1419 d += ` A ${{BR}} ${{BR}} 0 0 ${{dir > 0 ? 0 : 1}} ${{segP1.x}} ${{b.y + BR * dir}}`;
1420 }}
1421 }});
1422 return d;
1423 }}
1424
1425 // 2-point path (straight line)
1426 if (pts.length === 2) {{
1427 let d = `M ${{pts[0].x}} ${{pts[0].y}}`;
1428 d = appendBridges(d, 0, pts[0], pts[1]);
1429 d += ` L ${{pts[1].x}} ${{pts[1].y}}`;
1430 return d;
1431 }}
1432
1433 // Multi-point path with corners + bridges
1434 let d = `M ${{pts[0].x}} ${{pts[0].y}}`;
1435 for (let i = 1; i < pts.length; i++) {{
1436 const prev = pts[i - 1], curr = pts[i];
1437 const isLast = (i === pts.length - 1);
1438
1439 // Compute corner trimming for non-last points
1440 let target = curr, cSuffix = '';
1441 if (!isLast) {{
1442 const next = pts[i + 1];
1443 const dx1 = curr.x - prev.x, dy1 = curr.y - prev.y;
1444 const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
1445 const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
1446 const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
1447 if (len1 >= 1 && len2 >= 1) {{
1448 const r = Math.min(CR, len1 / 2, len2 / 2);
1449 const bx = curr.x - (dx1 / len1) * r;
1450 const by = curr.y - (dy1 / len1) * r;
1451 const ax = curr.x + (dx2 / len2) * r;
1452 const ay = curr.y + (dy2 / len2) * r;
1453 target = {{ x: bx, y: by }};
1454 cSuffix = ` Q ${{curr.x}} ${{curr.y}} ${{ax}} ${{ay}}`;
1455 }}
1456 }}
1457
1458 // Draw bridges on segment (i-1) → i
1459 d = appendBridges(d, i - 1, prev, curr);
1460
1461 // Line to target + optional corner curve
1462 d += ` L ${{target.x}} ${{target.y}}${{cSuffix}}`;
1463 }}
1464 return d;
1465 }}
1466
1467 // ── Obstacle avoidance: route edges around nodes ──
1468 function segHitsNode(x1, y1, x2, y2, pos, nw, nh, margin) {{
1469 const nx1 = pos.x - margin, ny1 = pos.y - margin;
1470 const nx2 = pos.x + nw + margin, ny2 = pos.y + nh + margin;
1471 if (Math.abs(x1 - x2) < 1) {{
1472 // Vertical segment
1473 const x = x1;
1474 const minY = Math.min(y1, y2), maxY = Math.max(y1, y2);
1475 return x > nx1 && x < nx2 && maxY > ny1 && minY < ny2;
1476 }} else {{
1477 // Horizontal segment
1478 const y = y1;
1479 const minX = Math.min(x1, x2), maxX = Math.max(x1, x2);
1480 return y > ny1 && y < ny2 && maxX > nx1 && minX < nx2;
1481 }}
1482 }}
1483
1484 function avoidNodes(pts, fromId, toId) {{
1485 const MARGIN = 25;
1486 const SECTION_MARGIN = 12;
1487 let points = pts.map(p => ({{...p}}));
1488 // Save original anchors — these must NEVER move (they attach to nodes)
1489 const startAnchor = {{...points[0]}};
1490 const endAnchor = {{...points[points.length - 1]}};
1491
1492 // Section (groupBox) obstacles: groupBoxes containing NEITHER endpoint.
1493 // Skip PE group since PE-type edges legitimately traverse into it.
1494 const _fromGrp = _nodeGrp[fromId];
1495 const _toGrp = _nodeGrp[toId];
1496 const sectionObstacles = [];
1497 for (let gi = 0; gi < groupBoxes.length; gi++) {{
1498 if (gi === _fromGrp || gi === _toGrp) continue;
1499 const gb = groupBoxes[gi];
1500 if (gb.isPE) continue;
1501 sectionObstacles.push(gb);
1502 }}
1503
1504 // Helper: if the section detour coord lands inside the VNet rect while
1505 // either endpoint sits outside the VNet, push the detour past the nearer
1506 // VNet edge so unrelated VNet interior is not traversed.
1507 function _clampOutsideVNet(val, axis) {{
1508 if (!_vnetBounds) return val;
1509 const inAnchor = (a) => (a.x > _vnetBounds.x && a.x < _vnetBounds.x + _vnetBounds.w
1510 && a.y > _vnetBounds.y && a.y < _vnetBounds.y + _vnetBounds.h);
1511 const srcOut = !inAnchor(startAnchor);
1512 const dstOut = !inAnchor(endAnchor);
1513 if (!srcOut && !dstOut) return val;
1514 if (axis === 'x') {{
1515 const L = _vnetBounds.x, R = _vnetBounds.x + _vnetBounds.w;
1516 if (val > L && val < R) return (val - L) <= (R - val) ? L - SECTION_MARGIN : R + SECTION_MARGIN;
1517 }} else {{
1518 const T = _vnetBounds.y, B = _vnetBounds.y + _vnetBounds.h;
1519 if (val > T && val < B) return (val - T) <= (B - val) ? T - SECTION_MARGIN : B + SECTION_MARGIN;
1520 }}
1521 return val;
1522 }}
1523
1524 for (let iter = 0; iter < 20; iter++) {{
1525 let found = false;
1526
1527 for (let i = 0; i < points.length - 1 && !found; i++) {{
1528 const p1 = points[i], p2 = points[i+1];
1529
1530 // 1) Section obstacles (larger, checked first)
1531 for (const gb of sectionObstacles) {{
1532 const pos = {{x: gb.x, y: gb.y}};
1533 if (!segHitsNode(p1.x, p1.y, p2.x, p2.y, pos, gb.w, gb.h, SECTION_MARGIN)) continue;
1534
1535 found = true;
1536 const isVert = Math.abs(p1.x - p2.x) < 1;
1537 const isFirst = (i === 0);
1538 const isLast = (i + 1 === points.length - 1);
1539
1540 if (points.length <= 2) {{
1541 if (isVert) {{
1542 const leftX = gb.x - SECTION_MARGIN;
1543 const rightX = gb.x + gb.w + SECTION_MARGIN;
1544 let detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
1545 detourX = _clampOutsideVNet(detourX, 'x');
1546 points = [points[0], {{x: detourX, y: p1.y}}, {{x: detourX, y: p2.y}}, points[points.length-1]];
1547 }} else {{
1548 const topY = gb.y - SECTION_MARGIN;
1549 const bottomY = gb.y + gb.h + SECTION_MARGIN;
1550 let detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
1551 detourY = _clampOutsideVNet(detourY, 'y');
1552 points = [points[0], {{x: p1.x, y: detourY}}, {{x: p2.x, y: detourY}}, points[points.length-1]];
1553 }}
1554 }} else if (isFirst) {{
1555 if (isVert) {{
1556 const leftX = gb.x - SECTION_MARGIN;
1557 const rightX = gb.x + gb.w + SECTION_MARGIN;
1558 let detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
1559 detourX = _clampOutsideVNet(detourX, 'x');
1560 points.splice(1, 0, {{x: p1.x, y: p1.y}}, {{x: detourX, y: p1.y}});
1561 points[3] = {{x: detourX, y: p2.y}};
1562 }} else {{
1563 const topY = gb.y - SECTION_MARGIN;
1564 const bottomY = gb.y + gb.h + SECTION_MARGIN;
1565 let detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
1566 detourY = _clampOutsideVNet(detourY, 'y');
1567 points.splice(1, 0, {{x: p1.x, y: detourY}});
1568 points[2] = {{x: p2.x, y: detourY}};
1569 }}
1570 }} else if (isLast) {{
1571 if (isVert) {{
1572 const leftX = gb.x - SECTION_MARGIN;
1573 const rightX = gb.x + gb.w + SECTION_MARGIN;
1574 let detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
1575 detourX = _clampOutsideVNet(detourX, 'x');
1576 points[i] = {{x: detourX, y: p1.y}};
1577 points.splice(i + 1, 0, {{x: detourX, y: p2.y}}, {{x: p2.x, y: p2.y}});
1578 }} else {{
1579 const topY = gb.y - SECTION_MARGIN;
1580 const bottomY = gb.y + gb.h + SECTION_MARGIN;
1581 let detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
1582 detourY = _clampOutsideVNet(detourY, 'y');
1583 points[i] = {{x: p1.x, y: detourY}};
1584 points.splice(i + 1, 0, {{x: p2.x, y: detourY}});
1585 }}
1586 }} else {{
1587 if (isVert) {{
1588 const leftX = gb.x - SECTION_MARGIN;
1589 const rightX = gb.x + gb.w + SECTION_MARGIN;
1590 let newX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
1591 newX = _clampOutsideVNet(newX, 'x');
1592 points[i] = {{ x: newX, y: p1.y }};
1593 points[i+1] = {{ x: newX, y: p2.y }};
1594 }} else {{
1595 const topY = gb.y - SECTION_MARGIN;
1596 const bottomY = gb.y + gb.h + SECTION_MARGIN;
1597 let newY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
1598 newY = _clampOutsideVNet(newY, 'y');
1599 points[i] = {{ x: p1.x, y: newY }};
1600 points[i+1] = {{ x: p2.x, y: newY }};
1601 }}
1602 }}
1603 break;
1604 }}
1605 if (found) break;
1606
1607 // 2) Service node obstacles
1608 for (const node of NODES) {{
1609 if (node.id === fromId || node.id === toId) continue;
1610 const pos = positions[node.id];
1611 if (!pos) continue;
1612 const nw = node.type === 'pe' ? PE_W : SVC_W;
1613 const nh = (node.type === 'pe' ? PE_H : SVC_H) + 20; // include text below box
1614
1615 if (!segHitsNode(p1.x, p1.y, p2.x, p2.y, pos, nw, nh, MARGIN)) continue;
1616
1617 found = true;
1618 const isVert = Math.abs(p1.x - p2.x) < 1;
1619 const isFirst = (i === 0);
1620 const isLast = (i + 1 === points.length - 1);
1621
1622 if (points.length <= 2) {{
1623 // Straight line hitting a node: convert to 4-point detour (anchors preserved)
1624 if (isVert) {{
1625 const leftX = pos.x - MARGIN;
1626 const rightX = pos.x + nw + MARGIN;
1627 const detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
1628 points = [points[0], {{x: detourX, y: p1.y}}, {{x: detourX, y: p2.y}}, points[points.length-1]];
1629 }} else {{
1630 const topY = pos.y - MARGIN;
1631 const bottomY = pos.y + nh + MARGIN;
1632 const detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
1633 points = [points[0], {{x: p1.x, y: detourY}}, {{x: p2.x, y: detourY}}, points[points.length-1]];
1634 }}
1635 }} else if (isFirst) {{
1636 // First segment collides — keep points[0] (anchor), insert detour after it
1637 if (isVert) {{
1638 const leftX = pos.x - MARGIN;
1639 const rightX = pos.x + nw + MARGIN;
1640 const detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
1641 points.splice(1, 0, {{x: p1.x, y: p1.y}}, {{x: detourX, y: p1.y}});
1642 points[3] = {{x: detourX, y: p2.y}};
1643 }} else {{
1644 const topY = pos.y - MARGIN;
1645 const bottomY = pos.y + nh + MARGIN;
1646 const detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
1647 points.splice(1, 0, {{x: p1.x, y: detourY}});
1648 points[2] = {{x: p2.x, y: detourY}};
1649 }}
1650 }} else if (isLast) {{
1651 // Last segment collides — keep last point (anchor), insert detour before it
1652 if (isVert) {{
1653 const leftX = pos.x - MARGIN;
1654 const rightX = pos.x + nw + MARGIN;
1655 const detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
1656 points[i] = {{x: detourX, y: p1.y}};
1657 points.splice(i + 1, 0, {{x: detourX, y: p2.y}}, {{x: p2.x, y: p2.y}});
1658 }} else {{
1659 const topY = pos.y - MARGIN;
1660 const bottomY = pos.y + nh + MARGIN;
1661 const detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
1662 points[i] = {{x: p1.x, y: detourY}};
1663 points.splice(i + 1, 0, {{x: p2.x, y: detourY}});
1664 }}
1665 }} else {{
1666 // Middle segment: safe to push both endpoints
1667 if (isVert) {{
1668 const leftX = pos.x - MARGIN;
1669 const rightX = pos.x + nw + MARGIN;
1670 const newX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
1671 points[i] = {{ x: newX, y: p1.y }};
1672 points[i+1] = {{ x: newX, y: p2.y }};
1673 }} else {{
1674 const topY = pos.y - MARGIN;
1675 const bottomY = pos.y + nh + MARGIN;
1676 const newY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
1677 points[i] = {{ x: p1.x, y: newY }};
1678 points[i+1] = {{ x: p2.x, y: newY }};
1679 }}
1680 }}
1681 break;
1682 }}
1683 }}
1684
1685 if (!found) break;
1686 }}
1687
1688 // Restore anchors — guarantee lines always touch source/target nodes
1689 points[0] = startAnchor;
1690 points[points.length - 1] = endAnchor;
1691
1692 return points;
1693 }}
1694
1695 // ── Edges: three-phase rendering ──
1696 // Phase 0: pre-scan exit sides → Phase 1: compute paths with staggered anchors
1697 // Phase 2: detect crossings → Phase 3: render with bridge arcs
1698 const _edgeLabels = [];
1699
1700 // PHASE 0 — pre-scan: count how many edges exit each side of each node
1701 const _sideTotal = {{}};
1702 const _edgeSides = [];
1703 EDGES.forEach(edge => {{
1704 const fn = NODES.find(n => n.id === edge.from);
1705 const tn = NODES.find(n => n.id === edge.to);
1706 if (!fn || !tn) {{ _edgeSides.push(null); return; }}
1707 const fromBox = getNodeBox(fn);
1708 const toBox = getNodeBox(tn);
1709 if (!fromBox || !toBox) {{ _edgeSides.push(null); return; }}
1710
1711 const isPeEdge = edge.type === 'private';
1712 let exitSide, entrySide;
1713 if (isPeEdge) {{
1714 exitSide = 'bottom'; entrySide = 'top';
1715 }} else {{
1716 const dx = toBox.cx - fromBox.cx;
1717 const dy = toBox.cy - fromBox.cy;
1718 if (Math.abs(dx) >= Math.abs(dy)) {{
1719 exitSide = dx >= 0 ? 'right' : 'left';
1720 entrySide = dx >= 0 ? 'left' : 'right';
1721 }} else {{
1722 exitSide = dy >= 0 ? 'bottom' : 'top';
1723 entrySide = dy >= 0 ? 'top' : 'bottom';
1724 }}
1725 }}
1726 const ek = `${{edge.from}}_${{exitSide}}`;
1727 const nk = `${{edge.to}}_${{entrySide}}`;
1728 _sideTotal[ek] = (_sideTotal[ek] || 0) + 1;
1729 _sideTotal[nk] = (_sideTotal[nk] || 0) + 1;
1730 _edgeSides.push({{ exitSide, entrySide, isPeEdge, fromBox, toBox, edge }});
1731 }});
1732
1733 // ── RACK MARSHALLING: build channel map for inter-group edge bundling ──
1734 // Step 1: map each node to its containing group box
1735 const _nodeGrp = {{}};
1736 NODES.forEach(n => {{
1737 const pos = positions[n.id];
1738 if (!pos) return;
1739 const nw = n.type === 'pe' ? PE_W : SVC_W;
1740 const nh = n.type === 'pe' ? PE_H : SVC_H;
1741 const cx = pos.x + nw / 2, cy = pos.y + nh / 2;
1742 for (let gi = 0; gi < groupBoxes.length; gi++) {{
1743 const gb = groupBoxes[gi];
1744 if (cx >= gb.x && cx <= gb.x + gb.w && cy >= gb.y && cy <= gb.y + gb.h) {{
1745 _nodeGrp[n.id] = gi; break;
1746 }}
1747 }}
1748 }});
1749
1750 // Step 2: identify channels between group pairs + assign slot offsets
1751 const _chMap = {{}}; // key → {{ axis:'y'|'x', value: number }}
1752 const _chEdges = {{}}; // key → [edgeIdx, ...]
1753 _edgeSides.forEach((info, idx) => {{
1754 if (!info || info.isPeEdge) return;
1755 const sg = _nodeGrp[info.edge.from], tg = _nodeGrp[info.edge.to];
1756 if (sg === undefined || tg === undefined) return;
1757
1758 let key;
1759 if (sg !== tg) {{
1760 key = Math.min(sg, tg) + '_' + Math.max(sg, tg);
1761 if (!_chEdges[key]) _chEdges[key] = [];
1762 _chEdges[key].push(idx);
1763 if (!_chMap[key]) {{
1764 const a = groupBoxes[sg], b = groupBoxes[tg];
1765 if (a.y + a.h <= b.y) _chMap[key] = {{ axis: 'y', value: (a.y + a.h + b.y) / 2 }};
1766 else if (b.y + b.h <= a.y) _chMap[key] = {{ axis: 'y', value: (b.y + b.h + a.y) / 2 }};
1767 else if (a.x + a.w <= b.x) _chMap[key] = {{ axis: 'x', value: (a.x + a.w + b.x) / 2 }};
1768 else if (b.x + b.w <= a.x) _chMap[key] = {{ axis: 'x', value: (b.x + b.w + a.x) / 2 }};
1769 }}
1770 }} else {{
1771 // Intra-group edges: group by direction for slot offset assignment
1772 const dir = (info.exitSide === 'bottom' || info.exitSide === 'top') ? 'v' : 'h';
1773 key = 'i' + sg + dir;
1774 if (!_chEdges[key]) _chEdges[key] = [];
1775 _chEdges[key].push(idx);
1776 // No fixed channel value — each edge uses its own midpoint + offset
1777 }}
1778 }});
1779
1780 // Step 3: sort edges within each channel and assign slot offsets
1781 const _chOff = {{}}; // edgeIdx → offset in px
1782 const _CH_SLOT = 18; // spacing between lines in a bundle
1783 Object.keys(_chEdges).forEach(key => {{
1784 const ch = _chMap[key];
1785 const arr = _chEdges[key];
1786 const isVert = ch ? ch.axis === 'y' : key.endsWith('v');
1787 arr.sort((a, b) => {{
1788 const ia = _edgeSides[a], ib = _edgeSides[b];
1789 if (isVert) return (ia.fromBox.cx + ia.toBox.cx) - (ib.fromBox.cx + ib.toBox.cx);
1790 return (ia.fromBox.cy + ia.toBox.cy) - (ib.fromBox.cy + ib.toBox.cy);
1791 }});
1792 const n = arr.length;
1793 arr.forEach((ei, slot) => {{
1794 _chOff[ei] = n > 1 ? (slot - (n - 1) / 2) * _CH_SLOT : 0;
1795 }});
1796 }});
1797
1798 // Staggered border exit: spread multiple edges evenly along node side
1799 const _sideUsed = {{}};
1800 function staggeredExit(nodeId, box, side) {{
1801 const key = `${{nodeId}}_${{side}}`;
1802 const total = _sideTotal[key] || 1;
1803 const idx = _sideUsed[key] || 0;
1804 _sideUsed[key] = idx + 1;
1805 const isH = (side === 'top' || side === 'bottom');
1806 const sideLen = isH ? box.w : box.h;
1807 const CM = Math.max(40, sideLen * 0.3); // corner margin — 40px min or 30% of side
1808 const usable = Math.max(0, sideLen - 2 * CM);
1809 const maxSpread = Math.min(usable, total * 14);
1810 const step = total > 1 ? maxSpread / (total - 1) : 0;
1811 const offset = total > 1 ? -maxSpread / 2 + idx * step : 0;
1812 if (side === 'top') return {{ x: Math.max(box.x + CM, Math.min(box.x + box.w - CM, box.cx + offset)), y: box.y }};
1813 if (side === 'bottom') return {{ x: Math.max(box.x + CM, Math.min(box.x + box.w - CM, box.cx + offset)), y: box.y + box.h }};
1814 if (side === 'left') return {{ x: box.x, y: Math.max(box.y + CM, Math.min(box.y + box.h - CM, box.cy + offset)) }};
1815 return {{ x: box.x + box.w, y: Math.max(box.y + CM, Math.min(box.y + box.h - CM, box.cy + offset)) }};
1816 }}
1817
1818 // PHASE 1 — compute edge paths with staggered anchors
1819 const _allEdgePaths = [];
1820 _edgeSides.forEach((info, idx) => {{
1821 if (!info) return;
1822 const {{ exitSide, entrySide, isPeEdge, fromBox, toBox, edge }} = info;
1823 let pts;
1824
1825 if (isPeEdge) {{
1826 const sp = staggeredExit(edge.from, fromBox, 'bottom');
1827 const ep = staggeredExit(edge.to, toBox, 'top');
1828 if (Math.abs(sp.x - ep.x) < 8) {{
1829 pts = [sp, ep];
1830 }} else {{
1831 let midY = (sp.y + ep.y) / 2;
1832 midY = Math.max(midY, fromBox.y + fromBox.h + 40);
1833 midY = Math.min(midY, toBox.y - 40);
1834 pts = [sp, {{x: sp.x, y: midY}}, {{x: ep.x, y: midY}}, ep];
1835 }}
1836 pts = avoidNodes(pts, edge.from, edge.to);
1837 }} else {{
1838 const sp = staggeredExit(edge.from, fromBox, exitSide);
1839 const ep = staggeredExit(edge.to, toBox, entrySide);
1840 const STUB = 40;
1841
1842 // Channel lookup for inter-group marshalling
1843 const _sg = _nodeGrp[edge.from], _tg = _nodeGrp[edge.to];
1844 const _ck = _sg !== undefined && _tg !== undefined && _sg !== _tg
1845 ? Math.min(_sg, _tg) + '_' + Math.max(_sg, _tg) : null;
1846 const _cc = _ck ? _chMap[_ck] : null;
1847 const _co = _chOff[idx] || 0;
1848
1849 if (exitSide === 'right' || exitSide === 'left') {{
1850 if (Math.abs(sp.y - ep.y) < 8) {{
1851 pts = [sp, ep];
1852 }} else {{
1853 let midX = (_cc && _cc.axis === 'x') ? _cc.value + _co : (sp.x + ep.x) / 2 + _co;
1854 if (exitSide === 'right') midX = Math.max(midX, fromBox.x + fromBox.w + STUB);
1855 if (exitSide === 'left') midX = Math.min(midX, fromBox.x - STUB);
1856 if (entrySide === 'right') midX = Math.max(midX, toBox.x + toBox.w + STUB);
1857 if (entrySide === 'left') midX = Math.min(midX, toBox.x - STUB);
1858 pts = [sp, {{x: midX, y: sp.y}}, {{x: midX, y: ep.y}}, ep];
1859 }}
1860 }} else {{
1861 if (Math.abs(sp.x - ep.x) < 8) {{
1862 pts = [sp, ep];
1863 }} else {{
1864 let midY = (_cc && _cc.axis === 'y') ? _cc.value + _co : (sp.y + ep.y) / 2 + _co;
1865 if (exitSide === 'bottom') midY = Math.max(midY, fromBox.y + fromBox.h + STUB);
1866 if (exitSide === 'top') midY = Math.min(midY, fromBox.y - STUB);
1867 if (entrySide === 'bottom') midY = Math.max(midY, toBox.y + toBox.h + STUB);
1868 if (entrySide === 'top') midY = Math.min(midY, toBox.y - STUB);
1869 pts = [sp, {{x: sp.x, y: midY}}, {{x: ep.x, y: midY}}, ep];
1870 }}
1871 }}
1872
1873 pts = avoidNodes(pts, edge.from, edge.to);
1874 }}
1875
1876 // POST-ROUTING: enforce perpendicular stub at exit & entry ends
1877 // 3 cases: (a) already orthogonal + long enough → skip
1878 // (b) orthogonal but short → extend existing turn point
1879 // (c) non-orthogonal → insert 2-point connector
1880 const _eSide = isPeEdge ? 'bottom' : exitSide;
1881 const _nSide = isPeEdge ? 'top' : entrySide;
1882 const _STUB = 40;
1883 if (pts.length >= 3) {{
1884 // --- EXIT end ---
1885 const _p0 = pts[0], _p1 = pts[1];
1886 const _eH = (_eSide === 'right' || _eSide === 'left');
1887 if (_eH) {{
1888 const _d = _eSide === 'right' ? 1 : -1;
1889 const _ortho = Math.abs(_p0.y - _p1.y) <= 1;
1890 if (_ortho) {{
1891 const _dist = (_p1.x - _p0.x) * _d;
1892 if (_dist < _STUB) {{
1893 const sx = _p0.x + _d * _STUB;
1894 pts[1] = {{x: sx, y: _p0.y}};
1895 if (pts.length > 2 && Math.abs(pts[2].x - _p1.x) <= 1) {{
1896 pts[2] = {{x: sx, y: pts[2].y}};
1897 }}
1898 }}
1899 }} else {{
1900 const sx = _p0.x + _d * _STUB;
1901 pts.splice(1, 0, {{x: sx, y: _p0.y}}, {{x: sx, y: _p1.y}});
1902 }}
1903 }} else {{
1904 const _d = _eSide === 'bottom' ? 1 : -1;
1905 const _ortho = Math.abs(_p0.x - _p1.x) <= 1;
1906 if (_ortho) {{
1907 const _dist = (_p1.y - _p0.y) * _d;
1908 if (_dist < _STUB) {{
1909 const sy = _p0.y + _d * _STUB;
1910 pts[1] = {{x: _p0.x, y: sy}};
1911 if (pts.length > 2 && Math.abs(pts[2].y - _p1.y) <= 1) {{
1912 pts[2] = {{x: pts[2].x, y: sy}};
1913 }}
1914 }}
1915 }} else {{
1916 const sy = _p0.y + _d * _STUB;
1917 pts.splice(1, 0, {{x: _p0.x, y: sy}}, {{x: _p1.x, y: sy}});
1918 }}
1919 }}
1920 // --- ENTRY end ---
1921 const _pN = pts[pts.length - 1], _pP = pts[pts.length - 2];
1922 const _nH = (_nSide === 'right' || _nSide === 'left');
1923 if (_nH) {{
1924 const _d = _nSide === 'left' ? -1 : 1;
1925 const _ortho = Math.abs(_pN.y - _pP.y) <= 1;
1926 if (_ortho) {{
1927 const _dist = (_pP.x - _pN.x) * _d;
1928 if (_dist < _STUB) {{
1929 const sx = _pN.x + _d * _STUB;
1930 const _idx = pts.length - 2;
1931 pts[_idx] = {{x: sx, y: _pN.y}};
1932 if (_idx > 0 && Math.abs(pts[_idx - 1].x - _pP.x) <= 1) {{
1933 pts[_idx - 1] = {{x: sx, y: pts[_idx - 1].y}};
1934 }}
1935 }}
1936 }} else {{
1937 const sx = _pN.x + _d * _STUB;
1938 pts.splice(pts.length - 1, 0, {{x: sx, y: _pP.y}}, {{x: sx, y: _pN.y}});
1939 }}
1940 }} else {{
1941 const _d = _nSide === 'top' ? -1 : 1;
1942 const _ortho = Math.abs(_pN.x - _pP.x) <= 1;
1943 if (_ortho) {{
1944 const _dist = (_pP.y - _pN.y) * _d;
1945 if (_dist < _STUB) {{
1946 const sy = _pN.y + _d * _STUB;
1947 const _idx = pts.length - 2;
1948 pts[_idx] = {{x: _pN.x, y: sy}};
1949 if (_idx > 0 && Math.abs(pts[_idx - 1].y - _pP.y) <= 1) {{
1950 pts[_idx - 1] = {{x: pts[_idx - 1].x, y: sy}};
1951 }}
1952 }}
1953 }} else {{
1954 const sy = _pN.y + _d * _STUB;
1955 pts.splice(pts.length - 1, 0, {{x: _pP.x, y: sy}}, {{x: _pN.x, y: sy}});
1956 }}
1957 }}
1958 }}
1959
1960 // SAFETY: break any remaining diagonal segments into orthogonal L-shapes
1961 for (let _i = 0; _i < pts.length - 1; _i++) {{
1962 const _a = pts[_i], _b = pts[_i + 1];
1963 if (Math.abs(_a.x - _b.x) > 1 && Math.abs(_a.y - _b.y) > 1) {{
1964 pts.splice(_i + 1, 0, {{x: _a.x, y: _b.y}});
1965 }}
1966 }}
1967
1968 // SIMPLIFY: remove duplicate & collinear middle points
1969 for (let _i = pts.length - 2; _i >= 1; _i--) {{
1970 const _a = pts[_i - 1], _b = pts[_i], _c = pts[_i + 1];
1971 if (Math.abs(_a.x - _b.x) <= 1 && Math.abs(_a.y - _b.y) <= 1) {{
1972 pts.splice(_i, 1); continue;
1973 }}
1974 if ((Math.abs(_a.x - _b.x) <= 1 && Math.abs(_b.x - _c.x) <= 1) ||
1975 (Math.abs(_a.y - _b.y) <= 1 && Math.abs(_b.y - _c.y) <= 1)) {{
1976 pts.splice(_i, 1);
1977 }}
1978 }}
1979
1980 _allEdgePaths.push({{ edge, pts, isPeEdge }});
1981 }});
1982
1983 // OVERLAP SEPARATION — shift collinear overlapping segments apart
1984 // Only separate segments closer than OSEP — pre-marshalled edges (16px apart) are unaffected
1985 const OSEP = 8;
1986 for (let pass = 0; pass < 4; pass++) {{
1987 for (let i = 0; i < _allEdgePaths.length; i++) {{
1988 for (let j = i + 1; j < _allEdgePaths.length; j++) {{
1989 const pA = _allEdgePaths[i].pts;
1990 const pB = _allEdgePaths[j].pts;
1991 const dir = (j % 2 === 0) ? 1 : -1;
1992 for (let si = 0; si < pA.length - 1; si++) {{
1993 for (let sj = 0; sj < pB.length - 1; sj++) {{
1994 const a1 = pA[si], a2 = pA[si + 1];
1995 const b1 = pB[sj], b2 = pB[sj + 1];
1996 const aV = Math.abs(a1.x - a2.x) < 2;
1997 const bV = Math.abs(b1.x - b2.x) < 2;
1998 const aH = Math.abs(a1.y - a2.y) < 2;
1999 const bH = Math.abs(b1.y - b2.y) < 2;
2000
2001 if (aV && bV && Math.abs(a1.x - b1.x) < OSEP) {{
2002 const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))
2003 - Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));
2004 if (ov > 10) {{
2005 let shift = OSEP * dir;
2006 if (b1.x + shift < 20) shift = Math.abs(shift);
2007 if (sj > 0) pB[sj] = {{ x: b1.x + shift, y: b1.y }};
2008 if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x + shift, y: b2.y }};
2009 }}
2010 }}
2011 if (aH && bH && Math.abs(a1.y - b1.y) < OSEP) {{
2012 const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))
2013 - Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));
2014 if (ov > 10) {{
2015 const shift = OSEP * dir;
2016 if (sj > 0) pB[sj] = {{ x: b1.x, y: b1.y + shift }};
2017 if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x, y: b2.y + shift }};
2018 }}
2019 }}
2020 }}
2021 }}
2022 }}
2023 }}
2024 }}
2025
2026 // FINAL ORTHOGONALIZATION — fix diagonals introduced by overlap separation
2027 _allEdgePaths.forEach(({{ pts }}) => {{
2028 for (let _i = 0; _i < pts.length - 1; _i++) {{
2029 const _a = pts[_i], _b = pts[_i + 1];
2030 if (Math.abs(_a.x - _b.x) > 1 && Math.abs(_a.y - _b.y) > 1) {{
2031 pts.splice(_i + 1, 0, {{x: _a.x, y: _b.y}});
2032 }}
2033 }}
2034 // Remove collinear
2035 for (let _i = pts.length - 2; _i >= 1; _i--) {{
2036 const _a = pts[_i - 1], _b = pts[_i], _c = pts[_i + 1];
2037 if (Math.abs(_a.x - _b.x) <= 1 && Math.abs(_a.y - _b.y) <= 1) {{
2038 pts.splice(_i, 1); continue;
2039 }}
2040 if ((Math.abs(_a.x - _b.x) <= 1 && Math.abs(_b.x - _c.x) <= 1) ||
2041 (Math.abs(_a.y - _b.y) <= 1 && Math.abs(_b.y - _c.y) <= 1)) {{
2042 pts.splice(_i, 1);
2043 }}
2044 }}
2045 }});
2046
2047 // ── RE-ROUTING PASS: minimize crossings by routing edges via outer margins ──
2048 // Instead of shortest-path, reroute crossing edges AROUND group boxes
2049 const _gbLeft = groupBoxes.length > 0 ? Math.min(...groupBoxes.map(g => g.x)) : 0;
2050 const _gbRight = groupBoxes.length > 0 ? Math.max(...groupBoxes.map(g => g.x + g.w)) : 800;
2051 const _gbTop = groupBoxes.length > 0 ? Math.min(...groupBoxes.map(g => g.y)) : 0;
2052 const _gbBottom = groupBoxes.length > 0 ? Math.max(...groupBoxes.map(g => g.y + g.h)) : 600;
2053 const _RMARGIN = 50; // margin outside group bounds for rerouted edges
2054 const _RM_SLOT = 14; // spacing between rerouted edges on the same margin
2055
2056 // Count H×V crossings between one edge and all others
2057 function _cntCross(eIdx) {{
2058 let c = 0;
2059 const pA = _allEdgePaths[eIdx].pts;
2060 for (let j = 0; j < _allEdgePaths.length; j++) {{
2061 if (j === eIdx) continue;
2062 const pB = _allEdgePaths[j].pts;
2063 for (let si = 0; si < pA.length - 1; si++) {{
2064 for (let sj = 0; sj < pB.length - 1; sj++) {{
2065 if (findSegCrossing(pA[si].x, pA[si].y, pA[si+1].x, pA[si+1].y,
2066 pB[sj].x, pB[sj].y, pB[sj+1].x, pB[sj+1].y)) c++;
2067 }}
2068 }}
2069 }}
2070 return c;
2071 }}
2072
2073 // Generate a margin route: sp → stub → margin → margin → stub → ep
2074 function _mRoute(sp, ep, exitSide, entrySide, side, slotOff) {{
2075 const S = 40; // stub length
2076 const so = slotOff || 0;
2077 // stub exit from source node
2078 const s1 = exitSide === 'bottom' ? {{x: sp.x, y: sp.y + S}}
2079 : exitSide === 'top' ? {{x: sp.x, y: sp.y - S}}
2080 : exitSide === 'right' ? {{x: sp.x + S, y: sp.y}}
2081 : {{x: sp.x - S, y: sp.y}};
2082 // stub entry to target node
2083 const s2 = entrySide === 'top' ? {{x: ep.x, y: ep.y - S}}
2084 : entrySide === 'bottom' ? {{x: ep.x, y: ep.y + S}}
2085 : entrySide === 'left' ? {{x: ep.x - S, y: ep.y}}
2086 : {{x: ep.x + S, y: ep.y}};
2087 if (side === 'left') {{
2088 const mx = _gbLeft - _RMARGIN - so;
2089 return [sp, s1, {{x: mx, y: s1.y}}, {{x: mx, y: s2.y}}, s2, ep];
2090 }}
2091 if (side === 'right') {{
2092 const mx = _gbRight + _RMARGIN + so;
2093 return [sp, s1, {{x: mx, y: s1.y}}, {{x: mx, y: s2.y}}, s2, ep];
2094 }}
2095 if (side === 'top') {{
2096 const my = _gbTop - _RMARGIN - so;
2097 return [sp, s1, {{x: s1.x, y: my}}, {{x: s2.x, y: my}}, s2, ep];
2098 }}
2099 // bottom
2100 const my = _gbBottom + _RMARGIN + so;
2101 return [sp, s1, {{x: s1.x, y: my}}, {{x: s2.x, y: my}}, s2, ep];
2102 }}
2103
2104 // Iteratively reroute edges with crossings via margins
2105 const _marginUsed = {{ left: 0, right: 0, top: 0, bottom: 0 }};
2106 const _tried = new Set();
2107 for (let _ri = 0; _ri < 30; _ri++) {{
2108 // Find edge with most crossings that hasn't been tried
2109 let worstIdx = -1, worstCnt = 0;
2110 for (let i = 0; i < _allEdgePaths.length; i++) {{
2111 if (_allEdgePaths[i].isPeEdge || _tried.has(i)) continue;
2112 const cnt = _cntCross(i);
2113 if (cnt > worstCnt) {{ worstCnt = cnt; worstIdx = i; }}
2114 }}
2115 if (worstIdx < 0 || worstCnt === 0) break;
2116
2117 const ei = _edgeSides[worstIdx];
2118 if (!ei) {{ _tried.add(worstIdx); continue; }}
2119 const origPts = _allEdgePaths[worstIdx].pts;
2120 const sp = origPts[0];
2121 const ep = origPts[origPts.length - 1];
2122 let bestPts = origPts, bestCnt = worstCnt, bestSide = null;
2123
2124 // Calculate span width for each margin to assign proper slot depth
2125 for (const side of ['left', 'right', 'top', 'bottom']) {{
2126 const alt = _mRoute(sp, ep, ei.exitSide, ei.entrySide, side, _marginUsed[side]);
2127 _allEdgePaths[worstIdx].pts = alt;
2128 const cnt = _cntCross(worstIdx);
2129 _allEdgePaths[worstIdx].pts = origPts;
2130 if (cnt < bestCnt) {{
2131 bestCnt = cnt; bestPts = alt; bestSide = side;
2132 }}
2133 }}
2134
2135 if (bestSide && bestCnt < worstCnt) {{
2136 _allEdgePaths[worstIdx].pts = bestPts;
2137 _marginUsed[bestSide] += _RM_SLOT;
2138 }} else {{
2139 _tried.add(worstIdx); // mark as cannot-improve, try next edge
2140 }}
2141 }}
2142
2143 // POST-REROUTE: sort co-margin edges by span width (widest = outermost)
2144 // Prevents vertical-segment crossings between edges on same margin side
2145 const _marginEdges = {{ left: [], right: [], top: [], bottom: [] }};
2146 for (let i = 0; i < _allEdgePaths.length; i++) {{
2147 const pts = _allEdgePaths[i].pts;
2148 if (pts.length !== 6) continue; // only margin-routed edges have 6 points
2149 // Detect which margin side this edge uses
2150 const p2 = pts[2], p3 = pts[3];
2151 if (p2.y === p3.y) {{
2152 // horizontal segment on margin → top or bottom
2153 if (p2.y < _gbTop) {{ _marginEdges.top.push(i); }}
2154 else if (p2.y > _gbBottom) {{ _marginEdges.bottom.push(i); }}
2155 }} else if (p2.x === p3.x) {{
2156 // vertical segment on margin → left or right
2157 if (p2.x < _gbLeft) {{ _marginEdges.left.push(i); }}
2158 else if (p2.x > _gbRight) {{ _marginEdges.right.push(i); }}
2159 }}
2160 }}
2161 // Sort each margin group: widest span → outermost slot
2162 for (const side of ['left', 'right', 'top', 'bottom']) {{
2163 const idxs = _marginEdges[side];
2164 if (idxs.length < 2) continue;
2165 const isHoriz = (side === 'top' || side === 'bottom');
2166 // Calculate span for each edge
2167 const spans = idxs.map(i => {{
2168 const pts = _allEdgePaths[i].pts;
2169 return isHoriz
2170 ? Math.abs(pts[2].x - pts[3].x)
2171 : Math.abs(pts[2].y - pts[3].y);
2172 }});
2173 // Sort indices by span descending (widest first → outermost)
2174 const sorted = idxs.map((idx, j) => ({{ idx, span: spans[j] }}))
2175 .sort((a, b) => b.span - a.span);
2176 // Reassign y/x positions for sorted edges
2177 const baseMargin = isHoriz
2178 ? (side === 'top' ? _gbTop - _RMARGIN : _gbBottom + _RMARGIN)
2179 : (side === 'left' ? _gbLeft - _RMARGIN : _gbRight + _RMARGIN);
2180 const dir = (side === 'top' || side === 'left') ? -1 : 1;
2181 sorted.forEach((s, k) => {{
2182 const pts = _allEdgePaths[s.idx].pts;
2183 const newM = baseMargin + dir * k * _RM_SLOT;
2184 if (isHoriz) {{
2185 pts[2].y = newM; pts[3].y = newM;
2186 }} else {{
2187 pts[2].x = newM; pts[3].x = newM;
2188 }}
2189 }});
2190 }}
2191
2192 // BOTTOM-LANE REROUTER — marshalled U-shape approach
2193 // Reroute overlapping edges via evenly-spaced horizontal lanes below all sections.
2194 // Direct vertical descent from node when possible (2 bends); offset only when blocked (4 bends).
2195 const OSEP2 = 14;
2196 const _bottomLaneBase = _gbBottom + _RMARGIN + 30;
2197 let _bottomSlot = 0;
2198 const _LANE_SPC = OSEP2; // 14px lane spacing — precise marshalled look
2199 const _COL_SPC = OSEP2; // minimum distance between vertical corridors
2200 const _rerouted = new Set();
2201 const _usedCols = [];
2202 function _colUsed(cx) {{ for (const ux of _usedCols) {{ if (Math.abs(cx - ux) < _COL_SPC) return true; }} return false; }}
2203 // Check if vertical column is free of nodes and non-exempt section boxes
2204 function _isColClear(cx, yMin, yMax, skipId1, skipId2, skipGbs) {{
2205 for (const _nd of NODES) {{
2206 if (_nd.id === skipId1 || _nd.id === skipId2) continue;
2207 const _np = positions[_nd.id]; if (!_np) continue;
2208 const _nw = _nd.type === 'pe' ? PE_W : SVC_W;
2209 const _nh = (_nd.type === 'pe' ? PE_H : SVC_H) + 20;
2210 const _pad = 6;
2211 if (cx > _np.x - _pad && cx < _np.x + _nw + _pad &&
2212 yMin < _np.y + _nh + _pad && yMax > _np.y - _pad) {{
2213 return false;
2214 }}
2215 }}
2216 for (const _gb of groupBoxes) {{
2217 if (skipGbs && skipGbs.indexOf(_gb) >= 0) continue;
2218 if (cx > _gb.x - 4 && cx < _gb.x + _gb.w + 4 &&
2219 yMin < _gb.y + _gb.h + 4 && yMax > _gb.y - 4) {{
2220 return false;
2221 }}
2222 }}
2223 return true;
2224 }}
2225 // Check if horizontal row is free of nodes and non-exempt section boxes
2226 function _isRowClear(cy, xMin, xMax, skipId1, skipId2, skipGbs) {{
2227 for (const _nd of NODES) {{
2228 if (_nd.id === skipId1 || _nd.id === skipId2) continue;
2229 const _np = positions[_nd.id]; if (!_np) continue;
2230 const _nw = _nd.type === 'pe' ? PE_W : SVC_W;
2231 const _nh = (_nd.type === 'pe' ? PE_H : SVC_H) + 20;
2232 const _pad = 6;
2233 if (cy > _np.y - _pad && cy < _np.y + _nh + _pad &&
2234 xMin < _np.x + _nw + _pad && xMax > _np.x - _pad) {{
2235 return false;
2236 }}
2237 }}
2238 for (const _gb of groupBoxes) {{
2239 if (skipGbs && skipGbs.indexOf(_gb) >= 0) continue;
2240 if (cy > _gb.y - 4 && cy < _gb.y + _gb.h + 4 &&
2241 xMin < _gb.x + _gb.w + 4 && xMax > _gb.x - 4) {{
2242 return false;
2243 }}
2244 }}
2245 return true;
2246 }}
2247 function _findGb(px, py) {{
2248 for (const _gb of groupBoxes) {{
2249 if (px >= _gb.x && px <= _gb.x + _gb.w && py >= _gb.y && py <= _gb.y + _gb.h) return _gb;
2250 }}
2251 return null;
2252 }}
2253 // Find nearest clear column starting from preferred x, skipping source/dest sections
2254 function _findCol(prefX, yMin, yMax, skipId1, skipId2, skipGbs, preferDir) {{
2255 // Try preferred position first (direct vertical from node)
2256 if (!_colUsed(prefX) && _isColClear(prefX, yMin, yMax, skipId1, skipId2, skipGbs)) return prefX;
2257 // Search outward in small steps
2258 const _dirs = preferDir < 0 ? [-1, 1] : (preferDir > 0 ? [1, -1] : [-1, 1]);
2259 for (let _t = 1; _t <= 100; _t++) {{
2260 for (const _d of _dirs) {{
2261 const _cx = prefX + _d * _t * OSEP;
2262 if (_cx < 20) continue;
2263 if (_colUsed(_cx)) continue;
2264 if (_isColClear(_cx, yMin, yMax, skipId1, skipId2, skipGbs)) return _cx;
2265 }}
2266 }}
2267 return null;
2268 }}
2269 for (let _blPass = 0; _blPass < 20; _blPass++) {{
2270 let _worstEdge = -1, _worstCount = 0;
2271 for (let i = 0; i < _allEdgePaths.length; i++) {{
2272 if (_rerouted.has(i)) continue;
2273 let cnt = 0;
2274 const pB = _allEdgePaths[i].pts;
2275 for (let j = 0; j < _allEdgePaths.length; j++) {{
2276 if (j === i) continue;
2277 const pA = _allEdgePaths[j].pts;
2278 let maxOv = 0;
2279 for (let si = 0; si < pA.length - 1; si++) {{
2280 for (let sj = 0; sj < pB.length - 1; sj++) {{
2281 const a1 = pA[si], a2 = pA[si + 1], b1 = pB[sj], b2 = pB[sj + 1];
2282 if (Math.abs(a1.y - a2.y) < 2 && Math.abs(b1.y - b2.y) < 2 && Math.abs(a1.y - b1.y) < OSEP2) {{
2283 const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))
2284 - Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));
2285 if (ov > maxOv) maxOv = ov;
2286 }}
2287 if (Math.abs(a1.x - a2.x) < 2 && Math.abs(b1.x - b2.x) < 2 && Math.abs(a1.x - b1.x) < OSEP2) {{
2288 const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))
2289 - Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));
2290 if (ov > maxOv) maxOv = ov;
2291 }}
2292 }}
2293 }}
2294 if (maxOv > 20) cnt++;
2295 }}
2296 if (cnt > _worstCount) {{ _worstCount = cnt; _worstEdge = i; }}
2297 }}
2298 if (_worstEdge < 0) break;
2299 const pB = _allEdgePaths[_worstEdge].pts;
2300 const _fromId = _allEdgePaths[_worstEdge].edge.from;
2301 const _toId = _allEdgePaths[_worstEdge].edge.to;
2302 const start = pB[0];
2303 const end = pB[pB.length - 1];
2304 // Source/dest sections are exempt — verticals can pass through own sections
2305 const srcGb = _findGb(start.x, start.y);
2306 const dstGb = _findGb(end.x, end.y);
2307 const skipGbs = [srcGb, dstGb].filter(g => g !== null);
2308 const _yMin = Math.min(start.y, end.y);
2309 const _yMax = Math.max(start.y, end.y);
2310 const _spanX = Math.abs(end.x - start.x);
2311 // Prefer a local single-column reroute first to avoid long bottom-lane detours.
2312 const _localPrefX = (start.x + end.x) / 2;
2313 const _localX = _findCol(_localPrefX, _yMin, _yMax, _fromId, _toId, skipGbs);
2314 const _localLimit = Math.max(_spanX + 40, 120);
2315 if (_localX !== null &&
2316 Math.abs(_localX - start.x) <= _localLimit &&
2317 Math.abs(_localX - end.x) <= _localLimit &&
2318 _isRowClear(start.y, Math.min(start.x, _localX), Math.max(start.x, _localX), _fromId, _toId, skipGbs) &&
2319 _isRowClear(end.y, Math.min(end.x, _localX), Math.max(end.x, _localX), _fromId, _toId, skipGbs)) {{
2320 _usedCols.push(_localX);
2321 pB.length = 0;
2322 pB.push(start);
2323 if (Math.abs(_localX - start.x) > 2) pB.push({{ x: _localX, y: start.y }});
2324 if (Math.abs(end.y - start.y) > 2) pB.push({{ x: _localX, y: end.y }});
2325 if (Math.abs(_localX - end.x) > 2) pB.push({{ x: _localX, y: end.y }});
2326 pB.push(end);
2327 _rerouted.add(_worstEdge);
2328 continue;
2329 }}
2330 const laneY = _bottomLaneBase + _bottomSlot * _LANE_SPC;
2331 const _towardEnd = end.x >= start.x ? 1 : -1;
2332 const _exitX = _findCol(start.x, Math.min(start.y, laneY), Math.max(start.y, laneY), _fromId, _toId, skipGbs, _towardEnd);
2333 const _enterX = _findCol(end.x, Math.min(end.y, laneY), Math.max(end.y, laneY), _fromId, _toId, skipGbs, -_towardEnd);
2334 if (_exitX === null || _enterX === null) {{
2335 _rerouted.add(_worstEdge);
2336 continue;
2337 }}
2338 _usedCols.push(_exitX);
2339 _usedCols.push(_enterX);
2340 _bottomSlot++;
2341 pB.length = 0;
2342 pB.push(start);
2343 // Only add horizontal stub if exit column differs from node x
2344 if (Math.abs(_exitX - start.x) > 2) pB.push({{ x: _exitX, y: start.y }});
2345 pB.push({{ x: _exitX, y: laneY }});
2346 pB.push({{ x: _enterX, y: laneY }});
2347 if (Math.abs(_enterX - end.x) > 2) pB.push({{ x: _enterX, y: end.y }});
2348 pB.push(end);
2349 _rerouted.add(_worstEdge);
2350 }}
2351 // POST-REROUTE OVERLAP SEPARATION — push rerouted segments apart
2352 for (let _rSep = 0; _rSep < 6; _rSep++) {{
2353 for (let i = 0; i < _allEdgePaths.length; i++) {{
2354 for (let j = i + 1; j < _allEdgePaths.length; j++) {{
2355 const pA = _allEdgePaths[i].pts;
2356 const pB = _allEdgePaths[j].pts;
2357 // Separate all edge pairs (rerouted or not) to handle post-reroute overlaps
2358 const dir = (j % 2 === 0) ? 1 : -1;
2359 for (let si = 0; si < pA.length - 1; si++) {{
2360 for (let sj = 0; sj < pB.length - 1; sj++) {{
2361 const a1 = pA[si], a2 = pA[si + 1];
2362 const b1 = pB[sj], b2 = pB[sj + 1];
2363 const aV = Math.abs(a1.x - a2.x) < 2;
2364 const bV = Math.abs(b1.x - b2.x) < 2;
2365 const aH = Math.abs(a1.y - a2.y) < 2;
2366 const bH = Math.abs(b1.y - b2.y) < 2;
2367 if (aV && bV && Math.abs(a1.x - b1.x) < OSEP2) {{
2368 const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))
2369 - Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));
2370 if (ov > 10) {{
2371 let shift = OSEP2 * dir;
2372 if (b1.x + shift < 20) shift = Math.abs(shift);
2373 if (sj > 0) pB[sj] = {{ x: b1.x + shift, y: b1.y }};
2374 if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x + shift, y: b2.y }};
2375 }}
2376 }}
2377 if (aH && bH && Math.abs(a1.y - b1.y) < OSEP2) {{
2378 const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))
2379 - Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));
2380 if (ov > 10) {{
2381 const shift = OSEP2 * dir;
2382 if (sj > 0) pB[sj] = {{ x: b1.x, y: b1.y + shift }};
2383 if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x, y: b2.y + shift }};
2384 }}
2385 }}
2386 }}
2387 }}
2388 }}
2389 }}
2390 }}
2391 // POST-REROUTE ORTHOGONALIZATION
2392 _allEdgePaths.forEach(({{ pts }}) => {{
2393 for (let _i = 0; _i < pts.length - 1; _i++) {{
2394 const _a = pts[_i], _b = pts[_i + 1];
2395 if (Math.abs(_a.x - _b.x) > 1 && Math.abs(_a.y - _b.y) > 1) {{
2396 pts.splice(_i + 1, 0, {{x: _a.x, y: _b.y}});
2397 }}
2398 }}
2399 for (let _i = pts.length - 2; _i >= 1; _i--) {{
2400 const _a = pts[_i - 1], _b = pts[_i], _c = pts[_i + 1];
2401 if (Math.abs(_a.x - _b.x) <= 1 && Math.abs(_a.y - _b.y) <= 1) {{
2402 pts.splice(_i, 1); continue;
2403 }}
2404 if ((Math.abs(_a.x - _b.x) <= 1 && Math.abs(_b.x - _c.x) <= 1) ||
2405 (Math.abs(_a.y - _b.y) <= 1 && Math.abs(_b.y - _c.y) <= 1)) {{
2406 pts.splice(_i, 1);
2407 }}
2408 }}
2409 }});
2410
2411 // FINAL OVERLAP SEPARATION — catch any overlaps re-created by orthogonalization
2412 for (let _fSep = 0; _fSep < 4; _fSep++) {{
2413 for (let i = 0; i < _allEdgePaths.length; i++) {{
2414 for (let j = i + 1; j < _allEdgePaths.length; j++) {{
2415 const pA = _allEdgePaths[i].pts;
2416 const pB = _allEdgePaths[j].pts;
2417 for (let si = 0; si < pA.length - 1; si++) {{
2418 for (let sj = 0; sj < pB.length - 1; sj++) {{
2419 const a1 = pA[si], a2 = pA[si + 1];
2420 const b1 = pB[sj], b2 = pB[sj + 1];
2421 const aH = Math.abs(a1.y - a2.y) < 2;
2422 const bH = Math.abs(b1.y - b2.y) < 2;
2423 const aV = Math.abs(a1.x - a2.x) < 2;
2424 const bV = Math.abs(b1.x - b2.x) < 2;
2425 if (aH && bH && Math.abs(a1.y - b1.y) < 6) {{
2426 const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))
2427 - Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));
2428 if (ov > 20) {{
2429 const shift = 8 * ((j % 2 === 0) ? 1 : -1);
2430 if (sj > 0) pB[sj] = {{ x: b1.x, y: b1.y + shift }};
2431 if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x, y: b2.y + shift }};
2432 }}
2433 }}
2434 if (aV && bV && Math.abs(a1.x - b1.x) < 6) {{
2435 const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))
2436 - Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));
2437 if (ov > 20) {{
2438 let shift = 8 * ((j % 2 === 0) ? 1 : -1);
2439 if (b1.x + shift < 20) shift = Math.abs(shift);
2440 if (sj > 0) pB[sj] = {{ x: b1.x + shift, y: b1.y }};
2441 if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x + shift, y: b2.y }};
2442 }}
2443 }}
2444 }}
2445 }}
2446 }}
2447 }}
2448 }}
2449
2450 // FINAL DIAGONAL BREAKER — any non-orthogonal segment is split into an L-shape.
2451 // Diagonals may be introduced by the separation pass above when only one
2452 // endpoint of a segment is shifted. Axis-align every segment as a last safety net.
2453 for (const _ep of _allEdgePaths) {{
2454 const pts = _ep.pts;
2455 for (let k = 0; k < pts.length - 1; k++) {{
2456 const q1 = pts[k], q2 = pts[k + 1];
2457 const dx = q2.x - q1.x;
2458 const dy = q2.y - q1.y;
2459 if (Math.abs(dx) > 1 && Math.abs(dy) > 1) {{
2460 // Insert elbow at (q2.x, q1.y) — preserves endpoints, forces L-shape.
2461 // Direction heuristic: follow the dominant axis first.
2462 const elbow = Math.abs(dx) >= Math.abs(dy)
2463 ? {{ x: q2.x, y: q1.y }}
2464 : {{ x: q1.x, y: q2.y }};
2465 pts.splice(k + 1, 0, elbow);
2466 // Re-check the newly inserted segments in the next iteration
2467 }}
2468 }}
2469 }}
2470
2471 // CROSSING DETECTION— find which edges cross each other (for color differentiation)
2472 const _crossNeighbors = {{}};
2473 for (let i = 0; i < _allEdgePaths.length; i++) {{
2474 for (let j = i + 1; j < _allEdgePaths.length; j++) {{
2475 const ptsA = _allEdgePaths[i].pts;
2476 const ptsB = _allEdgePaths[j].pts;
2477 let crossed = false;
2478 for (let si = 0; si < ptsA.length - 1 && !crossed; si++) {{
2479 for (let sj = 0; sj < ptsB.length - 1 && !crossed; sj++) {{
2480 if (findSegCrossing(
2481 ptsA[si].x, ptsA[si].y, ptsA[si + 1].x, ptsA[si + 1].y,
2482 ptsB[sj].x, ptsB[sj].y, ptsB[sj + 1].x, ptsB[sj + 1].y
2483 )) crossed = true;
2484 }}
2485 }}
2486 if (crossed) {{
2487 if (!_crossNeighbors[i]) _crossNeighbors[i] = new Set();
2488 if (!_crossNeighbors[j]) _crossNeighbors[j] = new Set();
2489 _crossNeighbors[i].add(j);
2490 _crossNeighbors[j].add(i);
2491 }}
2492 }}
2493 }}
2494
2495 // Greedy graph coloring — crossing edges get distinct colors
2496 const _CROSS_COLORS = ['#0078D4', '#E3008C', '#00B7C3', '#FF8C00', '#107C10', '#881798'];
2497 const _edgeColor = {{}};
2498 const crossingEdges = Object.keys(_crossNeighbors).map(Number)
2499 .sort((a, b) => _crossNeighbors[b].size - _crossNeighbors[a].size);
2500 crossingEdges.forEach(eIdx => {{
2501 const neighborColors = new Set();
2502 _crossNeighbors[eIdx].forEach(n => {{
2503 if (_edgeColor[n] !== undefined) neighborColors.add(_edgeColor[n]);
2504 }});
2505 let colorIdx = 0;
2506 while (neighborColors.has(colorIdx)) colorIdx++;
2507 _edgeColor[eIdx] = colorIdx;
2508 }});
2509
2510 // RENDER EDGES — no bridge arcs, just orthogonal paths with color coding
2511
2512 function renderEdge({{ edge, pts, isPeEdge, edgeIdx }}) {{
2513 let pathD;
2514 if (pts.length <= 2) {{
2515 pathD = `M ${{pts[0].x}} ${{pts[0].y}} L ${{pts[pts.length - 1].x}} ${{pts[pts.length - 1].y}}`;
2516 }} else {{
2517 pathD = buildOrthoPath(pts);
2518 }}
2519
2520 // Determine edge color: PE=purple, crossing=colored, normal=gray
2521 let edgeStroke, edgeOpacity;
2522 if (isPeEdge) {{
2523 edgeStroke = '#5C2D91';
2524 edgeOpacity = '0.5';
2525 }} else if (_edgeColor[edgeIdx] !== undefined) {{
2526 edgeStroke = _CROSS_COLORS[_edgeColor[edgeIdx] % _CROSS_COLORS.length];
2527 edgeOpacity = '0.75';
2528 }} else {{
2529 edgeStroke = '#8a8886';
2530 edgeOpacity = '0.65';
2531 }}
2532
2533 const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
2534 path.setAttribute('d', pathD);
2535 path.setAttribute('fill', 'none');
2536 path.setAttribute('stroke', edgeStroke);
2537 path.setAttribute('stroke-width', isPeEdge ? '1' : '1.2');
2538 path.setAttribute('stroke-dasharray', edge.dash || '0');
2539 path.setAttribute('marker-end', `url(#${{markerFor(edge.type)}})`);
2540 path.setAttribute('opacity', edgeOpacity);
2541 path.classList.add('edge-path');
2542 path.setAttribute('data-from', edge.from);
2543 path.setAttribute('data-to', edge.to);
2544 root.appendChild(path);
2545
2546 // Label placement — collision-aware
2547 if (edge.label) {{
2548 const bw = edge.label.length * 5.5 + 10;
2549 const bh = 14;
2550
2551 function labelHitsNode(lx, ly) {{
2552 return NODES.some(n => {{
2553 const p = positions[n.id];
2554 if (!p) return false;
2555 const nw = n.type === 'pe' ? PE_W : SVC_W;
2556 const nh = n.type === 'pe' ? PE_H : SVC_H;
2557 return lx + bw/2 > p.x && lx - bw/2 < p.x + nw
2558 && ly + bh/2 > p.y && ly - bh/2 < p.y + nh;
2559 }});
2560 }}
2561
2562 const candidates = [];
2563 for (let s = 0; s < pts.length - 1; s++) {{
2564 const cx = (pts[s].x + pts[s + 1].x) / 2;
2565 const cy = (pts[s].y + pts[s + 1].y) / 2;
2566 const priority = Math.abs(s - (pts.length - 2) / 2);
2567 candidates.push({{ x: cx, y: cy, priority }});
2568 }}
2569 candidates.sort((a, b) => a.priority - b.priority);
2570
2571 let chosen = candidates[0];
2572 for (const c of candidates) {{
2573 if (!labelHitsNode(c.x, c.y)) {{ chosen = c; break; }}
2574 }}
2575
2576 if (labelHitsNode(chosen.x, chosen.y)) {{
2577 const offsets = [{{x:0,y:-20}},{{x:0,y:20}},{{x:-20,y:0}},{{x:20,y:0}}];
2578 for (const off of offsets) {{
2579 if (!labelHitsNode(chosen.x + off.x, chosen.y + off.y)) {{
2580 chosen = {{ x: chosen.x + off.x, y: chosen.y + off.y }};
2581 break;
2582 }}
2583 }}
2584 }}
2585
2586 _edgeLabels.push({{ label: edge.label, x: chosen.x, y: chosen.y, from: edge.from, to: edge.to }});
2587 }}
2588
2589 return {{ path, edge, pts }};
2590 }}
2591
2592 // Render all edges
2593 _allEdgePaths.forEach((ep, edgeIdx) => renderEdge({{ ...ep, edgeIdx }}));
2594
2595 // Re-append group labels on top of edges
2596 _groupLabelElements.forEach(el => root.appendChild(el));
2597
2598 // ── Nodes (rendered LAST — on top of edges, covering crossing points) ──
2599 NODES.forEach(node => {{
2600 const pos = positions[node.id];
2601 if (!pos) return;
2602 const isPe = node.type === 'pe';
2603 const nw = isPe ? PE_W : SVC_W;
2604 const nh = isPe ? PE_H : SVC_H;
2605 const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2606 g.setAttribute('class', 'node');
2607 g.setAttribute('data-id', node.id);
2608 g.setAttribute('transform', `translate(${{pos.x}},${{pos.y}})`);
2609
2610 // Card background — full clickable area
2611 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
2612 rect.setAttribute('class', 'node-bg');
2613 rect.setAttribute('width', nw); rect.setAttribute('height', nh);
2614 rect.setAttribute('rx', '8'); rect.setAttribute('fill', 'white');
2615 rect.setAttribute('stroke', '#c8c6c4'); rect.setAttribute('stroke-width', '1.2');
2616 rect.setAttribute('filter', 'url(#shadow)');
2617 g.appendChild(rect);
2618
2619 // Color accent bar at top
2620 const accent = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
2621 accent.setAttribute('width', nw); accent.setAttribute('height', '3');
2622 accent.setAttribute('rx', '8'); accent.setAttribute('fill', node.color);
2623 accent.setAttribute('opacity', '0.7');
2624 g.appendChild(accent);
2625
2626 // Icon — official Azure icon (data URI) preferred, fallback to SVG
2627 const iconSize = isPe ? 28 : 36;
2628 const iconX = (nw - iconSize) / 2;
2629 const iconY = isPe ? 12 : 14;
2630 if (node.icon_data_uri) {{
2631 // Official Azure icon (Base64 image)
2632 const iconImg = document.createElementNS('http://www.w3.org/2000/svg', 'image');
2633 iconImg.setAttribute('x', iconX); iconImg.setAttribute('y', iconY);
2634 iconImg.setAttribute('width', iconSize); iconImg.setAttribute('height', iconSize);
2635 iconImg.setAttributeNS('http://www.w3.org/1999/xlink', 'href', node.icon_data_uri);
2636 g.appendChild(iconImg);
2637 }} else {{
2638 // Fallback: built-in SVG text icon
2639 const iconG = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2640 iconG.setAttribute('x', iconX); iconG.setAttribute('y', iconY);
2641 iconG.setAttribute('width', iconSize); iconG.setAttribute('height', iconSize);
2642 iconG.setAttribute('viewBox', '0 0 48 48');
2643 iconG.innerHTML = node.icon_svg;
2644 g.appendChild(iconG);
2645 }}
2646
2647 // Name — extra gap below icon (icon bottom ~50, name baseline at 74 → 24px breathing room)
2648 const name = document.createElementNS('http://www.w3.org/2000/svg', 'text');
2649 name.setAttribute('x', nw/2); name.setAttribute('y', isPe ? 64 : 74);
2650 name.setAttribute('text-anchor', 'middle');
2651 name.setAttribute('font-size', isPe ? '10' : '11');
2652 name.setAttribute('font-weight', '600'); name.setAttribute('fill', '#323130');
2653 name.setAttribute('font-family', 'Segoe UI, sans-serif');
2654 const maxC = isPe ? 14 : 20;
2655 name.textContent = node.name.length > maxC ? node.name.substring(0, maxC-1) + '..' : node.name;
2656 g.appendChild(name);
2657
2658 // SKU label
2659 if (!isPe && node.sku) {{
2660 const sku = document.createElementNS('http://www.w3.org/2000/svg', 'text');
2661 sku.setAttribute('x', nw/2); sku.setAttribute('y', 90);
2662 sku.setAttribute('text-anchor', 'middle');
2663 sku.setAttribute('font-size', '10'); sku.setAttribute('fill', '#a19f9d');
2664 sku.setAttribute('font-family', 'Segoe UI, sans-serif');
2665 sku.textContent = node.sku;
2666 g.appendChild(sku);
2667 }}
2668
2669 if (isPe && node.details.length > 0) {{
2670 const det = document.createElementNS('http://www.w3.org/2000/svg', 'text');
2671 det.setAttribute('x', nw/2); det.setAttribute('y', 76);
2672 det.setAttribute('text-anchor', 'middle');
2673 det.setAttribute('font-size', '9'); det.setAttribute('fill', '#a19f9d');
2674 det.setAttribute('font-family', 'Segoe UI, sans-serif');
2675 det.textContent = node.details[0];
2676 g.appendChild(det);
2677 }}
2678
2679 // Service type label below (not category — show actual service type name)
2680 if (!isPe) {{
2681 const TYPE_LABELS = {{
2682 'ai_foundry': 'AI Foundry', 'openai': 'Azure OpenAI', 'search': 'AI Search', 'ai_search': 'AI Search',
2683 'storage': 'Storage', 'adls': 'ADLS Gen2', 'keyvault': 'Key Vault', 'kv': 'Key Vault',
2684 'fabric': 'Fabric', 'databricks': 'Databricks', 'adf': 'Data Factory', 'data_factory': 'Data Factory',
2685 'sql_server': 'SQL Server', 'sql_database': 'SQL Database', 'cosmos_db': 'Cosmos DB',
2686 'vm': 'Virtual Machine', 'aks': 'AKS', 'app_service': 'App Service',
2687 'function_app': 'Function App', 'synapse': 'Synapse', 'vnet': 'VNet',
2688 'nsg': 'NSG', 'bastion': 'Bastion', 'pe': 'Private Endpoint',
2689 'log_analytics': 'Log Analytics', 'app_insights': 'App Insights',
2690 'monitor': 'Monitor', 'acr': 'Container Registry', 'container_registry': 'Container Registry',
2691 'document_intelligence': 'Doc Intelligence', 'form_recognizer': 'Doc Intelligence',
2692 'cdn': 'CDN', 'event_hub': 'Event Hub', 'redis': 'Redis Cache',
2693 'devops': 'Azure DevOps', 'app_gateway': 'App Gateway',
2694 'iot_hub': 'IoT Hub', 'stream_analytics': 'Stream Analytics',
2695 'vpn_gateway': 'VPN Gateway', 'front_door': 'Front Door',
2696 'ai_hub': 'AI Hub', 'firewall': 'Firewall',
2697 }};
2698 const typeLabel = TYPE_LABELS[node.type] || node.type;
2699 const cat = document.createElementNS('http://www.w3.org/2000/svg', 'text');
2700 cat.setAttribute('x', nw/2); cat.setAttribute('y', nh + 14);
2701 cat.setAttribute('text-anchor', 'middle');
2702 cat.setAttribute('font-size', '10'); cat.setAttribute('fill', node.color);
2703 cat.setAttribute('font-weight', '600');
2704 cat.setAttribute('font-family', 'Segoe UI, sans-serif');
2705 cat.textContent = typeLabel;
2706 g.appendChild(cat);
2707 }}
2708
2709 // Private badge on card
2710 if (node.private && !isPe) {{
2711 const badge = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2712 const br = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
2713 br.setAttribute('x', nw - 8); br.setAttribute('y', '4');
2714 br.setAttribute('width', '6'); br.setAttribute('height', '6');
2715 br.setAttribute('rx', '3'); br.setAttribute('fill', '#5C2D91');
2716 br.setAttribute('opacity', '0.6');
2717 badge.appendChild(br);
2718 g.appendChild(badge);
2719 }}
2720
2721 // ── Events: drag vs click separation ──
2722 g.addEventListener('mousedown', e => {{
2723 if (e.button !== 0) return;
2724 dragging = node.id;
2725 _didDrag = false;
2726 _dragStartX = e.clientX; _dragStartY = e.clientY;
2727 const svgPt = getSVGPoint(e);
2728 dragOffX = svgPt.x - pos.x; dragOffY = svgPt.y - pos.y;
2729 e.stopPropagation(); e.preventDefault();
2730 }});
2731 g.addEventListener('mousemove', e => {{
2732 if (dragging === node.id) {{
2733 const dx = Math.abs(e.clientX - _dragStartX);
2734 const dy = Math.abs(e.clientY - _dragStartY);
2735 if (dx > 3 || dy > 3) _didDrag = true;
2736 }}
2737 }});
2738 g.addEventListener('mouseup', e => {{
2739 if (!_didDrag && dragging === node.id) {{
2740 selectNode(node.id);
2741 }}
2742 }});
2743 g.addEventListener('mouseenter', e => {{
2744 const tt = document.getElementById('tooltip');
2745 const dets = node.details.map(d => `<div class="tooltip-detail">› ${{d}}</div>`).join('');
2746 tt.style.display = 'block';
2747 tt.innerHTML = `<strong>${{node.name}}</strong>${{node.sku ? `<div class="tooltip-detail">SKU: ${{node.sku}}</div>` : ''}}${{dets}}`;
2748 }});
2749 g.addEventListener('mousemove', e => {{
2750 const tt = document.getElementById('tooltip');
2751 tt.style.left = (e.clientX+12)+'px'; tt.style.top = (e.clientY-8)+'px';
2752 }});
2753 g.addEventListener('mouseleave', () => {{ document.getElementById('tooltip').style.display = 'none'; }});
2754
2755 root.appendChild(g);
2756 }});
2757
2758 // ── Edge labels (rendered AFTER nodes — always visible on top) ──
2759 _edgeLabels.forEach(el => {{
2760 const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2761 g.classList.add('edge-label');
2762 g.setAttribute('data-from', el.from);
2763 g.setAttribute('data-to', el.to);
2764 const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
2765 r.classList.add('edge-label-bg');
2766 const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
2767 const bw = el.label.length * 6 + 10;
2768 r.setAttribute('x', el.x-bw/2); r.setAttribute('y', el.y-7);
2769 r.setAttribute('width', bw); r.setAttribute('height', 14);
2770 r.setAttribute('rx', '3'); r.setAttribute('fill', 'white');
2771 r.setAttribute('stroke', '#d2d0ce'); r.setAttribute('stroke-width', '0.5');
2772 r.setAttribute('opacity', '0.95');
2773 t.setAttribute('x', el.x); t.setAttribute('y', el.y+3);
2774 t.setAttribute('text-anchor', 'middle'); t.setAttribute('font-size', '9');
2775 t.setAttribute('fill', '#605e5c'); t.setAttribute('font-family', 'Segoe UI, sans-serif');
2776 t.textContent = el.label;
2777 g.appendChild(r); g.appendChild(t);
2778 root.appendChild(g);
2779 }});
2780
2781 // Re-apply text scale and selection state after DOM rebuild
2782 if (typeof _textScale !== 'undefined' && _textScale !== 1) applyTextScale();
2783 if (_selectedNodeId) applySelectionHighlight();
2784
2785}}
2786
2787function getSVGPoint(e) {{
2788 const svg = document.getElementById('canvas');
2789 const pt = svg.createSVGPoint();
2790 pt.x = e.clientX; pt.y = e.clientY;
2791 return pt.matrixTransform(document.getElementById('diagram-root').getScreenCTM().inverse());
2792}}
2793
2794document.getElementById('canvas').addEventListener('mousemove', e => {{
2795 if (dragging) {{
2796 const p = getSVGPoint(e);
2797 positions[dragging].x = p.x - dragOffX;
2798 positions[dragging].y = p.y - dragOffY;
2799 renderDiagram();
2800 }} else if (draggingGroup !== null) {{
2801 const p = getSVGPoint(e);
2802 const dx = p.x - dragOffX;
2803 const dy = p.y - dragOffY;
2804 dragOffX = p.x; dragOffY = p.y;
2805 // Move all nodes in the group
2806 groupDragNodes.forEach(nid => {{
2807 if (positions[nid]) {{
2808 positions[nid].x += dx;
2809 positions[nid].y += dy;
2810 }}
2811 }});
2812 // Also move the group box itself
2813 const gb = groupBoxes[draggingGroup];
2814 if (gb) {{ gb.x += dx; gb.y += dy; }}
2815 renderDiagram();
2816 }}
2817}});
2818document.addEventListener('mouseup', () => {{ dragging = null; draggingGroup = null; groupDragNodes = []; }});
2819
2820// ── Pan & Zoom ──
2821function applyTransform() {{
2822 document.getElementById('diagram-root').setAttribute('transform',
2823 `translate(${{viewTransform.x}},${{viewTransform.y}}) scale(${{viewTransform.scale}})`);
2824 document.getElementById('zoom-level').textContent = Math.round(viewTransform.scale * 100) + '%';
2825}}
2826function fitToScreen() {{
2827 const svg = document.getElementById('canvas');
2828 const root = document.getElementById('diagram-root');
2829 root.setAttribute('transform', '');
2830 const bbox = root.getBBox();
2831 if (!bbox.width || !bbox.height) return;
2832 const w = svg.clientWidth, h = svg.clientHeight;
2833 const s = Math.min((w-60)/bbox.width, (h-60)/bbox.height, 1.5);
2834 if (s <= 0) return;
2835 viewTransform.scale = s;
2836 viewTransform.x = (w - bbox.width*s)/2 - bbox.x*s;
2837 viewTransform.y = (h - bbox.height*s)/2 - bbox.y*s;
2838 applyTransform();
2839}}
2840function zoomIn() {{ viewTransform.scale *= 1.25; applyTransform(); }}
2841function zoomOut() {{ viewTransform.scale *= 0.8; applyTransform(); }}
2842
2843// ── Text size controls ──
2844let _textScale = 1.4; // default 40% larger than raw attribute sizes
2845function applyTextScale() {{
2846 document.querySelectorAll('#canvas text').forEach(t => {{
2847 let orig = t.getAttribute('data-orig-fs');
2848 if (!orig) {{
2849 orig = t.getAttribute('font-size');
2850 if (!orig) {{
2851 const cs = window.getComputedStyle(t).fontSize;
2852 orig = cs ? parseFloat(cs).toString() : '11';
2853 }}
2854 t.setAttribute('data-orig-fs', orig);
2855 }}
2856 t.setAttribute('font-size', (parseFloat(orig) * _textScale).toFixed(2));
2857 }});
2858}}
2859function textBigger() {{ _textScale = Math.min(2.5, _textScale * 1.15); applyTextScale(); }}
2860function textSmaller() {{ _textScale = Math.max(0.5, _textScale / 1.15); applyTextScale(); }}
2861
2862function downloadPNG() {{
2863 const svg = document.getElementById('canvas');
2864 const bbox = svg.getBBox();
2865 const pad = 40;
2866 const w = Math.ceil(bbox.width + bbox.x + pad * 2);
2867 const h = Math.ceil(bbox.height + bbox.y + pad * 2);
2868
2869 const clone = svg.cloneNode(true);
2870 clone.setAttribute('width', w);
2871 clone.setAttribute('height', h);
2872 clone.setAttribute('viewBox', `${{-pad}} ${{-pad}} ${{w}} ${{h}}`);
2873 clone.querySelector('#viewport')?.removeAttribute('transform');
2874
2875 // Inline all computed styles
2876 const allEls = clone.querySelectorAll('*');
2877 const origEls = svg.querySelectorAll('*');
2878 allEls.forEach((el, i) => {{
2879 if (origEls[i]) {{
2880 const cs = window.getComputedStyle(origEls[i]);
2881 ['fill','stroke','stroke-width','font-size','font-family','font-weight',
2882 'text-anchor','opacity','fill-opacity','stroke-opacity','stroke-dasharray'].forEach(p => {{
2883 const v = cs.getPropertyValue(p);
2884 if (v) el.style.setProperty(p, v);
2885 }});
2886 }}
2887 }});
2888
2889 const serializer = new XMLSerializer();
2890 const svgStr = serializer.serializeToString(clone);
2891 const svgBlob = new Blob([svgStr], {{type: 'image/svg+xml;charset=utf-8'}});
2892 const url = URL.createObjectURL(svgBlob);
2893
2894 const img = new Image();
2895 img.onload = () => {{
2896 const canvas = document.createElement('canvas');
2897 const scale = 2;
2898 canvas.width = w * scale;
2899 canvas.height = h * scale;
2900 const ctx = canvas.getContext('2d');
2901 ctx.scale(scale, scale);
2902 ctx.fillStyle = '#ffffff';
2903 ctx.fillRect(0, 0, w, h);
2904 ctx.drawImage(img, 0, 0, w, h);
2905 URL.revokeObjectURL(url);
2906
2907 canvas.toBlob(blob => {{
2908 const a = document.createElement('a');
2909 a.href = URL.createObjectURL(blob);
2910 a.download = (document.title || 'azure-architecture') + '.png';
2911 a.click();
2912 URL.revokeObjectURL(a.href);
2913 }}, 'image/png');
2914 }};
2915 img.src = url;
2916}}
2917
2918document.getElementById('canvas').addEventListener('wheel', e => {{
2919 e.preventDefault();
2920 const f = e.deltaY < 0 ? 1.1 : 0.9;
2921 const rect = document.getElementById('canvas').getBoundingClientRect();
2922 const mx = e.clientX - rect.left, my = e.clientY - rect.top;
2923 const os = viewTransform.scale, ns = os * f;
2924 viewTransform.x = mx - (mx - viewTransform.x) * (ns/os);
2925 viewTransform.y = my - (my - viewTransform.y) * (ns/os);
2926 viewTransform.scale = ns;
2927 applyTransform();
2928}}, {{ passive: false }});
2929
2930document.getElementById('canvas').addEventListener('mousedown', e => {{
2931 if (e.target.closest('.node')) return;
2932 isPanning = true;
2933 panSX = e.clientX; panSY = e.clientY;
2934 panSTx = viewTransform.x; panSTy = viewTransform.y;
2935 document.getElementById('canvas').style.cursor = 'grabbing';
2936 e.preventDefault();
2937}});
2938document.addEventListener('mousemove', e => {{
2939 if (isPanning) {{
2940 viewTransform.x = panSTx + (e.clientX - panSX);
2941 viewTransform.y = panSTy + (e.clientY - panSY);
2942 applyTransform();
2943 }}
2944}});
2945document.addEventListener('mouseup', () => {{
2946 if (isPanning) {{ isPanning = false; document.getElementById('canvas').style.cursor = ''; }}
2947}});
2948
2949// ── Sidebar ──
2950function buildSidebar() {{
2951 const list = document.getElementById('service-list');
2952 const byCat = {{}};
2953 NODES.forEach(n => {{ if (!byCat[n.category]) byCat[n.category] = []; byCat[n.category].push(n); }});
2954 Object.entries(byCat).forEach(([cat, nodes]) => {{
2955 const cd = document.createElement('div');
2956 cd.className = 'cat-label'; cd.textContent = cat;
2957 list.appendChild(cd);
2958 nodes.forEach(node => {{
2959 const card = document.createElement('div');
2960 card.className = 'service-card'; card.id = 'card-' + node.id;
2961 card.innerHTML = `
2962 <div class="service-card-header">
2963 <div class="sc-icon">${{node.icon_data_uri ? `<img src="${{node.icon_data_uri}}" width="28" height="28" style="object-fit:contain;">` : `<svg viewBox="0 0 48 48">${{node.icon_svg}}</svg>`}}</div>
2964 <div>
2965 <div class="service-name">${{node.name}}</div>
2966 <div class="service-sku">${{node.sku || node.type}}</div>
2967 </div>
2968 ${{node.private ? '<span class="private-badge">Private</span>' : ''}}
2969 </div>
2970 ${{node.details.length > 0 ? `<div class="service-card-body">${{node.details.map(d => `<div class="service-detail">${{d}}</div>`).join('')}}</div>` : ''}}
2971 `;
2972 card.addEventListener('click', () => {{
2973 selectNode(node.id);
2974 }});
2975 list.appendChild(card);
2976 }});
2977 }});
2978}}
2979
2980// ── VNet highlight toggle ──
2981let _vnetHighlighted = false;
2982function toggleVNetHighlight() {{
2983 _vnetHighlighted = !_vnetHighlighted;
2984 const vr = document.getElementById('vnet-rect');
2985 if (!vr) return;
2986 if (_vnetHighlighted) {{
2987 vr.setAttribute('stroke-width', '4');
2988 vr.setAttribute('stroke', '#5C2D91');
2989 vr.setAttribute('fill', '#f0eaf8');
2990 }} else {{
2991 vr.setAttribute('stroke-width', '2');
2992 vr.setAttribute('stroke', '#5C2D91');
2993 vr.setAttribute('fill', '#f8f7ff');
2994 }}
2995 // Also toggle sidebar card
2996 const card = document.getElementById('card-vnet-boundary');
2997 if (card) card.classList.toggle('selected', _vnetHighlighted);
2998}}
2999
3000renderDiagram();
3001buildSidebar();
3002
3003// ── VNet sidebar card (added dynamically if VNet boundary exists) ──
3004if (VNET_INFO || NODES.some(n => n.private && n.type !== 'pe') || NODES.some(n => n.type === 'pe')) {{
3005 const list = document.getElementById('service-list');
3006 // Insert at the top
3007 const catLabel = document.createElement('div');
3008 catLabel.className = 'cat-label'; catLabel.textContent = 'NETWORK';
3009 const card = document.createElement('div');
3010 card.className = 'service-card'; card.id = 'card-vnet-boundary';
3011 const vnetIcon = '<rect x="6" y="6" width="36" height="36" rx="4" fill="none" stroke="#5C2D91" stroke-width="3"/><circle cx="16" cy="18" r="3" fill="#5C2D91"/><circle cx="32" cy="18" r="3" fill="#5C2D91"/><circle cx="24" cy="32" r="3" fill="#5C2D91"/>';
3012 const vnetDetails = VNET_INFO ? VNET_INFO.split('|').map(s => s.trim()) : [];
3013 card.innerHTML = `
3014 <div class="service-card-header">
3015 <div class="sc-icon"><svg viewBox="0 0 48 48">${{vnetIcon}}</svg></div>
3016 <div>
3017 <div class="service-name">Virtual Network</div>
3018 <div class="service-sku">vnet</div>
3019 </div>
3020 <span class="private-badge">Private</span>
3021 </div>
3022 ${{vnetDetails.length > 0 ? `<div class="service-card-body">${{vnetDetails.map(d => `<div class="service-detail">${{d}}</div>`).join('')}}</div>` : ''}}
3023 `;
3024 card.addEventListener('click', () => {{ toggleVNetHighlight(); }});
3025 list.insertBefore(card, list.firstChild);
3026 list.insertBefore(catLabel, list.firstChild);
3027}}
3028setTimeout(fitToScreen, 100);
3029</script>
3030</body>
3031</html>"""
3032 return html
3033
3034def generate_diagram(services, connections, title="Azure Architecture", vnet_info="", hierarchy=None):
3035 """Generate an interactive Azure architecture diagram as an HTML string.
3036
3037 Args:
3038 services: list of dicts with keys id, name, type, sku, private, details, etc.
3039 connections: list of dicts with keys from, to, label, type.
3040 title: diagram title string.
3041 vnet_info: VNet CIDR info string.
3042 hierarchy: optional subscription/RG hierarchy list.
3043
3044 Returns:
3045 HTML string containing the interactive diagram.
3046 """
3047 return generate_html(services, connections, title, vnet_info=vnet_info, hierarchy=hierarchy)