Files
awesome-copilot/skills/azure-architecture-autopilot/scripts/generator.py
T
Jeonghoon Lee a4ca380b7c 🤖🤖🤖 Update azure-architecture-autopilot: v0.1.5 engine (routing + UI) (#1451)
* Update azure-architecture-autopilot: v0.1.5 engine (routing overhaul, UI improvements)

Syncs the bundled diagram engine with az-diagram-autogen v0.1.5:
- Edge routing overhaul: zero overlaps, far fewer crossings, section-aware
  detours, outside-VNet routing, orthogonalization pass (no stray diagonals).
- Larger service boxes (+20%), default text scale 1.4x, icon-to-name spacing.
- Interactive A+/A- text-size controls; persistent selection across drag/pan;
  Reset button merged into Fit.
- Darker section outlines for contrast.

No breaking changes — generate_diagram(...) signature and JSON schema unchanged.

Also adds a skill-level README.md for easier browsing on the awesome-copilot repo.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: regenerate agents README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: whoniiii <whoniiii@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 12:26:06 +10:00

3048 lines
137 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Azure Interactive Architecture Diagram Generator v3
Generates interactive HTML diagrams with Azure official icons (Base64 inline).
"""
import json
from datetime import datetime
from icons import get_icon_data_uri
_HAS_OFFICIAL_ICONS = True
# Azure service icons: SVG, colors + official icon key mapping
# icon: 48x48 viewBox SVG path (fallback)
# azure_icon_key: key in icons.py (official Azure icon)
SERVICE_ICONS = {
"openai": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
"azure_icon_key": "azure_openai"
},
"ai_foundry": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
"azure_icon_key": "ai_foundry"
},
"ai_hub": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
"azure_icon_key": "machine_learning"
},
"search": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
"azure_icon_key": "cognitive_search"
},
"ai_search": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
"azure_icon_key": "cognitive_search"
},
"aml": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
"azure_icon_key": "machine_learning"
},
"storage": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "storage_accounts"
},
"adls": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "data_lake_storage_gen1"
},
"fabric": {
"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>',
"color": "#E8740C", "bg": "#FEF3E8", "category": "Data",
"azure_icon_key": "microsoft_fabric"
},
"synapse": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "azure_synapse_analytics"
},
"adf": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "data_factory"
},
"data_factory": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "data_factory"
},
"keyvault": {
"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"/>',
"color": "#E8A000", "bg": "#FEF7E0", "category": "Security",
"azure_icon_key": "key_vaults"
},
"kv": {
"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"/>',
"color": "#E8A000", "bg": "#FEF7E0", "category": "Security",
"azure_icon_key": "key_vaults"
},
"vnet": {
"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"/>',
"color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
"azure_icon_key": "virtual_networks"
},
"pe": {
"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"/>',
"color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
"azure_icon_key": "private_endpoints"
},
"nsg": {
"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"/>',
"color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
"azure_icon_key": "network_security_groups"
},
"acr": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Compute"
},
"aks": {
"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>',
"color": "#326CE5", "bg": "#EBF0FC", "category": "Compute",
"azure_icon_key": "kubernetes_services"
},
"appservice": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
"azure_icon_key": "app_services"
},
"appinsights": {
"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"/>',
"color": "#773ADC", "bg": "#F0EAFA", "category": "Monitor",
"azure_icon_key": "application_insights"
},
"monitor": {
"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"/>',
"color": "#773ADC", "bg": "#F0EAFA", "category": "Monitor",
"azure_icon_key": "monitor"
},
"vm": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
"azure_icon_key": "virtual_machine"
},
"bastion": {
"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"/>',
"color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
"azure_icon_key": "bastions"
},
"jumpbox": {
"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>',
"color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
"azure_icon_key": "virtual_machine"
},
"vpn": {
"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"/>',
"color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
"azure_icon_key": "virtual_network_gateways"
},
"user": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "External"
},
"app": {
"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"/>',
"color": "#666666", "bg": "#F5F5F5", "category": "External"
},
"default": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Azure"
},
"cdn": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Network",
"azure_icon_key": "cdn_profiles"
},
"event_hub": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
"azure_icon_key": "event_hubs"
},
"redis": {
"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>',
"color": "#D83B01", "bg": "#FEF0E8", "category": "Data",
"azure_icon_key": "cache_redis"
},
"devops": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "DevOps",
"azure_icon_key": "azure_devops"
},
"acr": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
"azure_icon_key": "container_registries"
},
"container_registry": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
"azure_icon_key": "container_registries"
},
"app_gateway": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Network",
"azure_icon_key": "application_gateways"
},
"iot_hub": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "IoT",
"azure_icon_key": "iot_hub"
},
"stream_analytics": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "stream_analytics_jobs"
},
"vpn_gateway": {
"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"/>',
"color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
"azure_icon_key": "virtual_network_gateways"
},
"front_door": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Network",
"azure_icon_key": "front_door_and_cdn_profiles"
},
"ai_hub": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
"azure_icon_key": "ai_studio"
},
"firewall": {
"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>',
"color": "#E8A000", "bg": "#FFF8E1", "category": "Network",
"azure_icon_key": "firewalls"
},
"document_intelligence": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
"azure_icon_key": "form_recognizer"
},
"form_recognizer": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "AI",
"azure_icon_key": "form_recognizer"
},
"databricks": {
"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>',
"color": "#FF3621", "bg": "#FFF0EE", "category": "Data",
"azure_icon_key": "azure_databricks"
},
"sql_server": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "sql_server"
},
"sql_database": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "sql_database"
},
"cosmos_db": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "azure_cosmos_db"
},
"app_service": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
"azure_icon_key": "app_services"
},
"aks": {
"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>',
"color": "#326CE5", "bg": "#EBF0FA", "category": "Compute",
"azure_icon_key": "kubernetes_services"
},
"function_app": {
"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>',
"color": "#F0AD4E", "bg": "#FFF8ED", "category": "Compute",
"azure_icon_key": "function_apps"
},
"synapse": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "azure_synapse_analytics"
},
"log_analytics": {
"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>',
"color": "#5C2D91", "bg": "#F3EDF7", "category": "Monitoring",
"azure_icon_key": "log_analytics_workspaces"
},
"app_insights": {
"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>',
"color": "#5C2D91", "bg": "#F3EDF7", "category": "Monitoring",
"azure_icon_key": "application_insights"
},
"nsg": {
"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>',
"color": "#E8A000", "bg": "#FFF8E1", "category": "Network",
"azure_icon_key": "network_security_groups"
},
"apim": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
"azure_icon_key": "api_management_services"
},
"api_management": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
"azure_icon_key": "api_management_services"
},
"service_bus": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
"azure_icon_key": "azure_service_bus"
},
"logic_apps": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
"azure_icon_key": "logic_apps"
},
"logic_app": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
"azure_icon_key": "logic_apps"
},
"event_grid": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
"azure_icon_key": "event_grid_topics"
},
"container_apps": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
"azure_icon_key": "container_apps_environments"
},
"container_app": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
"azure_icon_key": "container_apps_environments"
},
"postgresql": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "azure_database_postgresql_server"
},
"mysql": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "azure_database_mysql_server"
},
"load_balancer": {
"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"/>',
"color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
"azure_icon_key": "load_balancers"
},
"nat_gateway": {
"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>',
"color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
"azure_icon_key": "nat"
},
"expressroute": {
"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"/>',
"color": "#5C2D91", "bg": "#F3EEF9", "category": "Network",
"azure_icon_key": "expressroute_circuits"
},
"sentinel": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Security",
"azure_icon_key": "azure_sentinel"
},
"data_explorer": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "azure_data_explorer_clusters"
},
"kusto": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Data",
"azure_icon_key": "azure_data_explorer_clusters"
},
"signalr": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
"azure_icon_key": "signalr"
},
"notification_hub": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Integration",
"azure_icon_key": "notification_hubs"
},
"spring_apps": {
"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>',
"color": "#6DB33F", "bg": "#EFF8E8", "category": "Compute",
"azure_icon_key": "azure_spring_apps"
},
"static_web_app": {
"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>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Compute",
"azure_icon_key": "static_apps"
},
"digital_twins": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "IoT",
"azure_icon_key": "digital_twins"
},
"backup": {
"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"/>',
"color": "#0078D4", "bg": "#E8F4FD", "category": "Management",
"azure_icon_key": "backup_vault"
},
}
CONNECTION_STYLES = {
"api": {"color": "#0078D4", "dash": "0"},
"data": {"color": "#0F9D58", "dash": "0"},
"security": {"color": "#E8A000", "dash": "5,5"},
"private": {"color": "#5C2D91", "dash": "3,3"},
"network": {"color": "#5C2D91", "dash": "5,5"},
"default": {"color": "#999999", "dash": "0"},
}
_TYPE_ALIASES = {
# Azure ARM resource names → canonical diagram type
# Network
"private_endpoints": "pe", "private_endpoint": "pe",
"virtual_networks": "vnet", "virtual_network": "vnet",
"network_security_groups": "nsg", "network_security_group": "nsg",
"bastion_hosts": "bastion", "bastion_host": "bastion",
"application_gateways": "app_gateway", "application_gateway": "app_gateway",
"front_doors": "front_door", "front_door_and_cdn_profiles": "front_door",
"virtual_network_gateways": "vpn", "vpn_gateways": "vpn",
"load_balancers": "load_balancer",
"nat_gateways": "nat_gateway",
"expressroute_circuits": "expressroute",
"firewalls": "firewall",
"cdn_profiles": "cdn",
# Data
"data_factories": "adf", "data_factory": "adf",
"storage_accounts": "storage", "storage_account": "storage",
"data_lake": "adls", "adls_gen2": "adls", "data_lake_storage": "adls",
"fabric_capacities": "fabric", "fabric_capacity": "fabric", "microsoft_fabric": "fabric",
"synapse_workspaces": "synapse", "synapse_workspace": "synapse", "synapse_analytics": "synapse",
"cosmos": "cosmos_db", "cosmosdb": "cosmos_db", "documentdb": "cosmos_db",
"sql_databases": "sql_database", "sql_db": "sql_database",
"sql_servers": "sql_server",
"redis_caches": "redis", "redis_cache": "redis", "cache_redis": "redis",
"stream_analytics_jobs": "stream_analytics",
"databricks_workspaces": "databricks",
"data_explorer_clusters": "data_explorer", "azure_data_explorer": "data_explorer",
"postgresql_server": "postgresql", "postgresql_servers": "postgresql",
"mysql_server": "mysql", "mysql_servers": "mysql",
# AI
"cognitive_services": "ai_foundry", "ai_services": "ai_foundry", "foundry": "ai_foundry",
"azure_openai": "openai",
"cognitive_search": "search", "search_services": "search", "search_service": "search",
"machine_learning": "aml", "ml": "aml", "machine_learning_workspaces": "aml",
"form_recognizers": "document_intelligence",
"ai_studio": "ai_hub", "foundry_project": "ai_hub",
# Security
"key_vault": "keyvault", "key_vaults": "keyvault",
"sentinel": "sentinel", "azure_sentinel": "sentinel",
# Compute
"virtual_machines": "vm", "virtual_machine": "vm",
"app_services": "appservice", "web_apps": "appservice", "web_app": "appservice",
"function_apps": "function_app", "functions": "function_app",
"kubernetes_services": "aks", "managed_clusters": "aks", "kubernetes": "aks",
"container_registries": "acr",
"container_apps_environments": "container_apps",
"spring_apps": "spring_apps", "azure_spring_apps": "spring_apps",
"static_apps": "static_web_app", "static_web_apps": "static_web_app",
# Integration
"event_hubs": "event_hub",
"event_grid_topics": "event_grid", "event_grid_domains": "event_grid",
"api_management_services": "apim",
"service_bus_namespaces": "service_bus",
"logic_app": "logic_apps",
"notification_hubs": "notification_hub",
# Monitoring
"log_analytics_workspaces": "log_analytics",
"application_insights": "appinsights", "app_insight": "appinsights",
# IoT
"iot_hubs": "iot_hub",
# Management
"backup_vaults": "backup", "backup_vault": "backup",
}
def get_service_info(svc_type: str) -> dict:
t = svc_type.lower().replace("-", "_").replace(" ", "_")
t = _TYPE_ALIASES.get(t, t)
info = SERVICE_ICONS.get(t, SERVICE_ICONS["default"]).copy()
# Add official Azure icon data URI if available
azure_key = info.get("azure_icon_key", t)
icon_uri = get_icon_data_uri(azure_key)
info["icon_data_uri"] = icon_uri
return info
def generate_html(services: list, connections: list, title: str, vnet_info: str = "", hierarchy: list = None) -> str:
def _norm(t):
t = t.lower().replace("-", "_").replace(" ", "_")
return _TYPE_ALIASES.get(t, t)
nodes_js = json.dumps([{
"id": svc["id"],
"name": svc["name"],
"type": _norm(svc.get("type", "default")),
"sku": svc.get("sku", ""),
"private": svc.get("private", False),
"details": svc.get("details", []),
"subscription": svc.get("subscription", ""),
"resourceGroup": svc.get("resourceGroup", ""),
"icon_svg": get_service_info(svc.get("type", "default"))["icon_svg"],
"icon_data_uri": get_service_info(svc.get("type", "default")).get("icon_data_uri", ""),
"color": get_service_info(svc.get("type", "default"))["color"],
"bg": get_service_info(svc.get("type", "default"))["bg"],
"category": get_service_info(svc.get("type", "default"))["category"],
} for svc in services], ensure_ascii=False)
hierarchy_js = json.dumps(hierarchy or [], ensure_ascii=False)
edges_js = json.dumps([{
"from": conn["from"],
"to": conn["to"],
"label": conn.get("label", ""),
"type": conn.get("type", "default"),
"color": CONNECTION_STYLES.get(conn.get("type", "default"), CONNECTION_STYLES["default"])["color"],
"dash": CONNECTION_STYLES.get(conn.get("type", "default"), CONNECTION_STYLES["default"])["dash"],
} for conn in connections], ensure_ascii=False)
pe_count = sum(1 for s in services if _norm(s.get("type", "default")) == "pe")
svc_count = len(services) - pe_count
generated_at = datetime.now().strftime("%Y-%m-%d %H:%M")
vnet_info_js = json.dumps(vnet_info, ensure_ascii=False)
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Segoe+UI:wght@400;600;700&family=Inter:wght@400;500;600&display=swap');
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: 'Segoe UI', 'Inter', -apple-system, sans-serif; background: #f3f2f1; color: #323130; }}
.header {{
background: white; border-bottom: 1px solid #edebe9;
padding: 12px 24px; display: flex; align-items: center; gap: 14px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}}
.header-icon {{
width: 32px; height: 32px; border-radius: 4px;
background: linear-gradient(135deg, #0078D4, #00BCF2);
display: flex; align-items: center; justify-content: center;
}}
.header-icon svg {{ width: 20px; height: 20px; }}
.header h1 {{ font-size: 15px; font-weight: 600; color: #201f1e; }}
.header .meta {{ font-size: 11px; color: #a19f9d; }}
.header-right {{ margin-left: auto; display: flex; gap: 16px; align-items: center; }}
.stat {{ font-size: 11px; color: #605e5c; }}
.stat b {{ color: #323130; }}
.container {{ display: flex; height: calc(100vh - 56px); }}
.canvas-area {{
flex: 1; position: relative; overflow: hidden;
background: white;
background-image:
linear-gradient(#faf9f8 1px, transparent 1px),
linear-gradient(90deg, #faf9f8 1px, transparent 1px);
background-size: 24px 24px;
}}
#canvas {{ position: absolute; top: 0; left: 0; width: 100%; height: 100%; }}
.toolbar {{
position: absolute; top: 10px; left: 10px;
display: flex; gap: 1px; z-index: 10;
background: white; border: 1px solid #edebe9; border-radius: 6px;
padding: 2px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}}
.tool-btn {{
background: transparent; border: none; border-radius: 4px;
padding: 5px 10px; font-size: 11px; cursor: pointer; color: #605e5c;
font-family: inherit; transition: all 0.1s;
}}
.tool-btn:hover {{ background: #f3f2f1; color: #323130; }}
.tool-sep {{ width: 1px; background: #edebe9; margin: 3px 1px; }}
.zoom-indicator {{
position: absolute; top: 10px; right: 286px;
background: white; border: 1px solid #edebe9; border-radius: 4px;
padding: 3px 8px; font-size: 10px; color: #a19f9d; z-index: 10;
}}
/* ── Sidebar ── */
.sidebar {{
width: 272px; background: #faf9f8; border-left: 1px solid #edebe9;
overflow-y: auto; display: flex; flex-direction: column;
}}
.sidebar::-webkit-scrollbar {{ width: 3px; }}
.sidebar::-webkit-scrollbar-thumb {{ background: #c8c6c4; border-radius: 3px; }}
.sidebar-header {{
padding: 12px 14px; border-bottom: 1px solid #edebe9;
font-weight: 600; font-size: 12px; color: #605e5c;
position: sticky; top: 0; background: #faf9f8; z-index: 1;
}}
.cat-label {{
padding: 10px 14px 4px; font-size: 10px; color: #a19f9d;
font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
}}
.service-card {{
margin: 2px 6px; border: 1px solid #edebe9; border-radius: 6px;
overflow: hidden; cursor: pointer; transition: all 0.1s;
background: white;
}}
.service-card:hover {{ border-color: #c8c6c4; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }}
.service-card.selected {{ border-color: #0078D4; box-shadow: 0 0 0 1px #0078D4; }}
.service-card-header {{
padding: 7px 10px; display: flex; align-items: center; gap: 8px;
}}
.sc-icon {{ width: 28px; height: 28px; flex-shrink: 0; }}
.sc-icon svg {{ width: 28px; height: 28px; }}
.service-name {{ font-size: 12px; font-weight: 600; color: #323130; }}
.service-sku {{ font-size: 10px; color: #a19f9d; }}
.service-card-body {{ padding: 2px 10px 6px; }}
.service-detail {{ font-size: 10px; color: #605e5c; padding: 1px 0; }}
.service-detail::before {{ content: " "; color: #a19f9d; }}
.private-badge {{
font-size: 9px; background: #f3eef9; color: #5C2D91;
border-radius: 3px; padding: 1px 5px; margin-left: auto;
border: 1px solid #e0d4f5;
}}
.legend {{
padding: 10px 14px; border-top: 1px solid #edebe9; margin-top: auto;
}}
.legend-title {{ font-size: 10px; font-weight: 600; color: #a19f9d; margin-bottom: 5px; }}
.legend-item {{ display: flex; align-items: center; gap: 6px; font-size: 10px; color: #605e5c; margin-bottom: 2px; }}
.legend-line {{ width: 18px; height: 2px; border-radius: 1px; }}
.legend-line-dash {{ width: 18px; height: 0; border-top: 2px dashed; }}
/* ── SVG styles ── */
.node {{ cursor: grab; pointer-events: all; }}
.node:active {{ cursor: grabbing; }}
.node .node-bg {{ pointer-events: all; }}
.node.selected .node-bg {{ stroke: #0078D4; stroke-width: 2.5; }}
.node.selected {{ filter: drop-shadow(0 0 6px rgba(0,120,212,0.4)); }}
/* ── Edge highlight on node select ── */
.edge-path {{ transition: opacity 0.2s, stroke-width 0.2s; }}
.edge-label {{ transition: opacity 0.2s; }}
.edge-path.highlight {{ opacity: 1 !important; stroke-width: 2.5 !important; filter: drop-shadow(0 0 4px rgba(0,120,212,0.5)); }}
.edge-path.dimmed {{ opacity: 0.1 !important; }}
.edge-label.highlight {{ opacity: 1 !important; font-weight: 700; }}
.edge-label.dimmed {{ opacity: 0.15 !important; }}
.edge-label-bg.highlight {{ stroke: #0078D4 !important; stroke-width: 1.5 !important; }}
.edge-label-bg.dimmed {{ opacity: 0.15 !important; }}
.node.dimmed {{ opacity: 0.25; transition: opacity 0.2s; }}
.subnet-rect {{
rx: 6; ry: 6;
}}
.subnet-label {{
font-size: 11px; font-weight: 600; font-family: 'Segoe UI', sans-serif;
}}
.status-bar {{
position: absolute; bottom: 10px; left: 10px;
background: white; border: 1px solid #edebe9; border-radius: 4px;
padding: 4px 10px; font-size: 10px; color: #a19f9d;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}}
.tooltip {{
position: absolute; background: white; color: #323130;
border: 1px solid #edebe9; padding: 8px 12px;
border-radius: 6px; font-size: 11px; pointer-events: none;
white-space: nowrap; z-index: 100; display: none;
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
}}
.tooltip strong {{ color: #201f1e; }}
.tooltip-detail {{ color: #605e5c; margin-top: 1px; font-size: 10px; }}
</style>
</head>
<body>
<div class="header">
<div class="header-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7v10l10 5 10-5V7L12 2z" fill="white" opacity="0.9"/></svg>
</div>
<div>
<h1>{title}</h1>
<div class="meta">Azure Architecture &middot; {generated_at}</div>
</div>
<div class="header-right">
<div class="stat"><b>{svc_count}</b> Services</div>
<div class="stat"><b>{pe_count}</b> Private Endpoints</div>
<div class="stat"><b>{len(connections)}</b> Connections</div>
</div>
</div>
<div class="container">
<div class="canvas-area">
<div class="toolbar">
<button class="tool-btn" onclick="fitToScreen()">Fit</button>
<div class="tool-sep"></div>
<button class="tool-btn" onclick="zoomIn()">+</button>
<button class="tool-btn" onclick="zoomOut()">&minus;</button>
<div class="tool-sep"></div>
<button class="tool-btn" onclick="textBigger()" title="Bigger text" style="font-size:13px;">A+</button>
<button class="tool-btn" onclick="textSmaller()" title="Smaller text" style="font-size:10px;">A&minus;</button>
<div class="tool-sep"></div>
<button class="tool-btn" onclick="downloadPNG()" title="Download PNG">&#128247; PNG</button>
</div>
<div class="zoom-indicator" id="zoom-level">100%</div>
<svg id="canvas">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" opacity="0.7"/>
</marker>
<marker id="arr-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" opacity="0.7"/>
</marker>
<marker id="arr-sec" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" opacity="0.7"/>
</marker>
<marker id="arr-pe" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" opacity="0.7"/>
</marker>
<filter id="shadow">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.08"/>
</filter>
</defs>
<g id="diagram-root"></g>
</svg>
<div id="tooltip" class="tooltip"></div>
<div class="status-bar">Drag nodes &middot; Scroll to zoom &middot; Drag empty space to pan</div>
</div>
<div class="sidebar">
<div class="sidebar-header">Resources</div>
<div id="service-list"></div>
<div class="legend">
<div class="legend-title">Connection Types</div>
<div class="legend-item"><div class="legend-line" style="background:#0078D4;"></div> API</div>
<div class="legend-item"><div class="legend-line" style="background:#0F9D58;"></div> Data</div>
<div class="legend-item"><div class="legend-line-dash" style="border-color:#E8A000;"></div> Security</div>
<div class="legend-item"><div class="legend-line-dash" style="border-color:#5C2D91;"></div> Private Endpoint</div>
</div>
</div>
</div>
<script>
const NODES = {nodes_js};
const EDGES = {edges_js};
const VNET_INFO = {vnet_info_js};
const HIERARCHY = {hierarchy_js};
// ── Node sizing ──
const SVC_W = 180, SVC_H = 120; // service node (icon above, name below) — 20% larger
const PE_W = 120, PE_H = 84; // pe node (smaller) — 20% larger
const GAP = 40;
// ── Layout: Category Group Box style ──
// Each category gets a labeled box, services arranged in a grid inside.
// Groups arranged in 2D: main service groups on top, bottom groups below.
// PE nodes in a separate PE subnet group.
const positions = {{}};
const useRgLayout = HIERARCHY.length > 0 && NODES.some(n => n.resourceGroup);
const peNodes = useRgLayout ? [] : NODES.filter(n => n.type === 'pe'); // RG mode: PE included in mainNodes
const mainNodes = useRgLayout ? NODES : NODES.filter(n => n.type !== 'pe');
// Group box layout parameters
const GROUP_PAD = 24;
const GROUP_TITLE_H = 28;
const GROUP_GAP = 60;
const COLS_PER_GROUP = 3;
const CELL_W = SVC_W + 100;
const CELL_H = SVC_H + 90;
function groupDimensions(nodeCount) {{
const cols = Math.min(nodeCount, COLS_PER_GROUP);
const rows = Math.ceil(nodeCount / COLS_PER_GROUP);
const w = cols * CELL_W + GROUP_PAD * 2;
const h = rows * CELL_H + GROUP_PAD + GROUP_TITLE_H;
return {{ w, h, cols, rows }};
}}
const groupBoxes = [];
// ── Layout strategy: RG-based (if HIERARCHY) or Category-based (default) ──
if (useRgLayout) {{
// ── RG-based layout: group by Subscription > ResourceGroup ──
let gx = 60, gy = 140;
let subStartX = 60;
const SUB_GAP = 80;
const RG_GAP = 60;
HIERARCHY.forEach((sub, subIdx) => {{
let rgX = gx;
let rgMaxH = 0;
const subRGs = sub.resourceGroups || [];
subRGs.forEach((rgName, rgIdx) => {{
const rgNodes = mainNodes.filter(n => n.subscription === sub.subscription && n.resourceGroup === rgName);
if (rgNodes.length === 0) return;
const dim = groupDimensions(rgNodes.length);
rgNodes.forEach((n, i) => {{
const col = i % dim.cols;
const row = Math.floor(i / dim.cols);
positions[n.id] = {{
x: rgX + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,
y: gy + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2
}};
}});
groupBoxes.push({{
cat: rgName, x: rgX, y: gy, w: dim.w, h: dim.h,
color: rgNodes[0]?.color || '#0078D4',
isRG: true, subscription: sub.subscription
}});
rgX += dim.w + RG_GAP;
rgMaxH = Math.max(rgMaxH, dim.h);
}});
// Next subscription row
if (subIdx < HIERARCHY.length - 1) {{
gy += rgMaxH + SUB_GAP;
gx = subStartX;
}}
}});
// Place unassigned main nodes (no subscription/RG) in a generic group
const unassigned = mainNodes.filter(n => !positions[n.id]);
if (unassigned.length > 0) {{
const allY = Object.values(positions).map(p => p.y);
const bottomY = allY.length > 0 ? Math.max(...allY) + SVC_H + GROUP_GAP : 140;
const dim = groupDimensions(unassigned.length);
unassigned.forEach((n, i) => {{
const col = i % dim.cols;
const row = Math.floor(i / dim.cols);
positions[n.id] = {{
x: 60 + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,
y: bottomY + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2
}};
}});
groupBoxes.push({{
cat: 'Other', x: 60, y: bottomY, w: dim.w, h: dim.h,
color: '#666'
}});
}}
}} else {{
// ── Category-based layout (original) ──
const bottomCategories = ['Network', 'External', 'Monitor', 'Monitoring'];
const catOrder = ['AI', 'Data', 'Security', 'Compute', 'Integration', 'DevOps', 'IoT', 'Azure'];
const catGroups = {{}};
mainNodes.forEach(n => {{
const cat = n.category || 'Azure';
if (!catGroups[cat]) catGroups[cat] = [];
catGroups[cat].push(n);
}});
// Dynamically include any categories not in catOrder or bottomCategories
const extraCats = Object.keys(catGroups).filter(cat => !catOrder.includes(cat) && !bottomCategories.includes(cat));
const fullCatOrder = [...catOrder, ...extraCats];
// ── Place main service groups in a flowing grid ──
const serviceGroups = fullCatOrder.filter(cat => catGroups[cat] && catGroups[cat].length > 0
&& !bottomCategories.includes(cat));
let gx = 60, gy = 140;
let rowMaxH = 0;
let rowStartX = 60;
const MAX_ROW_W = Math.max(1600, serviceGroups.length * 400);
serviceGroups.forEach(cat => {{
const nodes = catGroups[cat];
const dim = groupDimensions(nodes.length);
// Wrap to next row if too wide
if (gx + dim.w > rowStartX + MAX_ROW_W && gx > rowStartX) {{
gx = rowStartX;
gy += rowMaxH + GROUP_GAP;
rowMaxH = 0;
}}
// Place nodes inside group grid
nodes.forEach((n, i) => {{
const col = i % dim.cols;
const row = Math.floor(i / dim.cols);
positions[n.id] = {{
x: gx + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,
y: gy + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2
}};
}});
groupBoxes.push({{
cat, x: gx, y: gy, w: dim.w, h: dim.h,
color: nodes[0]?.color || '#0078D4'
}});
gx += dim.w + GROUP_GAP;
rowMaxH = Math.max(rowMaxH, dim.h);
}});
// ── Place bottom groups (Network, External, Monitor) ──
const bottomGroupY = gy + rowMaxH + GROUP_GAP + 20;
let bgx = 60;
bottomCategories.forEach(cat => {{
const nodes = catGroups[cat];
if (!nodes || nodes.length === 0) return;
const dim = groupDimensions(nodes.length);
nodes.forEach((n, i) => {{
const col = i % dim.cols;
const row = Math.floor(i / dim.cols);
positions[n.id] = {{
x: bgx + GROUP_PAD + col * CELL_W + (CELL_W - SVC_W) / 2,
y: bottomGroupY + GROUP_TITLE_H + row * CELL_H + (CELL_H - SVC_H) / 2
}};
}});
groupBoxes.push({{
cat, x: bgx, y: bottomGroupY, w: dim.w, h: dim.h,
color: nodes[0]?.color || '#666',
isBottom: true
}});
bgx += dim.w + GROUP_GAP;
}});
}} // end of else (category-based layout)
// ── PE nodes placement ──
if (useRgLayout) {{
// RG mode: PE nodes go inside their respective RG boxes
// PE positions are already set by the RG layout if they have subscription/resourceGroup
// For PEs without RG assignment, place them in a separate group
const unplacedPEs = peNodes.filter(pe => !positions[pe.id]);
if (unplacedPEs.length > 0) {{
// Find the rightmost RG box position
const allGbRight = groupBoxes.length > 0 ? Math.max(...groupBoxes.map(gb => gb.x + gb.w)) : 0;
const peStartX = allGbRight + GROUP_GAP;
const peStartY = 140;
const peCols = Math.min(unplacedPEs.length, 4);
const peCellW = PE_W + 50;
const peCellH = PE_H + 30;
const peBoxW = peCols * peCellW + GROUP_PAD * 2;
const peRows = Math.ceil(unplacedPEs.length / peCols);
const peBoxH = peRows * peCellH + GROUP_PAD + GROUP_TITLE_H;
unplacedPEs.forEach((pe, i) => {{
const col = i % peCols;
const row = Math.floor(i / peCols);
positions[pe.id] = {{
x: peStartX + GROUP_PAD + col * peCellW + (peCellW - PE_W) / 2,
y: peStartY + GROUP_TITLE_H + row * peCellH + (peCellH - PE_H) / 2
}};
}});
groupBoxes.push({{
cat: 'Private Endpoints', x: peStartX, y: peStartY, w: peBoxW, h: peBoxH,
color: '#5C2D91', isPE: true
}});
}}
}} else {{
// Category mode: PE nodes in separate group above service groups
const PE_Y = 40;
if (peNodes.length > 0) {{
const peCols = Math.min(peNodes.length, 6);
const peRows = Math.ceil(peNodes.length / peCols);
const peCellW = PE_W + 50;
const peCellH = PE_H + 30;
const peBoxW = peCols * peCellW + GROUP_PAD * 2;
const peBoxH = peRows * peCellH + GROUP_PAD + GROUP_TITLE_H;
peNodes.forEach((pe, i) => {{
const col = i % peCols;
const row = Math.floor(i / peCols);
positions[pe.id] = {{
x: 60 + GROUP_PAD + col * peCellW + (peCellW - PE_W) / 2,
y: PE_Y + GROUP_TITLE_H + row * peCellH + (peCellH - PE_H) / 2
}};
}});
groupBoxes.push({{
cat: 'Private Endpoints', x: 60, y: PE_Y, w: peBoxW, h: peBoxH,
color: '#5C2D91', isPE: true
}});
const peBottom = PE_Y + peBoxH + GROUP_GAP;
if (peBottom > 140) {{
const shift = peBottom - 140;
NODES.forEach(n => {{
if (n.type !== 'pe' && positions[n.id]) {{
positions[n.id].y += shift;
}}
}});
groupBoxes.forEach(gb => {{
if (!gb.isPE) gb.y += shift;
}});
}}
}}
}}
// ── Node → Group mapping (for edge routing) ──
const nodeGroupMap = {{}};
groupBoxes.forEach((gb, idx) => {{
NODES.forEach(n => {{
const pos = positions[n.id];
if (!pos) return;
const nw = n.type === 'pe' ? PE_W : SVC_W;
const nh = n.type === 'pe' ? PE_H : SVC_H;
const ncx = pos.x + nw / 2;
const ncy = pos.y + nh / 2;
if (ncx >= gb.x && ncx <= gb.x + gb.w && ncy >= gb.y && ncy <= gb.y + gb.h) {{
nodeGroupMap[n.id] = idx;
}}
}});
}});
// Routing corridor margins (outside all group boxes)
const _rightMarginBase = groupBoxes.length > 0 ? Math.max(...groupBoxes.map(g => g.x + g.w)) + 40 : 800;
const _leftMarginBase = groupBoxes.length > 0 ? Math.min(...groupBoxes.map(g => g.x)) - 40 : -40;
// ── State ──
let dragging = null, dragOffX = 0, dragOffY = 0;
let draggingGroup = null, groupDragNodes = []; // for RG/group box dragging
let _dragStartX = 0, _dragStartY = 0, _didDrag = false; // global so renderDiagram rebuilding DOM mid-drag doesn't reset them
let viewTransform = {{ x: 0, y: 0, scale: 1 }};
let isPanning = false, panSX = 0, panSY = 0, panSTx = 0, panSTy = 0;
let _routeCounter = 0;
// ── Bidirectional highlight ──
let _selectedNodeId = null;
function selectNode(nodeId) {{
const wasSelected = _selectedNodeId === nodeId;
// Clear all selections
clearSelection();
// Toggle off if clicking same node
if (wasSelected) {{ _selectedNodeId = null; return; }}
_selectedNodeId = nodeId;
applySelectionHighlight();
// Scroll sidebar card into view on initial selection
const sCard = document.getElementById('card-' + nodeId);
if (sCard) sCard.scrollIntoView({{ behavior: 'smooth', block: 'nearest' }});
}}
// Re-apply CSS classes for current _selectedNodeId (called after renderDiagram rebuilds DOM)
function applySelectionHighlight() {{
const nodeId = _selectedNodeId;
if (!nodeId) return;
// Highlight diagram node
const svgNode = document.querySelector(`.node[data-id="${{nodeId}}"]`);
if (svgNode) svgNode.classList.add('selected');
// Highlight sidebar card
const card = document.getElementById('card-' + nodeId);
if (card) card.classList.add('selected');
// Find connected edges (where this node is from or to)
const connectedNodeIds = new Set([nodeId]);
document.querySelectorAll('.edge-path').forEach(p => {{
const f = p.getAttribute('data-from'), t = p.getAttribute('data-to');
if (f === nodeId || t === nodeId) {{
p.classList.add('highlight');
connectedNodeIds.add(f);
connectedNodeIds.add(t);
}} else {{
p.classList.add('dimmed');
}}
}});
document.querySelectorAll('.edge-label').forEach(g => {{
const f = g.getAttribute('data-from'), t = g.getAttribute('data-to');
if (f === nodeId || t === nodeId) {{
g.classList.add('highlight');
g.querySelector('.edge-label-bg')?.classList.add('highlight');
}} else {{
g.classList.add('dimmed');
g.querySelector('.edge-label-bg')?.classList.add('dimmed');
}}
}});
// Dim unconnected nodes
document.querySelectorAll('.node').forEach(n => {{
const nid = n.getAttribute('data-id');
if (!connectedNodeIds.has(nid)) n.classList.add('dimmed');
}});
}}
function clearSelection() {{
_selectedNodeId = null;
document.querySelectorAll('.node').forEach(n => {{ n.classList.remove('selected', 'dimmed'); }});
document.querySelectorAll('.service-card').forEach(c => c.classList.remove('selected'));
document.querySelectorAll('.edge-path').forEach(p => {{ p.classList.remove('highlight', 'dimmed'); }});
document.querySelectorAll('.edge-label').forEach(g => {{ g.classList.remove('highlight', 'dimmed'); }});
document.querySelectorAll('.edge-label-bg').forEach(r => {{ r.classList.remove('highlight', 'dimmed'); }});
}}
function markerFor(type) {{
if (type === 'data') return 'arr-data';
if (type === 'security') return 'arr-sec';
if (type === 'private') return 'arr-pe';
return 'arr';
}}
function renderDiagram() {{
const root = document.getElementById('diagram-root');
root.innerHTML = '';
_routeCounter = 0; // reset stagger counter each render
// ── VNet bounds (hoisted so avoidNodes can push detours outside VNet) ──
let _vnetBounds = null;
if (!useRgLayout) {{
const _pg = groupBoxes.filter(gb => !gb.isBottom);
const _hasPriv = NODES.some(n => n.private && n.type !== 'pe');
const _hasVNI = VNET_INFO && VNET_INFO.length > 0;
const _hasPe = NODES.some(n => n.type === 'pe');
if (_pg.length > 0 && (_hasPriv || _hasVNI || _hasPe)) {{
const vx = Math.min(..._pg.map(g => g.x)) - 16;
const vy = Math.min(..._pg.map(g => g.y)) - 36;
const vR = Math.max(..._pg.map(g => g.x + g.w)) + 16;
const vB = Math.max(..._pg.map(g => g.y + g.h)) + 16;
_vnetBounds = {{ x: vx, y: vy, w: vR - vx, h: vB - vy }};
}}
}}
// ── Draw VNet boundary (only in category-based layout, not RG layout) ──
if (!useRgLayout) {{
const privateGroups = groupBoxes.filter(gb => !gb.isBottom);
const hasPrivateNodes = NODES.some(n => n.private && n.type !== 'pe');
const hasVNetInfo = VNET_INFO && VNET_INFO.length > 0;
const hasPeNodes = NODES.some(n => n.type === 'pe');
const showVNet = hasPrivateNodes || hasVNetInfo || hasPeNodes;
if (privateGroups.length > 0 && showVNet) {{
const vx = Math.min(...privateGroups.map(g => g.x)) - 16;
const vy = Math.min(...privateGroups.map(g => g.y)) - 36;
const vRight = Math.max(...privateGroups.map(g => g.x + g.w)) + 16;
const vBottom = Math.max(...privateGroups.map(g => g.y + g.h)) + 16;
const vr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
vr.setAttribute('x', vx); vr.setAttribute('y', vy);
vr.setAttribute('width', vRight - vx); vr.setAttribute('height', vBottom - vy);
vr.setAttribute('fill', '#f8f7ff'); vr.setAttribute('stroke', '#5C2D91');
vr.setAttribute('stroke-width', '2'); vr.setAttribute('stroke-dasharray', '8,4');
vr.setAttribute('rx', '12');
root.appendChild(vr);
const vnetLabel = VNET_INFO ? `Virtual Network (${{VNET_INFO}})` : 'Virtual Network';
const vl = document.createElementNS('http://www.w3.org/2000/svg', 'g');
vl.setAttribute('class', 'vnet-boundary-label');
vl.setAttribute('style', 'cursor: pointer;');
vl.innerHTML = `<svg x="${{vx + 10}}" y="${{vy + 6}}" width="20" height="20" viewBox="0 0 48 48">
<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"/>
</svg>
<text x="${{vx + 34}}" y="${{vy + 20}}" font-size="12" font-weight="600" fill="#5C2D91" font-family="Segoe UI, sans-serif">${{vnetLabel}}</text>`;
root.appendChild(vl);
// Store VNet rect reference for highlight
vr.setAttribute('id', 'vnet-rect');
vl.addEventListener('click', () => {{ toggleVNetHighlight(); }});
root.appendChild(vl);
}}
}} // end if(!useRgLayout) for VNet boundary
// ── Draw group boxes (category or RG — depends on layout mode) ──
const _groupLabelElements = []; // store labels to re-render on top of edges
groupBoxes.forEach(gb => {{
if (gb.isPE) {{
// PE group — always draw with dashed style
const gr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
gr.setAttribute('x', gb.x); gr.setAttribute('y', gb.y);
gr.setAttribute('width', gb.w); gr.setAttribute('height', gb.h);
gr.setAttribute('rx', '8'); gr.setAttribute('fill', '#f3eef9');
gr.setAttribute('stroke', '#c8b8e8'); gr.setAttribute('stroke-width', '1.2');
gr.setAttribute('stroke-dasharray', '4,4');
root.appendChild(gr);
}} else {{
// Service group (category or RG)
const gr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
gr.setAttribute('x', gb.x); gr.setAttribute('y', gb.y);
gr.setAttribute('width', gb.w); gr.setAttribute('height', gb.h);
gr.setAttribute('rx', '8');
gr.setAttribute('fill', gb.isRG ? '#fafafa' : 'white');
gr.setAttribute('stroke', gb.isRG ? gb.color : '#c8c6c4');
gr.setAttribute('stroke-width', gb.isRG ? '1.5' : '1.2');
if (gb.isRG) gr.setAttribute('stroke-dasharray', '6,3');
root.appendChild(gr);
}}
// Title bar
const titleBar = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
titleBar.setAttribute('x', gb.x); titleBar.setAttribute('y', gb.y);
titleBar.setAttribute('width', gb.w); titleBar.setAttribute('height', GROUP_TITLE_H);
titleBar.setAttribute('rx', '8');
titleBar.setAttribute('fill', gb.color);
titleBar.setAttribute('opacity', '0.1');
root.appendChild(titleBar);
const titleFill = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
titleFill.setAttribute('x', gb.x); titleFill.setAttribute('y', gb.y + GROUP_TITLE_H - 8);
titleFill.setAttribute('width', gb.w); titleFill.setAttribute('height', '8');
titleFill.setAttribute('fill', gb.color); titleFill.setAttribute('opacity', '0.1');
root.appendChild(titleFill);
// Color accent line
const accent = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
accent.setAttribute('x', gb.x); accent.setAttribute('y', gb.y);
accent.setAttribute('width', gb.w); accent.setAttribute('height', '3');
accent.setAttribute('rx', '8'); accent.setAttribute('fill', gb.color);
root.appendChild(accent);
// Group label — RG uses 📁, PE uses "Private Endpoints", category uses category name
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
label.setAttribute('x', gb.x + 12); label.setAttribute('y', gb.y + 18);
label.setAttribute('font-size', '12'); label.setAttribute('font-weight', '600');
label.setAttribute('fill', gb.color); label.setAttribute('font-family', 'Segoe UI, sans-serif');
label.textContent = gb.isRG ? `📁 ${{gb.cat}}` : gb.cat;
root.appendChild(label);
_groupLabelElements.push(label);
// Make title bar draggable — drags all nodes inside
titleBar.style.cursor = 'grab';
const gbIdx = groupBoxes.indexOf(gb);
titleBar.addEventListener('mousedown', e => {{
if (e.button !== 0) return;
e.stopPropagation(); e.preventDefault();
draggingGroup = gbIdx;
const svgPt = getSVGPoint(e);
dragOffX = svgPt.x; dragOffY = svgPt.y;
// Find all nodes inside this group box
groupDragNodes = NODES.filter(n => {{
const pos = positions[n.id];
if (!pos) return false;
const nw = n.type === 'pe' ? PE_W : SVC_W;
const nh = n.type === 'pe' ? PE_H : SVC_H;
const cx = pos.x + nw/2, cy = pos.y + nh/2;
return cx >= gb.x && cx <= gb.x + gb.w && cy >= gb.y && cy <= gb.y + gb.h;
}}).map(n => n.id);
}});
}});
// ── Draw Subscription boundaries (only if multiple subscriptions, rendered AFTER group boxes) ──
if (HIERARCHY.length > 1 && useRgLayout) {{
HIERARCHY.forEach((sub, subIdx) => {{
// Find all RG boxes belonging to this subscription
const subRgBoxes = groupBoxes.filter(gb => gb.isRG && gb.subscription === sub.subscription);
if (subRgBoxes.length === 0) return;
const sx = Math.min(...subRgBoxes.map(gb => gb.x)) - 20;
const sy = Math.min(...subRgBoxes.map(gb => gb.y)) - 40;
const sRight = Math.max(...subRgBoxes.map(gb => gb.x + gb.w)) + 20;
const sBottom = Math.max(...subRgBoxes.map(gb => gb.y + gb.h)) + 20;
const sr = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
sr.setAttribute('x', sx); sr.setAttribute('y', sy);
sr.setAttribute('width', sRight - sx); sr.setAttribute('height', sBottom - sy);
sr.setAttribute('fill', 'none'); sr.setAttribute('stroke', '#0078D4');
sr.setAttribute('stroke-width', '2.5'); sr.setAttribute('stroke-dasharray', '12,4');
sr.setAttribute('rx', '16'); sr.setAttribute('opacity', '0.7');
root.appendChild(sr);
const sl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
sl.setAttribute('x', sx + 12); sl.setAttribute('y', sy + 16);
sl.setAttribute('font-size', '12'); sl.setAttribute('font-weight', '700');
sl.setAttribute('fill', '#0078D4'); sl.setAttribute('font-family', 'Segoe UI, sans-serif');
sl.textContent = `📦 ${{sub.subscription}}`;
root.appendChild(sl);
}});
}}
// ── Edge routing (obstacle-free) ──
// Compute global bounds: the absolute bottom of ALL nodes
function getGlobalBounds() {{
let minY = Infinity, maxY = -Infinity;
NODES.forEach(n => {{
const pos = positions[n.id];
if (!pos) return;
const h = n.type === 'pe' ? PE_H : SVC_H;
if (pos.y < minY) minY = pos.y;
if (pos.y + h > maxY) maxY = pos.y + h;
}});
return {{ minY, maxY }};
}}
function getNodeBox(node) {{
const pos = positions[node.id];
if (!pos) return null;
const w = node.type === 'pe' ? PE_W : SVC_W;
const h = node.type === 'pe' ? PE_H : SVC_H;
return {{ x: pos.x, y: pos.y, w, h, cx: pos.x + w/2, cy: pos.y + h/2 }};
}}
// Border point: exit/enter at edge of rectangle
function borderExit(box, side) {{
// side: 'top', 'bottom', 'left', 'right'
if (side === 'top') return {{ x: box.cx, y: box.y }};
if (side === 'bottom') return {{ x: box.cx, y: box.y + box.h }};
if (side === 'left') return {{ x: box.x, y: box.cy }};
if (side === 'right') return {{ x: box.x + box.w, y: box.cy }};
}}
// Check if a line segment hits any group box (for edge routing)
function hitsGroupBox(x1, y1, x2, y2, skipGroupIndices) {{
for (let gi = 0; gi < groupBoxes.length; gi++) {{
if (skipGroupIndices.includes(gi)) continue;
const gb = groupBoxes[gi];
const pad = 4;
const left = gb.x - pad, right = gb.x + gb.w + pad;
const top = gb.y - pad, bottom = gb.y + gb.h + pad;
const dx = x2 - x1, dy = y2 - y1;
let tmin = 0, tmax = 1;
const edges = [[-dx, x1 - left], [dx, right - x1], [-dy, y1 - top], [dy, bottom - y1]];
let hit = true;
for (const [p, q] of edges) {{
if (Math.abs(p) < 0.001) {{ if (q < 0) {{ hit = false; break; }} }}
else {{
const t = q / p;
if (p < 0) {{ if (t > tmin) tmin = t; }}
else {{ if (t < tmax) tmax = t; }}
if (tmin > tmax) {{ hit = false; break; }}
}}
}}
if (hit && tmin < tmax) return true;
}}
return false;
}}
// Find gap between adjacent groups (same row)
function findGapBetween(gi1, gi2) {{
if (gi1 < 0 || gi2 < 0) return null;
const g1 = groupBoxes[gi1], g2 = groupBoxes[gi2];
// Same row: Y ranges overlap
const yOverlap = g1.y < g2.y + g2.h && g2.y < g1.y + g1.h;
if (!yOverlap) return null;
// Gap between them
if (g1.x + g1.w < g2.x) return {{ x: (g1.x + g1.w + g2.x) / 2 }};
if (g2.x + g2.w < g1.x) return {{ x: (g2.x + g2.w + g1.x) / 2 }};
return null;
}}
// Build orthogonal path with rounded corners
function buildOrthoPath(pts) {{
let d = `M ${{pts[0].x}} ${{pts[0].y}}`;
const radius = 6;
for (let i = 1; i < pts.length - 1; i++) {{
const prev = pts[i-1], curr = pts[i], next = pts[i+1];
const dx1 = curr.x - prev.x, dy1 = curr.y - prev.y;
const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
const len1 = Math.sqrt(dx1*dx1 + dy1*dy1);
const len2 = Math.sqrt(dx2*dx2 + dy2*dy2);
if (len1 < 1 || len2 < 1) {{ d += ` L ${{curr.x}} ${{curr.y}}`; continue; }}
const r = Math.min(radius, len1/2, len2/2);
const bx = curr.x - (dx1/len1)*r, by = curr.y - (dy1/len1)*r;
const ax = curr.x + (dx2/len2)*r, ay = curr.y + (dy2/len2)*r;
d += ` L ${{bx}} ${{by}} Q ${{curr.x}} ${{curr.y}} ${{ax}} ${{ay}}`;
}}
d += ` L ${{pts[pts.length-1].x}} ${{pts[pts.length-1].y}}`;
return d;
}}
// Find crossing point between two orthogonal segments (H crosses V only)
function findSegCrossing(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {{
const aIsH = Math.abs(ay1 - ay2) < 1;
const bIsH = Math.abs(by1 - by2) < 1;
if (aIsH === bIsH) return null;
let hx1, hx2, hy, vx, vy1, vy2;
if (aIsH) {{
hy = ay1; hx1 = Math.min(ax1, ax2); hx2 = Math.max(ax1, ax2);
vx = bx1; vy1 = Math.min(by1, by2); vy2 = Math.max(by1, by2);
}} else {{
hy = by1; hx1 = Math.min(bx1, bx2); hx2 = Math.max(bx1, bx2);
vx = ax1; vy1 = Math.min(ay1, ay2); vy2 = Math.max(ay1, ay2);
}}
const MM = 2;
if (vx > hx1 + MM && vx < hx2 - MM &&
hy > vy1 + MM && hy < vy2 - MM) {{
return {{ x: vx, y: hy }};
}}
return null;
}}
// Build orthogonal path with rounded corners AND bridge arcs at crossing points
function buildPathWithBridges(pts, bridges) {{
const CR = 6, BR = 12;
if (pts.length <= 1) return '';
// Index bridges by segment, sort along travel direction
const bySeg = {{}};
(bridges || []).forEach(b => {{
if (!bySeg[b.segIdx]) bySeg[b.segIdx] = [];
bySeg[b.segIdx].push(b);
}});
for (const si in bySeg) {{
const i = parseInt(si);
if (i >= pts.length - 1) continue;
const p1 = pts[i], p2 = pts[i + 1];
const isH = Math.abs(p1.y - p2.y) < 1;
if (isH) {{
const dir = Math.sign(p2.x - p1.x) || 1;
bySeg[si].sort((a, b) => (a.x - b.x) * dir);
}} else {{
const dir = Math.sign(p2.y - p1.y) || 1;
bySeg[si].sort((a, b) => (a.y - b.y) * dir);
}}
}}
// Helper: append bridge arcs for a segment
function appendBridges(d, segIdx, segP1, segP2) {{
const segB = bySeg[segIdx] || [];
if (segB.length === 0) return d;
const isH = Math.abs(segP1.y - segP2.y) < 1;
segB.forEach(b => {{
if (isH) {{
const dir = Math.sign(segP2.x - segP1.x) || 1;
d += ` L ${{b.x - BR * dir}} ${{segP1.y}}`;
d += ` A ${{BR}} ${{BR}} 0 0 ${{dir > 0 ? 1 : 0}} ${{b.x + BR * dir}} ${{segP1.y}}`;
}} else {{
const dir = Math.sign(segP2.y - segP1.y) || 1;
d += ` L ${{segP1.x}} ${{b.y - BR * dir}}`;
d += ` A ${{BR}} ${{BR}} 0 0 ${{dir > 0 ? 0 : 1}} ${{segP1.x}} ${{b.y + BR * dir}}`;
}}
}});
return d;
}}
// 2-point path (straight line)
if (pts.length === 2) {{
let d = `M ${{pts[0].x}} ${{pts[0].y}}`;
d = appendBridges(d, 0, pts[0], pts[1]);
d += ` L ${{pts[1].x}} ${{pts[1].y}}`;
return d;
}}
// Multi-point path with corners + bridges
let d = `M ${{pts[0].x}} ${{pts[0].y}}`;
for (let i = 1; i < pts.length; i++) {{
const prev = pts[i - 1], curr = pts[i];
const isLast = (i === pts.length - 1);
// Compute corner trimming for non-last points
let target = curr, cSuffix = '';
if (!isLast) {{
const next = pts[i + 1];
const dx1 = curr.x - prev.x, dy1 = curr.y - prev.y;
const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
if (len1 >= 1 && len2 >= 1) {{
const r = Math.min(CR, len1 / 2, len2 / 2);
const bx = curr.x - (dx1 / len1) * r;
const by = curr.y - (dy1 / len1) * r;
const ax = curr.x + (dx2 / len2) * r;
const ay = curr.y + (dy2 / len2) * r;
target = {{ x: bx, y: by }};
cSuffix = ` Q ${{curr.x}} ${{curr.y}} ${{ax}} ${{ay}}`;
}}
}}
// Draw bridges on segment (i-1) → i
d = appendBridges(d, i - 1, prev, curr);
// Line to target + optional corner curve
d += ` L ${{target.x}} ${{target.y}}${{cSuffix}}`;
}}
return d;
}}
// ── Obstacle avoidance: route edges around nodes ──
function segHitsNode(x1, y1, x2, y2, pos, nw, nh, margin) {{
const nx1 = pos.x - margin, ny1 = pos.y - margin;
const nx2 = pos.x + nw + margin, ny2 = pos.y + nh + margin;
if (Math.abs(x1 - x2) < 1) {{
// Vertical segment
const x = x1;
const minY = Math.min(y1, y2), maxY = Math.max(y1, y2);
return x > nx1 && x < nx2 && maxY > ny1 && minY < ny2;
}} else {{
// Horizontal segment
const y = y1;
const minX = Math.min(x1, x2), maxX = Math.max(x1, x2);
return y > ny1 && y < ny2 && maxX > nx1 && minX < nx2;
}}
}}
function avoidNodes(pts, fromId, toId) {{
const MARGIN = 25;
const SECTION_MARGIN = 12;
let points = pts.map(p => ({{...p}}));
// Save original anchors — these must NEVER move (they attach to nodes)
const startAnchor = {{...points[0]}};
const endAnchor = {{...points[points.length - 1]}};
// Section (groupBox) obstacles: groupBoxes containing NEITHER endpoint.
// Skip PE group since PE-type edges legitimately traverse into it.
const _fromGrp = _nodeGrp[fromId];
const _toGrp = _nodeGrp[toId];
const sectionObstacles = [];
for (let gi = 0; gi < groupBoxes.length; gi++) {{
if (gi === _fromGrp || gi === _toGrp) continue;
const gb = groupBoxes[gi];
if (gb.isPE) continue;
sectionObstacles.push(gb);
}}
// Helper: if the section detour coord lands inside the VNet rect while
// either endpoint sits outside the VNet, push the detour past the nearer
// VNet edge so unrelated VNet interior is not traversed.
function _clampOutsideVNet(val, axis) {{
if (!_vnetBounds) return val;
const inAnchor = (a) => (a.x > _vnetBounds.x && a.x < _vnetBounds.x + _vnetBounds.w
&& a.y > _vnetBounds.y && a.y < _vnetBounds.y + _vnetBounds.h);
const srcOut = !inAnchor(startAnchor);
const dstOut = !inAnchor(endAnchor);
if (!srcOut && !dstOut) return val;
if (axis === 'x') {{
const L = _vnetBounds.x, R = _vnetBounds.x + _vnetBounds.w;
if (val > L && val < R) return (val - L) <= (R - val) ? L - SECTION_MARGIN : R + SECTION_MARGIN;
}} else {{
const T = _vnetBounds.y, B = _vnetBounds.y + _vnetBounds.h;
if (val > T && val < B) return (val - T) <= (B - val) ? T - SECTION_MARGIN : B + SECTION_MARGIN;
}}
return val;
}}
for (let iter = 0; iter < 20; iter++) {{
let found = false;
for (let i = 0; i < points.length - 1 && !found; i++) {{
const p1 = points[i], p2 = points[i+1];
// 1) Section obstacles (larger, checked first)
for (const gb of sectionObstacles) {{
const pos = {{x: gb.x, y: gb.y}};
if (!segHitsNode(p1.x, p1.y, p2.x, p2.y, pos, gb.w, gb.h, SECTION_MARGIN)) continue;
found = true;
const isVert = Math.abs(p1.x - p2.x) < 1;
const isFirst = (i === 0);
const isLast = (i + 1 === points.length - 1);
if (points.length <= 2) {{
if (isVert) {{
const leftX = gb.x - SECTION_MARGIN;
const rightX = gb.x + gb.w + SECTION_MARGIN;
let detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
detourX = _clampOutsideVNet(detourX, 'x');
points = [points[0], {{x: detourX, y: p1.y}}, {{x: detourX, y: p2.y}}, points[points.length-1]];
}} else {{
const topY = gb.y - SECTION_MARGIN;
const bottomY = gb.y + gb.h + SECTION_MARGIN;
let detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
detourY = _clampOutsideVNet(detourY, 'y');
points = [points[0], {{x: p1.x, y: detourY}}, {{x: p2.x, y: detourY}}, points[points.length-1]];
}}
}} else if (isFirst) {{
if (isVert) {{
const leftX = gb.x - SECTION_MARGIN;
const rightX = gb.x + gb.w + SECTION_MARGIN;
let detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
detourX = _clampOutsideVNet(detourX, 'x');
points.splice(1, 0, {{x: p1.x, y: p1.y}}, {{x: detourX, y: p1.y}});
points[3] = {{x: detourX, y: p2.y}};
}} else {{
const topY = gb.y - SECTION_MARGIN;
const bottomY = gb.y + gb.h + SECTION_MARGIN;
let detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
detourY = _clampOutsideVNet(detourY, 'y');
points.splice(1, 0, {{x: p1.x, y: detourY}});
points[2] = {{x: p2.x, y: detourY}};
}}
}} else if (isLast) {{
if (isVert) {{
const leftX = gb.x - SECTION_MARGIN;
const rightX = gb.x + gb.w + SECTION_MARGIN;
let detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
detourX = _clampOutsideVNet(detourX, 'x');
points[i] = {{x: detourX, y: p1.y}};
points.splice(i + 1, 0, {{x: detourX, y: p2.y}}, {{x: p2.x, y: p2.y}});
}} else {{
const topY = gb.y - SECTION_MARGIN;
const bottomY = gb.y + gb.h + SECTION_MARGIN;
let detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
detourY = _clampOutsideVNet(detourY, 'y');
points[i] = {{x: p1.x, y: detourY}};
points.splice(i + 1, 0, {{x: p2.x, y: detourY}});
}}
}} else {{
if (isVert) {{
const leftX = gb.x - SECTION_MARGIN;
const rightX = gb.x + gb.w + SECTION_MARGIN;
let newX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
newX = _clampOutsideVNet(newX, 'x');
points[i] = {{ x: newX, y: p1.y }};
points[i+1] = {{ x: newX, y: p2.y }};
}} else {{
const topY = gb.y - SECTION_MARGIN;
const bottomY = gb.y + gb.h + SECTION_MARGIN;
let newY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
newY = _clampOutsideVNet(newY, 'y');
points[i] = {{ x: p1.x, y: newY }};
points[i+1] = {{ x: p2.x, y: newY }};
}}
}}
break;
}}
if (found) break;
// 2) Service node obstacles
for (const node of NODES) {{
if (node.id === fromId || node.id === toId) continue;
const pos = positions[node.id];
if (!pos) continue;
const nw = node.type === 'pe' ? PE_W : SVC_W;
const nh = (node.type === 'pe' ? PE_H : SVC_H) + 20; // include text below box
if (!segHitsNode(p1.x, p1.y, p2.x, p2.y, pos, nw, nh, MARGIN)) continue;
found = true;
const isVert = Math.abs(p1.x - p2.x) < 1;
const isFirst = (i === 0);
const isLast = (i + 1 === points.length - 1);
if (points.length <= 2) {{
// Straight line hitting a node: convert to 4-point detour (anchors preserved)
if (isVert) {{
const leftX = pos.x - MARGIN;
const rightX = pos.x + nw + MARGIN;
const detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
points = [points[0], {{x: detourX, y: p1.y}}, {{x: detourX, y: p2.y}}, points[points.length-1]];
}} else {{
const topY = pos.y - MARGIN;
const bottomY = pos.y + nh + MARGIN;
const detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
points = [points[0], {{x: p1.x, y: detourY}}, {{x: p2.x, y: detourY}}, points[points.length-1]];
}}
}} else if (isFirst) {{
// First segment collides — keep points[0] (anchor), insert detour after it
if (isVert) {{
const leftX = pos.x - MARGIN;
const rightX = pos.x + nw + MARGIN;
const detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
points.splice(1, 0, {{x: p1.x, y: p1.y}}, {{x: detourX, y: p1.y}});
points[3] = {{x: detourX, y: p2.y}};
}} else {{
const topY = pos.y - MARGIN;
const bottomY = pos.y + nh + MARGIN;
const detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
points.splice(1, 0, {{x: p1.x, y: detourY}});
points[2] = {{x: p2.x, y: detourY}};
}}
}} else if (isLast) {{
// Last segment collides — keep last point (anchor), insert detour before it
if (isVert) {{
const leftX = pos.x - MARGIN;
const rightX = pos.x + nw + MARGIN;
const detourX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
points[i] = {{x: detourX, y: p1.y}};
points.splice(i + 1, 0, {{x: detourX, y: p2.y}}, {{x: p2.x, y: p2.y}});
}} else {{
const topY = pos.y - MARGIN;
const bottomY = pos.y + nh + MARGIN;
const detourY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
points[i] = {{x: p1.x, y: detourY}};
points.splice(i + 1, 0, {{x: p2.x, y: detourY}});
}}
}} else {{
// Middle segment: safe to push both endpoints
if (isVert) {{
const leftX = pos.x - MARGIN;
const rightX = pos.x + nw + MARGIN;
const newX = Math.abs(p1.x - leftX) <= Math.abs(p1.x - rightX) ? leftX : rightX;
points[i] = {{ x: newX, y: p1.y }};
points[i+1] = {{ x: newX, y: p2.y }};
}} else {{
const topY = pos.y - MARGIN;
const bottomY = pos.y + nh + MARGIN;
const newY = Math.abs(p1.y - topY) <= Math.abs(p1.y - bottomY) ? topY : bottomY;
points[i] = {{ x: p1.x, y: newY }};
points[i+1] = {{ x: p2.x, y: newY }};
}}
}}
break;
}}
}}
if (!found) break;
}}
// Restore anchors — guarantee lines always touch source/target nodes
points[0] = startAnchor;
points[points.length - 1] = endAnchor;
return points;
}}
// ── Edges: three-phase rendering ──
// Phase 0: pre-scan exit sides → Phase 1: compute paths with staggered anchors
// Phase 2: detect crossings → Phase 3: render with bridge arcs
const _edgeLabels = [];
// PHASE 0 — pre-scan: count how many edges exit each side of each node
const _sideTotal = {{}};
const _edgeSides = [];
EDGES.forEach(edge => {{
const fn = NODES.find(n => n.id === edge.from);
const tn = NODES.find(n => n.id === edge.to);
if (!fn || !tn) {{ _edgeSides.push(null); return; }}
const fromBox = getNodeBox(fn);
const toBox = getNodeBox(tn);
if (!fromBox || !toBox) {{ _edgeSides.push(null); return; }}
const isPeEdge = edge.type === 'private';
let exitSide, entrySide;
if (isPeEdge) {{
exitSide = 'bottom'; entrySide = 'top';
}} else {{
const dx = toBox.cx - fromBox.cx;
const dy = toBox.cy - fromBox.cy;
if (Math.abs(dx) >= Math.abs(dy)) {{
exitSide = dx >= 0 ? 'right' : 'left';
entrySide = dx >= 0 ? 'left' : 'right';
}} else {{
exitSide = dy >= 0 ? 'bottom' : 'top';
entrySide = dy >= 0 ? 'top' : 'bottom';
}}
}}
const ek = `${{edge.from}}_${{exitSide}}`;
const nk = `${{edge.to}}_${{entrySide}}`;
_sideTotal[ek] = (_sideTotal[ek] || 0) + 1;
_sideTotal[nk] = (_sideTotal[nk] || 0) + 1;
_edgeSides.push({{ exitSide, entrySide, isPeEdge, fromBox, toBox, edge }});
}});
// ── RACK MARSHALLING: build channel map for inter-group edge bundling ──
// Step 1: map each node to its containing group box
const _nodeGrp = {{}};
NODES.forEach(n => {{
const pos = positions[n.id];
if (!pos) return;
const nw = n.type === 'pe' ? PE_W : SVC_W;
const nh = n.type === 'pe' ? PE_H : SVC_H;
const cx = pos.x + nw / 2, cy = pos.y + nh / 2;
for (let gi = 0; gi < groupBoxes.length; gi++) {{
const gb = groupBoxes[gi];
if (cx >= gb.x && cx <= gb.x + gb.w && cy >= gb.y && cy <= gb.y + gb.h) {{
_nodeGrp[n.id] = gi; break;
}}
}}
}});
// Step 2: identify channels between group pairs + assign slot offsets
const _chMap = {{}}; // key → {{ axis:'y'|'x', value: number }}
const _chEdges = {{}}; // key → [edgeIdx, ...]
_edgeSides.forEach((info, idx) => {{
if (!info || info.isPeEdge) return;
const sg = _nodeGrp[info.edge.from], tg = _nodeGrp[info.edge.to];
if (sg === undefined || tg === undefined) return;
let key;
if (sg !== tg) {{
key = Math.min(sg, tg) + '_' + Math.max(sg, tg);
if (!_chEdges[key]) _chEdges[key] = [];
_chEdges[key].push(idx);
if (!_chMap[key]) {{
const a = groupBoxes[sg], b = groupBoxes[tg];
if (a.y + a.h <= b.y) _chMap[key] = {{ axis: 'y', value: (a.y + a.h + b.y) / 2 }};
else if (b.y + b.h <= a.y) _chMap[key] = {{ axis: 'y', value: (b.y + b.h + a.y) / 2 }};
else if (a.x + a.w <= b.x) _chMap[key] = {{ axis: 'x', value: (a.x + a.w + b.x) / 2 }};
else if (b.x + b.w <= a.x) _chMap[key] = {{ axis: 'x', value: (b.x + b.w + a.x) / 2 }};
}}
}} else {{
// Intra-group edges: group by direction for slot offset assignment
const dir = (info.exitSide === 'bottom' || info.exitSide === 'top') ? 'v' : 'h';
key = 'i' + sg + dir;
if (!_chEdges[key]) _chEdges[key] = [];
_chEdges[key].push(idx);
// No fixed channel value — each edge uses its own midpoint + offset
}}
}});
// Step 3: sort edges within each channel and assign slot offsets
const _chOff = {{}}; // edgeIdx → offset in px
const _CH_SLOT = 18; // spacing between lines in a bundle
Object.keys(_chEdges).forEach(key => {{
const ch = _chMap[key];
const arr = _chEdges[key];
const isVert = ch ? ch.axis === 'y' : key.endsWith('v');
arr.sort((a, b) => {{
const ia = _edgeSides[a], ib = _edgeSides[b];
if (isVert) return (ia.fromBox.cx + ia.toBox.cx) - (ib.fromBox.cx + ib.toBox.cx);
return (ia.fromBox.cy + ia.toBox.cy) - (ib.fromBox.cy + ib.toBox.cy);
}});
const n = arr.length;
arr.forEach((ei, slot) => {{
_chOff[ei] = n > 1 ? (slot - (n - 1) / 2) * _CH_SLOT : 0;
}});
}});
// Staggered border exit: spread multiple edges evenly along node side
const _sideUsed = {{}};
function staggeredExit(nodeId, box, side) {{
const key = `${{nodeId}}_${{side}}`;
const total = _sideTotal[key] || 1;
const idx = _sideUsed[key] || 0;
_sideUsed[key] = idx + 1;
const isH = (side === 'top' || side === 'bottom');
const sideLen = isH ? box.w : box.h;
const CM = Math.max(40, sideLen * 0.3); // corner margin — 40px min or 30% of side
const usable = Math.max(0, sideLen - 2 * CM);
const maxSpread = Math.min(usable, total * 14);
const step = total > 1 ? maxSpread / (total - 1) : 0;
const offset = total > 1 ? -maxSpread / 2 + idx * step : 0;
if (side === 'top') return {{ x: Math.max(box.x + CM, Math.min(box.x + box.w - CM, box.cx + offset)), y: box.y }};
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 }};
if (side === 'left') return {{ x: box.x, y: Math.max(box.y + CM, Math.min(box.y + box.h - CM, box.cy + offset)) }};
return {{ x: box.x + box.w, y: Math.max(box.y + CM, Math.min(box.y + box.h - CM, box.cy + offset)) }};
}}
// PHASE 1 — compute edge paths with staggered anchors
const _allEdgePaths = [];
_edgeSides.forEach((info, idx) => {{
if (!info) return;
const {{ exitSide, entrySide, isPeEdge, fromBox, toBox, edge }} = info;
let pts;
if (isPeEdge) {{
const sp = staggeredExit(edge.from, fromBox, 'bottom');
const ep = staggeredExit(edge.to, toBox, 'top');
if (Math.abs(sp.x - ep.x) < 8) {{
pts = [sp, ep];
}} else {{
let midY = (sp.y + ep.y) / 2;
midY = Math.max(midY, fromBox.y + fromBox.h + 40);
midY = Math.min(midY, toBox.y - 40);
pts = [sp, {{x: sp.x, y: midY}}, {{x: ep.x, y: midY}}, ep];
}}
pts = avoidNodes(pts, edge.from, edge.to);
}} else {{
const sp = staggeredExit(edge.from, fromBox, exitSide);
const ep = staggeredExit(edge.to, toBox, entrySide);
const STUB = 40;
// Channel lookup for inter-group marshalling
const _sg = _nodeGrp[edge.from], _tg = _nodeGrp[edge.to];
const _ck = _sg !== undefined && _tg !== undefined && _sg !== _tg
? Math.min(_sg, _tg) + '_' + Math.max(_sg, _tg) : null;
const _cc = _ck ? _chMap[_ck] : null;
const _co = _chOff[idx] || 0;
if (exitSide === 'right' || exitSide === 'left') {{
if (Math.abs(sp.y - ep.y) < 8) {{
pts = [sp, ep];
}} else {{
let midX = (_cc && _cc.axis === 'x') ? _cc.value + _co : (sp.x + ep.x) / 2 + _co;
if (exitSide === 'right') midX = Math.max(midX, fromBox.x + fromBox.w + STUB);
if (exitSide === 'left') midX = Math.min(midX, fromBox.x - STUB);
if (entrySide === 'right') midX = Math.max(midX, toBox.x + toBox.w + STUB);
if (entrySide === 'left') midX = Math.min(midX, toBox.x - STUB);
pts = [sp, {{x: midX, y: sp.y}}, {{x: midX, y: ep.y}}, ep];
}}
}} else {{
if (Math.abs(sp.x - ep.x) < 8) {{
pts = [sp, ep];
}} else {{
let midY = (_cc && _cc.axis === 'y') ? _cc.value + _co : (sp.y + ep.y) / 2 + _co;
if (exitSide === 'bottom') midY = Math.max(midY, fromBox.y + fromBox.h + STUB);
if (exitSide === 'top') midY = Math.min(midY, fromBox.y - STUB);
if (entrySide === 'bottom') midY = Math.max(midY, toBox.y + toBox.h + STUB);
if (entrySide === 'top') midY = Math.min(midY, toBox.y - STUB);
pts = [sp, {{x: sp.x, y: midY}}, {{x: ep.x, y: midY}}, ep];
}}
}}
pts = avoidNodes(pts, edge.from, edge.to);
}}
// POST-ROUTING: enforce perpendicular stub at exit & entry ends
// 3 cases: (a) already orthogonal + long enough → skip
// (b) orthogonal but short → extend existing turn point
// (c) non-orthogonal → insert 2-point connector
const _eSide = isPeEdge ? 'bottom' : exitSide;
const _nSide = isPeEdge ? 'top' : entrySide;
const _STUB = 40;
if (pts.length >= 3) {{
// --- EXIT end ---
const _p0 = pts[0], _p1 = pts[1];
const _eH = (_eSide === 'right' || _eSide === 'left');
if (_eH) {{
const _d = _eSide === 'right' ? 1 : -1;
const _ortho = Math.abs(_p0.y - _p1.y) <= 1;
if (_ortho) {{
const _dist = (_p1.x - _p0.x) * _d;
if (_dist < _STUB) {{
const sx = _p0.x + _d * _STUB;
pts[1] = {{x: sx, y: _p0.y}};
if (pts.length > 2 && Math.abs(pts[2].x - _p1.x) <= 1) {{
pts[2] = {{x: sx, y: pts[2].y}};
}}
}}
}} else {{
const sx = _p0.x + _d * _STUB;
pts.splice(1, 0, {{x: sx, y: _p0.y}}, {{x: sx, y: _p1.y}});
}}
}} else {{
const _d = _eSide === 'bottom' ? 1 : -1;
const _ortho = Math.abs(_p0.x - _p1.x) <= 1;
if (_ortho) {{
const _dist = (_p1.y - _p0.y) * _d;
if (_dist < _STUB) {{
const sy = _p0.y + _d * _STUB;
pts[1] = {{x: _p0.x, y: sy}};
if (pts.length > 2 && Math.abs(pts[2].y - _p1.y) <= 1) {{
pts[2] = {{x: pts[2].x, y: sy}};
}}
}}
}} else {{
const sy = _p0.y + _d * _STUB;
pts.splice(1, 0, {{x: _p0.x, y: sy}}, {{x: _p1.x, y: sy}});
}}
}}
// --- ENTRY end ---
const _pN = pts[pts.length - 1], _pP = pts[pts.length - 2];
const _nH = (_nSide === 'right' || _nSide === 'left');
if (_nH) {{
const _d = _nSide === 'left' ? -1 : 1;
const _ortho = Math.abs(_pN.y - _pP.y) <= 1;
if (_ortho) {{
const _dist = (_pP.x - _pN.x) * _d;
if (_dist < _STUB) {{
const sx = _pN.x + _d * _STUB;
const _idx = pts.length - 2;
pts[_idx] = {{x: sx, y: _pN.y}};
if (_idx > 0 && Math.abs(pts[_idx - 1].x - _pP.x) <= 1) {{
pts[_idx - 1] = {{x: sx, y: pts[_idx - 1].y}};
}}
}}
}} else {{
const sx = _pN.x + _d * _STUB;
pts.splice(pts.length - 1, 0, {{x: sx, y: _pP.y}}, {{x: sx, y: _pN.y}});
}}
}} else {{
const _d = _nSide === 'top' ? -1 : 1;
const _ortho = Math.abs(_pN.x - _pP.x) <= 1;
if (_ortho) {{
const _dist = (_pP.y - _pN.y) * _d;
if (_dist < _STUB) {{
const sy = _pN.y + _d * _STUB;
const _idx = pts.length - 2;
pts[_idx] = {{x: _pN.x, y: sy}};
if (_idx > 0 && Math.abs(pts[_idx - 1].y - _pP.y) <= 1) {{
pts[_idx - 1] = {{x: pts[_idx - 1].x, y: sy}};
}}
}}
}} else {{
const sy = _pN.y + _d * _STUB;
pts.splice(pts.length - 1, 0, {{x: _pP.x, y: sy}}, {{x: _pN.x, y: sy}});
}}
}}
}}
// SAFETY: break any remaining diagonal segments into orthogonal L-shapes
for (let _i = 0; _i < pts.length - 1; _i++) {{
const _a = pts[_i], _b = pts[_i + 1];
if (Math.abs(_a.x - _b.x) > 1 && Math.abs(_a.y - _b.y) > 1) {{
pts.splice(_i + 1, 0, {{x: _a.x, y: _b.y}});
}}
}}
// SIMPLIFY: remove duplicate & collinear middle points
for (let _i = pts.length - 2; _i >= 1; _i--) {{
const _a = pts[_i - 1], _b = pts[_i], _c = pts[_i + 1];
if (Math.abs(_a.x - _b.x) <= 1 && Math.abs(_a.y - _b.y) <= 1) {{
pts.splice(_i, 1); continue;
}}
if ((Math.abs(_a.x - _b.x) <= 1 && Math.abs(_b.x - _c.x) <= 1) ||
(Math.abs(_a.y - _b.y) <= 1 && Math.abs(_b.y - _c.y) <= 1)) {{
pts.splice(_i, 1);
}}
}}
_allEdgePaths.push({{ edge, pts, isPeEdge }});
}});
// OVERLAP SEPARATION — shift collinear overlapping segments apart
// Only separate segments closer than OSEP — pre-marshalled edges (16px apart) are unaffected
const OSEP = 8;
for (let pass = 0; pass < 4; pass++) {{
for (let i = 0; i < _allEdgePaths.length; i++) {{
for (let j = i + 1; j < _allEdgePaths.length; j++) {{
const pA = _allEdgePaths[i].pts;
const pB = _allEdgePaths[j].pts;
const dir = (j % 2 === 0) ? 1 : -1;
for (let si = 0; si < pA.length - 1; si++) {{
for (let sj = 0; sj < pB.length - 1; sj++) {{
const a1 = pA[si], a2 = pA[si + 1];
const b1 = pB[sj], b2 = pB[sj + 1];
const aV = Math.abs(a1.x - a2.x) < 2;
const bV = Math.abs(b1.x - b2.x) < 2;
const aH = Math.abs(a1.y - a2.y) < 2;
const bH = Math.abs(b1.y - b2.y) < 2;
if (aV && bV && Math.abs(a1.x - b1.x) < OSEP) {{
const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))
- Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));
if (ov > 10) {{
let shift = OSEP * dir;
if (b1.x + shift < 20) shift = Math.abs(shift);
if (sj > 0) pB[sj] = {{ x: b1.x + shift, y: b1.y }};
if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x + shift, y: b2.y }};
}}
}}
if (aH && bH && Math.abs(a1.y - b1.y) < OSEP) {{
const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))
- Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));
if (ov > 10) {{
const shift = OSEP * dir;
if (sj > 0) pB[sj] = {{ x: b1.x, y: b1.y + shift }};
if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x, y: b2.y + shift }};
}}
}}
}}
}}
}}
}}
}}
// FINAL ORTHOGONALIZATION — fix diagonals introduced by overlap separation
_allEdgePaths.forEach(({{ pts }}) => {{
for (let _i = 0; _i < pts.length - 1; _i++) {{
const _a = pts[_i], _b = pts[_i + 1];
if (Math.abs(_a.x - _b.x) > 1 && Math.abs(_a.y - _b.y) > 1) {{
pts.splice(_i + 1, 0, {{x: _a.x, y: _b.y}});
}}
}}
// Remove collinear
for (let _i = pts.length - 2; _i >= 1; _i--) {{
const _a = pts[_i - 1], _b = pts[_i], _c = pts[_i + 1];
if (Math.abs(_a.x - _b.x) <= 1 && Math.abs(_a.y - _b.y) <= 1) {{
pts.splice(_i, 1); continue;
}}
if ((Math.abs(_a.x - _b.x) <= 1 && Math.abs(_b.x - _c.x) <= 1) ||
(Math.abs(_a.y - _b.y) <= 1 && Math.abs(_b.y - _c.y) <= 1)) {{
pts.splice(_i, 1);
}}
}}
}});
// ── RE-ROUTING PASS: minimize crossings by routing edges via outer margins ──
// Instead of shortest-path, reroute crossing edges AROUND group boxes
const _gbLeft = groupBoxes.length > 0 ? Math.min(...groupBoxes.map(g => g.x)) : 0;
const _gbRight = groupBoxes.length > 0 ? Math.max(...groupBoxes.map(g => g.x + g.w)) : 800;
const _gbTop = groupBoxes.length > 0 ? Math.min(...groupBoxes.map(g => g.y)) : 0;
const _gbBottom = groupBoxes.length > 0 ? Math.max(...groupBoxes.map(g => g.y + g.h)) : 600;
const _RMARGIN = 50; // margin outside group bounds for rerouted edges
const _RM_SLOT = 14; // spacing between rerouted edges on the same margin
// Count H×V crossings between one edge and all others
function _cntCross(eIdx) {{
let c = 0;
const pA = _allEdgePaths[eIdx].pts;
for (let j = 0; j < _allEdgePaths.length; j++) {{
if (j === eIdx) continue;
const pB = _allEdgePaths[j].pts;
for (let si = 0; si < pA.length - 1; si++) {{
for (let sj = 0; sj < pB.length - 1; sj++) {{
if (findSegCrossing(pA[si].x, pA[si].y, pA[si+1].x, pA[si+1].y,
pB[sj].x, pB[sj].y, pB[sj+1].x, pB[sj+1].y)) c++;
}}
}}
}}
return c;
}}
// Generate a margin route: sp → stub → margin → margin → stub → ep
function _mRoute(sp, ep, exitSide, entrySide, side, slotOff) {{
const S = 40; // stub length
const so = slotOff || 0;
// stub exit from source node
const s1 = exitSide === 'bottom' ? {{x: sp.x, y: sp.y + S}}
: exitSide === 'top' ? {{x: sp.x, y: sp.y - S}}
: exitSide === 'right' ? {{x: sp.x + S, y: sp.y}}
: {{x: sp.x - S, y: sp.y}};
// stub entry to target node
const s2 = entrySide === 'top' ? {{x: ep.x, y: ep.y - S}}
: entrySide === 'bottom' ? {{x: ep.x, y: ep.y + S}}
: entrySide === 'left' ? {{x: ep.x - S, y: ep.y}}
: {{x: ep.x + S, y: ep.y}};
if (side === 'left') {{
const mx = _gbLeft - _RMARGIN - so;
return [sp, s1, {{x: mx, y: s1.y}}, {{x: mx, y: s2.y}}, s2, ep];
}}
if (side === 'right') {{
const mx = _gbRight + _RMARGIN + so;
return [sp, s1, {{x: mx, y: s1.y}}, {{x: mx, y: s2.y}}, s2, ep];
}}
if (side === 'top') {{
const my = _gbTop - _RMARGIN - so;
return [sp, s1, {{x: s1.x, y: my}}, {{x: s2.x, y: my}}, s2, ep];
}}
// bottom
const my = _gbBottom + _RMARGIN + so;
return [sp, s1, {{x: s1.x, y: my}}, {{x: s2.x, y: my}}, s2, ep];
}}
// Iteratively reroute edges with crossings via margins
const _marginUsed = {{ left: 0, right: 0, top: 0, bottom: 0 }};
const _tried = new Set();
for (let _ri = 0; _ri < 30; _ri++) {{
// Find edge with most crossings that hasn't been tried
let worstIdx = -1, worstCnt = 0;
for (let i = 0; i < _allEdgePaths.length; i++) {{
if (_allEdgePaths[i].isPeEdge || _tried.has(i)) continue;
const cnt = _cntCross(i);
if (cnt > worstCnt) {{ worstCnt = cnt; worstIdx = i; }}
}}
if (worstIdx < 0 || worstCnt === 0) break;
const ei = _edgeSides[worstIdx];
if (!ei) {{ _tried.add(worstIdx); continue; }}
const origPts = _allEdgePaths[worstIdx].pts;
const sp = origPts[0];
const ep = origPts[origPts.length - 1];
let bestPts = origPts, bestCnt = worstCnt, bestSide = null;
// Calculate span width for each margin to assign proper slot depth
for (const side of ['left', 'right', 'top', 'bottom']) {{
const alt = _mRoute(sp, ep, ei.exitSide, ei.entrySide, side, _marginUsed[side]);
_allEdgePaths[worstIdx].pts = alt;
const cnt = _cntCross(worstIdx);
_allEdgePaths[worstIdx].pts = origPts;
if (cnt < bestCnt) {{
bestCnt = cnt; bestPts = alt; bestSide = side;
}}
}}
if (bestSide && bestCnt < worstCnt) {{
_allEdgePaths[worstIdx].pts = bestPts;
_marginUsed[bestSide] += _RM_SLOT;
}} else {{
_tried.add(worstIdx); // mark as cannot-improve, try next edge
}}
}}
// POST-REROUTE: sort co-margin edges by span width (widest = outermost)
// Prevents vertical-segment crossings between edges on same margin side
const _marginEdges = {{ left: [], right: [], top: [], bottom: [] }};
for (let i = 0; i < _allEdgePaths.length; i++) {{
const pts = _allEdgePaths[i].pts;
if (pts.length !== 6) continue; // only margin-routed edges have 6 points
// Detect which margin side this edge uses
const p2 = pts[2], p3 = pts[3];
if (p2.y === p3.y) {{
// horizontal segment on margin → top or bottom
if (p2.y < _gbTop) {{ _marginEdges.top.push(i); }}
else if (p2.y > _gbBottom) {{ _marginEdges.bottom.push(i); }}
}} else if (p2.x === p3.x) {{
// vertical segment on margin → left or right
if (p2.x < _gbLeft) {{ _marginEdges.left.push(i); }}
else if (p2.x > _gbRight) {{ _marginEdges.right.push(i); }}
}}
}}
// Sort each margin group: widest span → outermost slot
for (const side of ['left', 'right', 'top', 'bottom']) {{
const idxs = _marginEdges[side];
if (idxs.length < 2) continue;
const isHoriz = (side === 'top' || side === 'bottom');
// Calculate span for each edge
const spans = idxs.map(i => {{
const pts = _allEdgePaths[i].pts;
return isHoriz
? Math.abs(pts[2].x - pts[3].x)
: Math.abs(pts[2].y - pts[3].y);
}});
// Sort indices by span descending (widest first → outermost)
const sorted = idxs.map((idx, j) => ({{ idx, span: spans[j] }}))
.sort((a, b) => b.span - a.span);
// Reassign y/x positions for sorted edges
const baseMargin = isHoriz
? (side === 'top' ? _gbTop - _RMARGIN : _gbBottom + _RMARGIN)
: (side === 'left' ? _gbLeft - _RMARGIN : _gbRight + _RMARGIN);
const dir = (side === 'top' || side === 'left') ? -1 : 1;
sorted.forEach((s, k) => {{
const pts = _allEdgePaths[s.idx].pts;
const newM = baseMargin + dir * k * _RM_SLOT;
if (isHoriz) {{
pts[2].y = newM; pts[3].y = newM;
}} else {{
pts[2].x = newM; pts[3].x = newM;
}}
}});
}}
// BOTTOM-LANE REROUTER — marshalled U-shape approach
// Reroute overlapping edges via evenly-spaced horizontal lanes below all sections.
// Direct vertical descent from node when possible (2 bends); offset only when blocked (4 bends).
const OSEP2 = 14;
const _bottomLaneBase = _gbBottom + _RMARGIN + 30;
let _bottomSlot = 0;
const _LANE_SPC = OSEP2; // 14px lane spacing — precise marshalled look
const _COL_SPC = OSEP2; // minimum distance between vertical corridors
const _rerouted = new Set();
const _usedCols = [];
function _colUsed(cx) {{ for (const ux of _usedCols) {{ if (Math.abs(cx - ux) < _COL_SPC) return true; }} return false; }}
// Check if vertical column is free of nodes and non-exempt section boxes
function _isColClear(cx, yMin, yMax, skipId1, skipId2, skipGbs) {{
for (const _nd of NODES) {{
if (_nd.id === skipId1 || _nd.id === skipId2) continue;
const _np = positions[_nd.id]; if (!_np) continue;
const _nw = _nd.type === 'pe' ? PE_W : SVC_W;
const _nh = (_nd.type === 'pe' ? PE_H : SVC_H) + 20;
const _pad = 6;
if (cx > _np.x - _pad && cx < _np.x + _nw + _pad &&
yMin < _np.y + _nh + _pad && yMax > _np.y - _pad) {{
return false;
}}
}}
for (const _gb of groupBoxes) {{
if (skipGbs && skipGbs.indexOf(_gb) >= 0) continue;
if (cx > _gb.x - 4 && cx < _gb.x + _gb.w + 4 &&
yMin < _gb.y + _gb.h + 4 && yMax > _gb.y - 4) {{
return false;
}}
}}
return true;
}}
// Check if horizontal row is free of nodes and non-exempt section boxes
function _isRowClear(cy, xMin, xMax, skipId1, skipId2, skipGbs) {{
for (const _nd of NODES) {{
if (_nd.id === skipId1 || _nd.id === skipId2) continue;
const _np = positions[_nd.id]; if (!_np) continue;
const _nw = _nd.type === 'pe' ? PE_W : SVC_W;
const _nh = (_nd.type === 'pe' ? PE_H : SVC_H) + 20;
const _pad = 6;
if (cy > _np.y - _pad && cy < _np.y + _nh + _pad &&
xMin < _np.x + _nw + _pad && xMax > _np.x - _pad) {{
return false;
}}
}}
for (const _gb of groupBoxes) {{
if (skipGbs && skipGbs.indexOf(_gb) >= 0) continue;
if (cy > _gb.y - 4 && cy < _gb.y + _gb.h + 4 &&
xMin < _gb.x + _gb.w + 4 && xMax > _gb.x - 4) {{
return false;
}}
}}
return true;
}}
function _findGb(px, py) {{
for (const _gb of groupBoxes) {{
if (px >= _gb.x && px <= _gb.x + _gb.w && py >= _gb.y && py <= _gb.y + _gb.h) return _gb;
}}
return null;
}}
// Find nearest clear column starting from preferred x, skipping source/dest sections
function _findCol(prefX, yMin, yMax, skipId1, skipId2, skipGbs, preferDir) {{
// Try preferred position first (direct vertical from node)
if (!_colUsed(prefX) && _isColClear(prefX, yMin, yMax, skipId1, skipId2, skipGbs)) return prefX;
// Search outward in small steps
const _dirs = preferDir < 0 ? [-1, 1] : (preferDir > 0 ? [1, -1] : [-1, 1]);
for (let _t = 1; _t <= 100; _t++) {{
for (const _d of _dirs) {{
const _cx = prefX + _d * _t * OSEP;
if (_cx < 20) continue;
if (_colUsed(_cx)) continue;
if (_isColClear(_cx, yMin, yMax, skipId1, skipId2, skipGbs)) return _cx;
}}
}}
return null;
}}
for (let _blPass = 0; _blPass < 20; _blPass++) {{
let _worstEdge = -1, _worstCount = 0;
for (let i = 0; i < _allEdgePaths.length; i++) {{
if (_rerouted.has(i)) continue;
let cnt = 0;
const pB = _allEdgePaths[i].pts;
for (let j = 0; j < _allEdgePaths.length; j++) {{
if (j === i) continue;
const pA = _allEdgePaths[j].pts;
let maxOv = 0;
for (let si = 0; si < pA.length - 1; si++) {{
for (let sj = 0; sj < pB.length - 1; sj++) {{
const a1 = pA[si], a2 = pA[si + 1], b1 = pB[sj], b2 = pB[sj + 1];
if (Math.abs(a1.y - a2.y) < 2 && Math.abs(b1.y - b2.y) < 2 && Math.abs(a1.y - b1.y) < OSEP2) {{
const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))
- Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));
if (ov > maxOv) maxOv = ov;
}}
if (Math.abs(a1.x - a2.x) < 2 && Math.abs(b1.x - b2.x) < 2 && Math.abs(a1.x - b1.x) < OSEP2) {{
const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))
- Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));
if (ov > maxOv) maxOv = ov;
}}
}}
}}
if (maxOv > 20) cnt++;
}}
if (cnt > _worstCount) {{ _worstCount = cnt; _worstEdge = i; }}
}}
if (_worstEdge < 0) break;
const pB = _allEdgePaths[_worstEdge].pts;
const _fromId = _allEdgePaths[_worstEdge].edge.from;
const _toId = _allEdgePaths[_worstEdge].edge.to;
const start = pB[0];
const end = pB[pB.length - 1];
// Source/dest sections are exempt — verticals can pass through own sections
const srcGb = _findGb(start.x, start.y);
const dstGb = _findGb(end.x, end.y);
const skipGbs = [srcGb, dstGb].filter(g => g !== null);
const _yMin = Math.min(start.y, end.y);
const _yMax = Math.max(start.y, end.y);
const _spanX = Math.abs(end.x - start.x);
// Prefer a local single-column reroute first to avoid long bottom-lane detours.
const _localPrefX = (start.x + end.x) / 2;
const _localX = _findCol(_localPrefX, _yMin, _yMax, _fromId, _toId, skipGbs);
const _localLimit = Math.max(_spanX + 40, 120);
if (_localX !== null &&
Math.abs(_localX - start.x) <= _localLimit &&
Math.abs(_localX - end.x) <= _localLimit &&
_isRowClear(start.y, Math.min(start.x, _localX), Math.max(start.x, _localX), _fromId, _toId, skipGbs) &&
_isRowClear(end.y, Math.min(end.x, _localX), Math.max(end.x, _localX), _fromId, _toId, skipGbs)) {{
_usedCols.push(_localX);
pB.length = 0;
pB.push(start);
if (Math.abs(_localX - start.x) > 2) pB.push({{ x: _localX, y: start.y }});
if (Math.abs(end.y - start.y) > 2) pB.push({{ x: _localX, y: end.y }});
if (Math.abs(_localX - end.x) > 2) pB.push({{ x: _localX, y: end.y }});
pB.push(end);
_rerouted.add(_worstEdge);
continue;
}}
const laneY = _bottomLaneBase + _bottomSlot * _LANE_SPC;
const _towardEnd = end.x >= start.x ? 1 : -1;
const _exitX = _findCol(start.x, Math.min(start.y, laneY), Math.max(start.y, laneY), _fromId, _toId, skipGbs, _towardEnd);
const _enterX = _findCol(end.x, Math.min(end.y, laneY), Math.max(end.y, laneY), _fromId, _toId, skipGbs, -_towardEnd);
if (_exitX === null || _enterX === null) {{
_rerouted.add(_worstEdge);
continue;
}}
_usedCols.push(_exitX);
_usedCols.push(_enterX);
_bottomSlot++;
pB.length = 0;
pB.push(start);
// Only add horizontal stub if exit column differs from node x
if (Math.abs(_exitX - start.x) > 2) pB.push({{ x: _exitX, y: start.y }});
pB.push({{ x: _exitX, y: laneY }});
pB.push({{ x: _enterX, y: laneY }});
if (Math.abs(_enterX - end.x) > 2) pB.push({{ x: _enterX, y: end.y }});
pB.push(end);
_rerouted.add(_worstEdge);
}}
// POST-REROUTE OVERLAP SEPARATION — push rerouted segments apart
for (let _rSep = 0; _rSep < 6; _rSep++) {{
for (let i = 0; i < _allEdgePaths.length; i++) {{
for (let j = i + 1; j < _allEdgePaths.length; j++) {{
const pA = _allEdgePaths[i].pts;
const pB = _allEdgePaths[j].pts;
// Separate all edge pairs (rerouted or not) to handle post-reroute overlaps
const dir = (j % 2 === 0) ? 1 : -1;
for (let si = 0; si < pA.length - 1; si++) {{
for (let sj = 0; sj < pB.length - 1; sj++) {{
const a1 = pA[si], a2 = pA[si + 1];
const b1 = pB[sj], b2 = pB[sj + 1];
const aV = Math.abs(a1.x - a2.x) < 2;
const bV = Math.abs(b1.x - b2.x) < 2;
const aH = Math.abs(a1.y - a2.y) < 2;
const bH = Math.abs(b1.y - b2.y) < 2;
if (aV && bV && Math.abs(a1.x - b1.x) < OSEP2) {{
const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))
- Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));
if (ov > 10) {{
let shift = OSEP2 * dir;
if (b1.x + shift < 20) shift = Math.abs(shift);
if (sj > 0) pB[sj] = {{ x: b1.x + shift, y: b1.y }};
if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x + shift, y: b2.y }};
}}
}}
if (aH && bH && Math.abs(a1.y - b1.y) < OSEP2) {{
const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))
- Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));
if (ov > 10) {{
const shift = OSEP2 * dir;
if (sj > 0) pB[sj] = {{ x: b1.x, y: b1.y + shift }};
if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x, y: b2.y + shift }};
}}
}}
}}
}}
}}
}}
}}
// POST-REROUTE ORTHOGONALIZATION
_allEdgePaths.forEach(({{ pts }}) => {{
for (let _i = 0; _i < pts.length - 1; _i++) {{
const _a = pts[_i], _b = pts[_i + 1];
if (Math.abs(_a.x - _b.x) > 1 && Math.abs(_a.y - _b.y) > 1) {{
pts.splice(_i + 1, 0, {{x: _a.x, y: _b.y}});
}}
}}
for (let _i = pts.length - 2; _i >= 1; _i--) {{
const _a = pts[_i - 1], _b = pts[_i], _c = pts[_i + 1];
if (Math.abs(_a.x - _b.x) <= 1 && Math.abs(_a.y - _b.y) <= 1) {{
pts.splice(_i, 1); continue;
}}
if ((Math.abs(_a.x - _b.x) <= 1 && Math.abs(_b.x - _c.x) <= 1) ||
(Math.abs(_a.y - _b.y) <= 1 && Math.abs(_b.y - _c.y) <= 1)) {{
pts.splice(_i, 1);
}}
}}
}});
// FINAL OVERLAP SEPARATION — catch any overlaps re-created by orthogonalization
for (let _fSep = 0; _fSep < 4; _fSep++) {{
for (let i = 0; i < _allEdgePaths.length; i++) {{
for (let j = i + 1; j < _allEdgePaths.length; j++) {{
const pA = _allEdgePaths[i].pts;
const pB = _allEdgePaths[j].pts;
for (let si = 0; si < pA.length - 1; si++) {{
for (let sj = 0; sj < pB.length - 1; sj++) {{
const a1 = pA[si], a2 = pA[si + 1];
const b1 = pB[sj], b2 = pB[sj + 1];
const aH = Math.abs(a1.y - a2.y) < 2;
const bH = Math.abs(b1.y - b2.y) < 2;
const aV = Math.abs(a1.x - a2.x) < 2;
const bV = Math.abs(b1.x - b2.x) < 2;
if (aH && bH && Math.abs(a1.y - b1.y) < 6) {{
const ov = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x))
- Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x));
if (ov > 20) {{
const shift = 8 * ((j % 2 === 0) ? 1 : -1);
if (sj > 0) pB[sj] = {{ x: b1.x, y: b1.y + shift }};
if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x, y: b2.y + shift }};
}}
}}
if (aV && bV && Math.abs(a1.x - b1.x) < 6) {{
const ov = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y))
- Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y));
if (ov > 20) {{
let shift = 8 * ((j % 2 === 0) ? 1 : -1);
if (b1.x + shift < 20) shift = Math.abs(shift);
if (sj > 0) pB[sj] = {{ x: b1.x + shift, y: b1.y }};
if (sj + 1 < pB.length - 1) pB[sj + 1] = {{ x: b2.x + shift, y: b2.y }};
}}
}}
}}
}}
}}
}}
}}
// FINAL DIAGONAL BREAKER — any non-orthogonal segment is split into an L-shape.
// Diagonals may be introduced by the separation pass above when only one
// endpoint of a segment is shifted. Axis-align every segment as a last safety net.
for (const _ep of _allEdgePaths) {{
const pts = _ep.pts;
for (let k = 0; k < pts.length - 1; k++) {{
const q1 = pts[k], q2 = pts[k + 1];
const dx = q2.x - q1.x;
const dy = q2.y - q1.y;
if (Math.abs(dx) > 1 && Math.abs(dy) > 1) {{
// Insert elbow at (q2.x, q1.y) — preserves endpoints, forces L-shape.
// Direction heuristic: follow the dominant axis first.
const elbow = Math.abs(dx) >= Math.abs(dy)
? {{ x: q2.x, y: q1.y }}
: {{ x: q1.x, y: q2.y }};
pts.splice(k + 1, 0, elbow);
// Re-check the newly inserted segments in the next iteration
}}
}}
}}
// CROSSING DETECTION— find which edges cross each other (for color differentiation)
const _crossNeighbors = {{}};
for (let i = 0; i < _allEdgePaths.length; i++) {{
for (let j = i + 1; j < _allEdgePaths.length; j++) {{
const ptsA = _allEdgePaths[i].pts;
const ptsB = _allEdgePaths[j].pts;
let crossed = false;
for (let si = 0; si < ptsA.length - 1 && !crossed; si++) {{
for (let sj = 0; sj < ptsB.length - 1 && !crossed; sj++) {{
if (findSegCrossing(
ptsA[si].x, ptsA[si].y, ptsA[si + 1].x, ptsA[si + 1].y,
ptsB[sj].x, ptsB[sj].y, ptsB[sj + 1].x, ptsB[sj + 1].y
)) crossed = true;
}}
}}
if (crossed) {{
if (!_crossNeighbors[i]) _crossNeighbors[i] = new Set();
if (!_crossNeighbors[j]) _crossNeighbors[j] = new Set();
_crossNeighbors[i].add(j);
_crossNeighbors[j].add(i);
}}
}}
}}
// Greedy graph coloring — crossing edges get distinct colors
const _CROSS_COLORS = ['#0078D4', '#E3008C', '#00B7C3', '#FF8C00', '#107C10', '#881798'];
const _edgeColor = {{}};
const crossingEdges = Object.keys(_crossNeighbors).map(Number)
.sort((a, b) => _crossNeighbors[b].size - _crossNeighbors[a].size);
crossingEdges.forEach(eIdx => {{
const neighborColors = new Set();
_crossNeighbors[eIdx].forEach(n => {{
if (_edgeColor[n] !== undefined) neighborColors.add(_edgeColor[n]);
}});
let colorIdx = 0;
while (neighborColors.has(colorIdx)) colorIdx++;
_edgeColor[eIdx] = colorIdx;
}});
// RENDER EDGES — no bridge arcs, just orthogonal paths with color coding
function renderEdge({{ edge, pts, isPeEdge, edgeIdx }}) {{
let pathD;
if (pts.length <= 2) {{
pathD = `M ${{pts[0].x}} ${{pts[0].y}} L ${{pts[pts.length - 1].x}} ${{pts[pts.length - 1].y}}`;
}} else {{
pathD = buildOrthoPath(pts);
}}
// Determine edge color: PE=purple, crossing=colored, normal=gray
let edgeStroke, edgeOpacity;
if (isPeEdge) {{
edgeStroke = '#5C2D91';
edgeOpacity = '0.5';
}} else if (_edgeColor[edgeIdx] !== undefined) {{
edgeStroke = _CROSS_COLORS[_edgeColor[edgeIdx] % _CROSS_COLORS.length];
edgeOpacity = '0.75';
}} else {{
edgeStroke = '#8a8886';
edgeOpacity = '0.65';
}}
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', pathD);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', edgeStroke);
path.setAttribute('stroke-width', isPeEdge ? '1' : '1.2');
path.setAttribute('stroke-dasharray', edge.dash || '0');
path.setAttribute('marker-end', `url(#${{markerFor(edge.type)}})`);
path.setAttribute('opacity', edgeOpacity);
path.classList.add('edge-path');
path.setAttribute('data-from', edge.from);
path.setAttribute('data-to', edge.to);
root.appendChild(path);
// Label placement — collision-aware
if (edge.label) {{
const bw = edge.label.length * 5.5 + 10;
const bh = 14;
function labelHitsNode(lx, ly) {{
return NODES.some(n => {{
const p = positions[n.id];
if (!p) return false;
const nw = n.type === 'pe' ? PE_W : SVC_W;
const nh = n.type === 'pe' ? PE_H : SVC_H;
return lx + bw/2 > p.x && lx - bw/2 < p.x + nw
&& ly + bh/2 > p.y && ly - bh/2 < p.y + nh;
}});
}}
const candidates = [];
for (let s = 0; s < pts.length - 1; s++) {{
const cx = (pts[s].x + pts[s + 1].x) / 2;
const cy = (pts[s].y + pts[s + 1].y) / 2;
const priority = Math.abs(s - (pts.length - 2) / 2);
candidates.push({{ x: cx, y: cy, priority }});
}}
candidates.sort((a, b) => a.priority - b.priority);
let chosen = candidates[0];
for (const c of candidates) {{
if (!labelHitsNode(c.x, c.y)) {{ chosen = c; break; }}
}}
if (labelHitsNode(chosen.x, chosen.y)) {{
const offsets = [{{x:0,y:-20}},{{x:0,y:20}},{{x:-20,y:0}},{{x:20,y:0}}];
for (const off of offsets) {{
if (!labelHitsNode(chosen.x + off.x, chosen.y + off.y)) {{
chosen = {{ x: chosen.x + off.x, y: chosen.y + off.y }};
break;
}}
}}
}}
_edgeLabels.push({{ label: edge.label, x: chosen.x, y: chosen.y, from: edge.from, to: edge.to }});
}}
return {{ path, edge, pts }};
}}
// Render all edges
_allEdgePaths.forEach((ep, edgeIdx) => renderEdge({{ ...ep, edgeIdx }}));
// Re-append group labels on top of edges
_groupLabelElements.forEach(el => root.appendChild(el));
// ── Nodes (rendered LAST — on top of edges, covering crossing points) ──
NODES.forEach(node => {{
const pos = positions[node.id];
if (!pos) return;
const isPe = node.type === 'pe';
const nw = isPe ? PE_W : SVC_W;
const nh = isPe ? PE_H : SVC_H;
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('class', 'node');
g.setAttribute('data-id', node.id);
g.setAttribute('transform', `translate(${{pos.x}},${{pos.y}})`);
// Card background — full clickable area
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('class', 'node-bg');
rect.setAttribute('width', nw); rect.setAttribute('height', nh);
rect.setAttribute('rx', '8'); rect.setAttribute('fill', 'white');
rect.setAttribute('stroke', '#c8c6c4'); rect.setAttribute('stroke-width', '1.2');
rect.setAttribute('filter', 'url(#shadow)');
g.appendChild(rect);
// Color accent bar at top
const accent = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
accent.setAttribute('width', nw); accent.setAttribute('height', '3');
accent.setAttribute('rx', '8'); accent.setAttribute('fill', node.color);
accent.setAttribute('opacity', '0.7');
g.appendChild(accent);
// Icon — official Azure icon (data URI) preferred, fallback to SVG
const iconSize = isPe ? 28 : 36;
const iconX = (nw - iconSize) / 2;
const iconY = isPe ? 12 : 14;
if (node.icon_data_uri) {{
// Official Azure icon (Base64 image)
const iconImg = document.createElementNS('http://www.w3.org/2000/svg', 'image');
iconImg.setAttribute('x', iconX); iconImg.setAttribute('y', iconY);
iconImg.setAttribute('width', iconSize); iconImg.setAttribute('height', iconSize);
iconImg.setAttributeNS('http://www.w3.org/1999/xlink', 'href', node.icon_data_uri);
g.appendChild(iconImg);
}} else {{
// Fallback: built-in SVG text icon
const iconG = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
iconG.setAttribute('x', iconX); iconG.setAttribute('y', iconY);
iconG.setAttribute('width', iconSize); iconG.setAttribute('height', iconSize);
iconG.setAttribute('viewBox', '0 0 48 48');
iconG.innerHTML = node.icon_svg;
g.appendChild(iconG);
}}
// Name — extra gap below icon (icon bottom ~50, name baseline at 74 → 24px breathing room)
const name = document.createElementNS('http://www.w3.org/2000/svg', 'text');
name.setAttribute('x', nw/2); name.setAttribute('y', isPe ? 64 : 74);
name.setAttribute('text-anchor', 'middle');
name.setAttribute('font-size', isPe ? '10' : '11');
name.setAttribute('font-weight', '600'); name.setAttribute('fill', '#323130');
name.setAttribute('font-family', 'Segoe UI, sans-serif');
const maxC = isPe ? 14 : 20;
name.textContent = node.name.length > maxC ? node.name.substring(0, maxC-1) + '..' : node.name;
g.appendChild(name);
// SKU label
if (!isPe && node.sku) {{
const sku = document.createElementNS('http://www.w3.org/2000/svg', 'text');
sku.setAttribute('x', nw/2); sku.setAttribute('y', 90);
sku.setAttribute('text-anchor', 'middle');
sku.setAttribute('font-size', '10'); sku.setAttribute('fill', '#a19f9d');
sku.setAttribute('font-family', 'Segoe UI, sans-serif');
sku.textContent = node.sku;
g.appendChild(sku);
}}
if (isPe && node.details.length > 0) {{
const det = document.createElementNS('http://www.w3.org/2000/svg', 'text');
det.setAttribute('x', nw/2); det.setAttribute('y', 76);
det.setAttribute('text-anchor', 'middle');
det.setAttribute('font-size', '9'); det.setAttribute('fill', '#a19f9d');
det.setAttribute('font-family', 'Segoe UI, sans-serif');
det.textContent = node.details[0];
g.appendChild(det);
}}
// Service type label below (not category — show actual service type name)
if (!isPe) {{
const TYPE_LABELS = {{
'ai_foundry': 'AI Foundry', 'openai': 'Azure OpenAI', 'search': 'AI Search', 'ai_search': 'AI Search',
'storage': 'Storage', 'adls': 'ADLS Gen2', 'keyvault': 'Key Vault', 'kv': 'Key Vault',
'fabric': 'Fabric', 'databricks': 'Databricks', 'adf': 'Data Factory', 'data_factory': 'Data Factory',
'sql_server': 'SQL Server', 'sql_database': 'SQL Database', 'cosmos_db': 'Cosmos DB',
'vm': 'Virtual Machine', 'aks': 'AKS', 'app_service': 'App Service',
'function_app': 'Function App', 'synapse': 'Synapse', 'vnet': 'VNet',
'nsg': 'NSG', 'bastion': 'Bastion', 'pe': 'Private Endpoint',
'log_analytics': 'Log Analytics', 'app_insights': 'App Insights',
'monitor': 'Monitor', 'acr': 'Container Registry', 'container_registry': 'Container Registry',
'document_intelligence': 'Doc Intelligence', 'form_recognizer': 'Doc Intelligence',
'cdn': 'CDN', 'event_hub': 'Event Hub', 'redis': 'Redis Cache',
'devops': 'Azure DevOps', 'app_gateway': 'App Gateway',
'iot_hub': 'IoT Hub', 'stream_analytics': 'Stream Analytics',
'vpn_gateway': 'VPN Gateway', 'front_door': 'Front Door',
'ai_hub': 'AI Hub', 'firewall': 'Firewall',
}};
const typeLabel = TYPE_LABELS[node.type] || node.type;
const cat = document.createElementNS('http://www.w3.org/2000/svg', 'text');
cat.setAttribute('x', nw/2); cat.setAttribute('y', nh + 14);
cat.setAttribute('text-anchor', 'middle');
cat.setAttribute('font-size', '10'); cat.setAttribute('fill', node.color);
cat.setAttribute('font-weight', '600');
cat.setAttribute('font-family', 'Segoe UI, sans-serif');
cat.textContent = typeLabel;
g.appendChild(cat);
}}
// Private badge on card
if (node.private && !isPe) {{
const badge = document.createElementNS('http://www.w3.org/2000/svg', 'g');
const br = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
br.setAttribute('x', nw - 8); br.setAttribute('y', '4');
br.setAttribute('width', '6'); br.setAttribute('height', '6');
br.setAttribute('rx', '3'); br.setAttribute('fill', '#5C2D91');
br.setAttribute('opacity', '0.6');
badge.appendChild(br);
g.appendChild(badge);
}}
// ── Events: drag vs click separation ──
g.addEventListener('mousedown', e => {{
if (e.button !== 0) return;
dragging = node.id;
_didDrag = false;
_dragStartX = e.clientX; _dragStartY = e.clientY;
const svgPt = getSVGPoint(e);
dragOffX = svgPt.x - pos.x; dragOffY = svgPt.y - pos.y;
e.stopPropagation(); e.preventDefault();
}});
g.addEventListener('mousemove', e => {{
if (dragging === node.id) {{
const dx = Math.abs(e.clientX - _dragStartX);
const dy = Math.abs(e.clientY - _dragStartY);
if (dx > 3 || dy > 3) _didDrag = true;
}}
}});
g.addEventListener('mouseup', e => {{
if (!_didDrag && dragging === node.id) {{
selectNode(node.id);
}}
}});
g.addEventListener('mouseenter', e => {{
const tt = document.getElementById('tooltip');
const dets = node.details.map(d => `<div class="tooltip-detail"> ${{d}}</div>`).join('');
tt.style.display = 'block';
tt.innerHTML = `<strong>${{node.name}}</strong>${{node.sku ? `<div class="tooltip-detail">SKU: ${{node.sku}}</div>` : ''}}${{dets}}`;
}});
g.addEventListener('mousemove', e => {{
const tt = document.getElementById('tooltip');
tt.style.left = (e.clientX+12)+'px'; tt.style.top = (e.clientY-8)+'px';
}});
g.addEventListener('mouseleave', () => {{ document.getElementById('tooltip').style.display = 'none'; }});
root.appendChild(g);
}});
// ── Edge labels (rendered AFTER nodes — always visible on top) ──
_edgeLabels.forEach(el => {{
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.classList.add('edge-label');
g.setAttribute('data-from', el.from);
g.setAttribute('data-to', el.to);
const r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
r.classList.add('edge-label-bg');
const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
const bw = el.label.length * 6 + 10;
r.setAttribute('x', el.x-bw/2); r.setAttribute('y', el.y-7);
r.setAttribute('width', bw); r.setAttribute('height', 14);
r.setAttribute('rx', '3'); r.setAttribute('fill', 'white');
r.setAttribute('stroke', '#d2d0ce'); r.setAttribute('stroke-width', '0.5');
r.setAttribute('opacity', '0.95');
t.setAttribute('x', el.x); t.setAttribute('y', el.y+3);
t.setAttribute('text-anchor', 'middle'); t.setAttribute('font-size', '9');
t.setAttribute('fill', '#605e5c'); t.setAttribute('font-family', 'Segoe UI, sans-serif');
t.textContent = el.label;
g.appendChild(r); g.appendChild(t);
root.appendChild(g);
}});
// Re-apply text scale and selection state after DOM rebuild
if (typeof _textScale !== 'undefined' && _textScale !== 1) applyTextScale();
if (_selectedNodeId) applySelectionHighlight();
}}
function getSVGPoint(e) {{
const svg = document.getElementById('canvas');
const pt = svg.createSVGPoint();
pt.x = e.clientX; pt.y = e.clientY;
return pt.matrixTransform(document.getElementById('diagram-root').getScreenCTM().inverse());
}}
document.getElementById('canvas').addEventListener('mousemove', e => {{
if (dragging) {{
const p = getSVGPoint(e);
positions[dragging].x = p.x - dragOffX;
positions[dragging].y = p.y - dragOffY;
renderDiagram();
}} else if (draggingGroup !== null) {{
const p = getSVGPoint(e);
const dx = p.x - dragOffX;
const dy = p.y - dragOffY;
dragOffX = p.x; dragOffY = p.y;
// Move all nodes in the group
groupDragNodes.forEach(nid => {{
if (positions[nid]) {{
positions[nid].x += dx;
positions[nid].y += dy;
}}
}});
// Also move the group box itself
const gb = groupBoxes[draggingGroup];
if (gb) {{ gb.x += dx; gb.y += dy; }}
renderDiagram();
}}
}});
document.addEventListener('mouseup', () => {{ dragging = null; draggingGroup = null; groupDragNodes = []; }});
// ── Pan & Zoom ──
function applyTransform() {{
document.getElementById('diagram-root').setAttribute('transform',
`translate(${{viewTransform.x}},${{viewTransform.y}}) scale(${{viewTransform.scale}})`);
document.getElementById('zoom-level').textContent = Math.round(viewTransform.scale * 100) + '%';
}}
function fitToScreen() {{
const svg = document.getElementById('canvas');
const root = document.getElementById('diagram-root');
root.setAttribute('transform', '');
const bbox = root.getBBox();
if (!bbox.width || !bbox.height) return;
const w = svg.clientWidth, h = svg.clientHeight;
const s = Math.min((w-60)/bbox.width, (h-60)/bbox.height, 1.5);
if (s <= 0) return;
viewTransform.scale = s;
viewTransform.x = (w - bbox.width*s)/2 - bbox.x*s;
viewTransform.y = (h - bbox.height*s)/2 - bbox.y*s;
applyTransform();
}}
function zoomIn() {{ viewTransform.scale *= 1.25; applyTransform(); }}
function zoomOut() {{ viewTransform.scale *= 0.8; applyTransform(); }}
// ── Text size controls ──
let _textScale = 1.4; // default 40% larger than raw attribute sizes
function applyTextScale() {{
document.querySelectorAll('#canvas text').forEach(t => {{
let orig = t.getAttribute('data-orig-fs');
if (!orig) {{
orig = t.getAttribute('font-size');
if (!orig) {{
const cs = window.getComputedStyle(t).fontSize;
orig = cs ? parseFloat(cs).toString() : '11';
}}
t.setAttribute('data-orig-fs', orig);
}}
t.setAttribute('font-size', (parseFloat(orig) * _textScale).toFixed(2));
}});
}}
function textBigger() {{ _textScale = Math.min(2.5, _textScale * 1.15); applyTextScale(); }}
function textSmaller() {{ _textScale = Math.max(0.5, _textScale / 1.15); applyTextScale(); }}
function downloadPNG() {{
const svg = document.getElementById('canvas');
const bbox = svg.getBBox();
const pad = 40;
const w = Math.ceil(bbox.width + bbox.x + pad * 2);
const h = Math.ceil(bbox.height + bbox.y + pad * 2);
const clone = svg.cloneNode(true);
clone.setAttribute('width', w);
clone.setAttribute('height', h);
clone.setAttribute('viewBox', `${{-pad}} ${{-pad}} ${{w}} ${{h}}`);
clone.querySelector('#viewport')?.removeAttribute('transform');
// Inline all computed styles
const allEls = clone.querySelectorAll('*');
const origEls = svg.querySelectorAll('*');
allEls.forEach((el, i) => {{
if (origEls[i]) {{
const cs = window.getComputedStyle(origEls[i]);
['fill','stroke','stroke-width','font-size','font-family','font-weight',
'text-anchor','opacity','fill-opacity','stroke-opacity','stroke-dasharray'].forEach(p => {{
const v = cs.getPropertyValue(p);
if (v) el.style.setProperty(p, v);
}});
}}
}});
const serializer = new XMLSerializer();
const svgStr = serializer.serializeToString(clone);
const svgBlob = new Blob([svgStr], {{type: 'image/svg+xml;charset=utf-8'}});
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = () => {{
const canvas = document.createElement('canvas');
const scale = 2;
canvas.width = w * scale;
canvas.height = h * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
URL.revokeObjectURL(url);
canvas.toBlob(blob => {{
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (document.title || 'azure-architecture') + '.png';
a.click();
URL.revokeObjectURL(a.href);
}}, 'image/png');
}};
img.src = url;
}}
document.getElementById('canvas').addEventListener('wheel', e => {{
e.preventDefault();
const f = e.deltaY < 0 ? 1.1 : 0.9;
const rect = document.getElementById('canvas').getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const os = viewTransform.scale, ns = os * f;
viewTransform.x = mx - (mx - viewTransform.x) * (ns/os);
viewTransform.y = my - (my - viewTransform.y) * (ns/os);
viewTransform.scale = ns;
applyTransform();
}}, {{ passive: false }});
document.getElementById('canvas').addEventListener('mousedown', e => {{
if (e.target.closest('.node')) return;
isPanning = true;
panSX = e.clientX; panSY = e.clientY;
panSTx = viewTransform.x; panSTy = viewTransform.y;
document.getElementById('canvas').style.cursor = 'grabbing';
e.preventDefault();
}});
document.addEventListener('mousemove', e => {{
if (isPanning) {{
viewTransform.x = panSTx + (e.clientX - panSX);
viewTransform.y = panSTy + (e.clientY - panSY);
applyTransform();
}}
}});
document.addEventListener('mouseup', () => {{
if (isPanning) {{ isPanning = false; document.getElementById('canvas').style.cursor = ''; }}
}});
// ── Sidebar ──
function buildSidebar() {{
const list = document.getElementById('service-list');
const byCat = {{}};
NODES.forEach(n => {{ if (!byCat[n.category]) byCat[n.category] = []; byCat[n.category].push(n); }});
Object.entries(byCat).forEach(([cat, nodes]) => {{
const cd = document.createElement('div');
cd.className = 'cat-label'; cd.textContent = cat;
list.appendChild(cd);
nodes.forEach(node => {{
const card = document.createElement('div');
card.className = 'service-card'; card.id = 'card-' + node.id;
card.innerHTML = `
<div class="service-card-header">
<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>
<div>
<div class="service-name">${{node.name}}</div>
<div class="service-sku">${{node.sku || node.type}}</div>
</div>
${{node.private ? '<span class="private-badge">Private</span>' : ''}}
</div>
${{node.details.length > 0 ? `<div class="service-card-body">${{node.details.map(d => `<div class="service-detail">${{d}}</div>`).join('')}}</div>` : ''}}
`;
card.addEventListener('click', () => {{
selectNode(node.id);
}});
list.appendChild(card);
}});
}});
}}
// ── VNet highlight toggle ──
let _vnetHighlighted = false;
function toggleVNetHighlight() {{
_vnetHighlighted = !_vnetHighlighted;
const vr = document.getElementById('vnet-rect');
if (!vr) return;
if (_vnetHighlighted) {{
vr.setAttribute('stroke-width', '4');
vr.setAttribute('stroke', '#5C2D91');
vr.setAttribute('fill', '#f0eaf8');
}} else {{
vr.setAttribute('stroke-width', '2');
vr.setAttribute('stroke', '#5C2D91');
vr.setAttribute('fill', '#f8f7ff');
}}
// Also toggle sidebar card
const card = document.getElementById('card-vnet-boundary');
if (card) card.classList.toggle('selected', _vnetHighlighted);
}}
renderDiagram();
buildSidebar();
// ── VNet sidebar card (added dynamically if VNet boundary exists) ──
if (VNET_INFO || NODES.some(n => n.private && n.type !== 'pe') || NODES.some(n => n.type === 'pe')) {{
const list = document.getElementById('service-list');
// Insert at the top
const catLabel = document.createElement('div');
catLabel.className = 'cat-label'; catLabel.textContent = 'NETWORK';
const card = document.createElement('div');
card.className = 'service-card'; card.id = 'card-vnet-boundary';
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"/>';
const vnetDetails = VNET_INFO ? VNET_INFO.split('|').map(s => s.trim()) : [];
card.innerHTML = `
<div class="service-card-header">
<div class="sc-icon"><svg viewBox="0 0 48 48">${{vnetIcon}}</svg></div>
<div>
<div class="service-name">Virtual Network</div>
<div class="service-sku">vnet</div>
</div>
<span class="private-badge">Private</span>
</div>
${{vnetDetails.length > 0 ? `<div class="service-card-body">${{vnetDetails.map(d => `<div class="service-detail">${{d}}</div>`).join('')}}</div>` : ''}}
`;
card.addEventListener('click', () => {{ toggleVNetHighlight(); }});
list.insertBefore(card, list.firstChild);
list.insertBefore(catLabel, list.firstChild);
}}
setTimeout(fitToScreen, 100);
</script>
</body>
</html>"""
return html
def generate_diagram(services, connections, title="Azure Architecture", vnet_info="", hierarchy=None):
"""Generate an interactive Azure architecture diagram as an HTML string.
Args:
services: list of dicts with keys id, name, type, sku, private, details, etc.
connections: list of dicts with keys from, to, label, type.
title: diagram title string.
vnet_info: VNet CIDR info string.
hierarchy: optional subscription/RG hierarchy list.
Returns:
HTML string containing the interactive diagram.
"""
return generate_html(services, connections, title, vnet_info=vnet_info, hierarchy=hierarchy)