Modules/Outputs/Diagrams/10-Diagrams.ps1

function Invoke-RangerDiagramGeneration {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

        [Parameter(Mandatory = $true)]
        [string]$PackageRoot,

        [string[]]$Formats = @('svg'),
        [string]$Mode
    )

    $diagramsRoot = Join-Path -Path $PackageRoot -ChildPath 'diagrams'
    New-Item -ItemType Directory -Path $diagramsRoot -Force | Out-Null
    $artifacts = New-Object System.Collections.ArrayList
    $prefix = Get-RangerArtifactPrefix -Manifest $Manifest

    foreach ($definition in (Get-RangerDiagramDefinitions)) {
        if (-not (Test-RangerShouldRenderDiagram -Manifest $Manifest -Definition $definition -Mode $Mode)) {
            [void]$artifacts.Add((New-RangerArtifactRecord -Type 'diagram' -RelativePath (Join-Path 'diagrams' ((Get-RangerSafeName -Value $definition.Name) + '.drawio')) -Status skipped -Audience ($definition.Audience -join ',') -Reason 'Selection rules did not include this diagram for the current manifest.'))
            continue
        }

        if (-not (Test-RangerDiagramHasRequiredData -Manifest $Manifest -Definition $definition)) {
            [void]$artifacts.Add((New-RangerArtifactRecord -Type 'diagram' -RelativePath (Join-Path 'diagrams' ((Get-RangerSafeName -Value $definition.Name) + '.drawio')) -Status skipped -Audience ($definition.Audience -join ',') -Reason 'Required manifest domains were missing.'))
            continue
        }

        $model = Get-RangerDiagramModel -Manifest $Manifest -Definition $definition
        $baseName = "{0}-{1}" -f $prefix, (Get-RangerSafeName -Value $definition.Name)

        $drawIoPath = Join-Path -Path $diagramsRoot -ChildPath ($baseName + '.drawio')
        (ConvertTo-RangerDrawIoXml -Manifest $Manifest -Definition $definition -Model $model) | Set-Content -Path $drawIoPath -Encoding UTF8
        [void]$artifacts.Add((New-RangerArtifactRecord -Type 'diagram-drawio' -RelativePath ([System.IO.Path]::GetRelativePath($PackageRoot, $drawIoPath)) -Status generated -Audience ($definition.Audience -join ',')))

        if ('svg' -in $Formats) {
            $svgContent = ConvertTo-RangerSvgDiagram -Manifest $Manifest -Definition $definition -Model $model
            if ($null -ne $svgContent) {
                $svgPath = Join-Path -Path $diagramsRoot -ChildPath ($baseName + '.svg')
                $svgContent | Set-Content -Path $svgPath -Encoding UTF8
                [void]$artifacts.Add((New-RangerArtifactRecord -Type 'diagram-svg' -RelativePath ([System.IO.Path]::GetRelativePath($PackageRoot, $svgPath)) -Status generated -Audience ($definition.Audience -join ',')))
            } else {
                [void]$artifacts.Add((New-RangerArtifactRecord -Type 'diagram-svg' -RelativePath (Join-Path 'diagrams' ($baseName + '.svg')) -Status skipped -Audience ($definition.Audience -join ',') -Reason 'Insufficient data to produce a useful diagram.'))
            }
        }
    }

    return @($artifacts)
}

function Test-RangerShouldRenderDiagram {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

        [Parameter(Mandatory = $true)]
        [object]$Definition,

        [string]$Mode
    )

    if ($Definition.Tier -eq 'baseline') {
        return $true
    }

    if ($Mode -eq 'as-built') {
        return $true
    }

    $name = $Definition.Name
    switch ($name) {
        'topology-variant-map' { return $Manifest.topology.deploymentType -in @('rack-aware', 'multi-rack', 'switchless') }
        'identity-secret-flow' { return $Manifest.topology.identityMode -eq 'local-key-vault' }
        'monitoring-telemetry-flow' { return Test-RangerDomainPopulated -Value $Manifest.domains.monitoring }
        'connectivity-dependency-map' { return Test-RangerDomainPopulated -Value $Manifest.domains.networking.proxy }
        'identity-access-surface' { return Test-RangerDomainPopulated -Value $Manifest.domains.identitySecurity }
        'monitoring-health-heatmap' { return @($Manifest.findings | Where-Object { $_.severity -in @('critical', 'warning') }).Count -gt 0 }
        'oem-firmware-posture' { return Test-RangerDomainPopulated -Value $Manifest.domains.hardware }
        'backup-recovery-map' { return @($Manifest.domains.azureIntegration.backup).Count -gt 0 }
        'management-plane-tooling' { return Test-RangerDomainPopulated -Value $Manifest.domains.managementTools }
        'workload-family-placement' { return @($Manifest.domains.virtualMachines.workloadFamilies).Count -gt 0 }
        'fabric-map' { return $Manifest.topology.deploymentType -eq 'rack-aware' }
        'disconnected-control-plane' { return $Manifest.topology.controlPlaneMode -eq 'disconnected' }
        default { return $false }
    }
}

function Test-RangerDiagramHasRequiredData {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

        [Parameter(Mandatory = $true)]
        [object]$Definition
    )

    foreach ($required in @($Definition.Required)) {
        if (-not (Test-RangerDomainPopulated -Value $Manifest.domains[$required])) {
            return $false
        }
    }

    return $true
}

function Get-RangerDiagramModel {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

        [Parameter(Mandatory = $true)]
        [object]$Definition
    )

    $nodes = New-Object System.Collections.ArrayList
    $edges = New-Object System.Collections.ArrayList
    $clusterName = if (-not [string]::IsNullOrWhiteSpace($Manifest.target.clusterName)) { $Manifest.target.clusterName } else { $Manifest.target.environmentLabel }
    [void]$nodes.Add([ordered]@{ id = 'cluster'; label = $clusterName; kind = 'cluster'; detail = $Manifest.topology.deploymentType; group = 'platform' })

    switch ($Definition.Name) {
        'physical-architecture' {
            foreach ($node in @($Manifest.domains.clusterNode.nodes)) {
                $nodeId = 'node-' + (Get-RangerSafeName -Value $node.name)
                [void]$nodes.Add([ordered]@{ id = $nodeId; label = $node.name; kind = 'node'; detail = ('{0} | {1}' -f $node.state, $node.model); group = 'platform' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $nodeId; label = $node.state })
            }

            foreach ($endpoint in @($Manifest.domains.oemIntegration.endpoints)) {
                $endpointId = 'bmc-' + (Get-RangerSafeName -Value $endpoint.host)
                [void]$nodes.Add([ordered]@{ id = $endpointId; label = $endpoint.host; kind = 'bmc'; detail = $endpoint.node; group = 'management' })
                [void]$edges.Add([ordered]@{ source = $endpointId; target = ('node-' + (Get-RangerSafeName -Value $endpoint.node)); label = 'manages' })
            }
        }
        'logical-network-topology' {
            foreach ($network in @($Manifest.domains.clusterNode.networks)) {
                $networkId = 'network-' + (Get-RangerSafeName -Value $network.name)
                [void]$nodes.Add([ordered]@{ id = $networkId; label = $network.name; kind = 'network'; detail = ('role {0}' -f $network.role); group = 'network' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $networkId; label = $network.role })
            }

            foreach ($adapter in @($Manifest.domains.networking.adapters | Select-Object -First 10)) {
                $adapterId = 'adapter-' + (Get-RangerSafeName -Value $adapter.name)
                [void]$nodes.Add([ordered]@{ id = $adapterId; label = $adapter.name; kind = 'adapter'; detail = ('{0} | {1}' -f $adapter.status, $adapter.linkSpeed); group = 'network' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $adapterId; label = 'adapter' })
            }
        }
        'storage-architecture' {
            foreach ($pool in @($Manifest.domains.storage.pools)) {
                $poolId = 'pool-' + (Get-RangerSafeName -Value $pool.friendlyName)
                [void]$nodes.Add([ordered]@{ id = $poolId; label = $pool.friendlyName; kind = 'storage'; detail = ('{0} | {1} GiB' -f $pool.healthStatus, [math]::Round(($pool.size / 1GB), 2)); group = 'storage' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $poolId; label = $pool.healthStatus })
            }
            foreach ($csv in @($Manifest.domains.storage.csvs)) {
                $csvId = 'csv-' + (Get-RangerSafeName -Value $csv.name)
                [void]$nodes.Add([ordered]@{ id = $csvId; label = $csv.name; kind = 'volume'; detail = $csv.state; group = 'storage' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $csvId; label = $csv.state })
            }
            foreach ($disk in @($Manifest.domains.storage.physicalDisks | Select-Object -First 8)) {
                $diskId = 'disk-' + (Get-RangerSafeName -Value $disk.friendlyName)
                [void]$nodes.Add([ordered]@{ id = $diskId; label = $disk.friendlyName; kind = 'disk'; detail = ('{0} | {1}' -f $disk.mediaType, $disk.healthStatus); group = 'storage' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $diskId; label = 'physical disk' })
            }
        }
        'vm-placement-map' {
            foreach ($vm in @($Manifest.domains.virtualMachines.inventory)) {
                $vmId = 'vm-' + (Get-RangerSafeName -Value $vm.name)
                [void]$nodes.Add([ordered]@{ id = $vmId; label = $vm.name; kind = 'vm'; detail = ('{0} | {1} MB' -f $vm.state, $vm.memoryAssignedMb); group = 'workload' })
                [void]$edges.Add([ordered]@{ source = ('node-' + (Get-RangerSafeName -Value $vm.hostNode)); target = $vmId; label = $vm.state })
            }
        }
        'azure-arc-integration' {
            foreach ($resource in @($Manifest.domains.azureIntegration.resources | Select-Object -First 10)) {
                $resourceId = 'az-' + (Get-RangerSafeName -Value $resource.name)
                [void]$nodes.Add([ordered]@{ id = $resourceId; label = $resource.name; kind = 'azure'; detail = ('{0} | {1}' -f $resource.resourceType, $resource.location); group = 'azure' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $resourceId; label = $resource.resourceType })
            }
        }
        'workload-services-map' {
            foreach ($family in @($Manifest.domains.virtualMachines.workloadFamilies)) {
                $familyId = 'workload-' + (Get-RangerSafeName -Value $family.name)
                [void]$nodes.Add([ordered]@{ id = $familyId; label = $family.name; kind = 'workload'; detail = ('count {0}' -f $family.count); group = 'workload' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $familyId; label = $family.count })
            }
            foreach ($service in @($Manifest.domains.azureIntegration.services | Select-Object -First 8)) {
                $serviceId = 'service-' + (Get-RangerSafeName -Value $service.name)
                [void]$nodes.Add([ordered]@{ id = $serviceId; label = $service.name; kind = 'service'; detail = ('count {0}' -f $service.count); group = 'azure' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $serviceId; label = $service.category })
            }
        }
        'topology-variant-map' {
            foreach ($marker in @($Manifest.topology.variantMarkers | Select-Object -Unique)) {
                $markerId = 'variant-' + (Get-RangerSafeName -Value $marker)
                [void]$nodes.Add([ordered]@{ id = $markerId; label = $marker; kind = 'variant'; detail = 'applies'; group = 'platform' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $markerId; label = 'variant' })
            }
        }
        'identity-secret-flow' {
            foreach ($node in @($Manifest.domains.identitySecurity.nodes)) {
                $identityId = 'identity-' + (Get-RangerSafeName -Value $node.node)
                [void]$nodes.Add([ordered]@{ id = $identityId; label = $node.node; kind = 'identity'; detail = ('domain joined: {0}' -f $node.partOfDomain); group = 'identity' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $identityId; label = 'trust' })
            }
            foreach ($policy in @($Manifest.domains.azureIntegration.policy | Select-Object -First 5)) {
                $policyId = 'policy-' + (Get-RangerSafeName -Value $policy.name)
                [void]$nodes.Add([ordered]@{ id = $policyId; label = $policy.name; kind = 'policy'; detail = $policy.enforcementMode; group = 'azure' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $policyId; label = 'policy' })
            }
        }
        'monitoring-telemetry-flow' {
            $monitoringResources = @(@($Manifest.domains.monitoring.ama) + @($Manifest.domains.monitoring.dcr) + @($Manifest.domains.monitoring.alerts) | Select-Object -First 12)
            foreach ($resource in $monitoringResources) {
                $resourceId = 'monitor-' + (Get-RangerSafeName -Value $resource.name)
                [void]$nodes.Add([ordered]@{ id = $resourceId; label = $resource.name; kind = 'monitor'; detail = $resource.resourceType; group = 'management' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $resourceId; label = 'telemetry' })
            }
        }
        'connectivity-dependency-map' {
            foreach ($proxy in @($Manifest.domains.networking.proxy | Select-Object -First 10)) {
                $proxyId = 'proxy-' + (Get-RangerSafeName -Value $proxy.node)
                [void]$nodes.Add([ordered]@{ id = $proxyId; label = $proxy.node; kind = 'connectivity'; detail = $proxy.value; group = 'network' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $proxyId; label = 'proxy path' })
            }
            foreach ($fwProfile in @($Manifest.domains.networking.firewall | Select-Object -First 10)) {
                $profileId = 'fw-' + (Get-RangerSafeName -Value $fwProfile.node)
                [void]$nodes.Add([ordered]@{ id = $profileId; label = $fwProfile.node; kind = 'firewall'; detail = ('profiles {0}' -f @($fwProfile.profiles).Count); group = 'network' })
                [void]$edges.Add([ordered]@{ source = $profileId; target = 'cluster'; label = 'host firewall' })
            }
        }
        'identity-access-surface' {
            foreach ($adminSet in @($Manifest.domains.identitySecurity.localAdmins | Select-Object -First 10)) {
                $adminId = 'admins-' + (Get-RangerSafeName -Value $adminSet.node)
                [void]$nodes.Add([ordered]@{ id = $adminId; label = $adminSet.node; kind = 'identity'; detail = ('admin members {0}' -f @($adminSet.members).Count); group = 'identity' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $adminId; label = 'local admin surface' })
            }
        }
        'monitoring-health-heatmap' {
            foreach ($node in @($Manifest.domains.clusterNode.nodes)) {
                $findingCount = @($Manifest.findings | Where-Object { @($_.affectedComponents) -contains $node.name }).Count
                [void]$nodes.Add([ordered]@{ id = 'heat-' + (Get-RangerSafeName -Value $node.name); label = $node.name; kind = 'heat'; detail = ('findings {0}' -f $findingCount); group = 'management'; severity = if ($findingCount -gt 1) { 'warning' } else { 'good' } })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = ('heat-' + (Get-RangerSafeName -Value $node.name)); label = 'health' })
            }
        }
        'oem-firmware-posture' {
            foreach ($node in @($Manifest.domains.hardware.nodes)) {
                $hardwareId = 'hw-' + (Get-RangerSafeName -Value $node.node)
                [void]$nodes.Add([ordered]@{ id = $hardwareId; label = $node.node; kind = 'hardware'; detail = ('bios {0}' -f $node.biosVersion); group = 'hardware' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $hardwareId; label = 'hardware' })
            }
            foreach ($mgmt in @($Manifest.domains.oemIntegration.managementPosture | Select-Object -First 10)) {
                $mgmtId = 'oem-' + (Get-RangerSafeName -Value $mgmt.node)
                [void]$nodes.Add([ordered]@{ id = $mgmtId; label = $mgmt.node; kind = 'bmc'; detail = $mgmt.managerFirmwareVersion; group = 'hardware' })
                [void]$edges.Add([ordered]@{ source = $mgmtId; target = ('hw-' + (Get-RangerSafeName -Value $mgmt.node)); label = 'firmware posture' })
            }
        }
        'backup-recovery-map' {
            $recoveryResources = @(@($Manifest.domains.azureIntegration.backup) + @($Manifest.domains.azureIntegration.update) | Select-Object -First 10)
            foreach ($resource in $recoveryResources) {
                $resourceId = 'recover-' + (Get-RangerSafeName -Value $resource.name)
                [void]$nodes.Add([ordered]@{ id = $resourceId; label = $resource.name; kind = 'recovery'; detail = $resource.resourceType; group = 'azure' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $resourceId; label = 'continuity' })
            }
        }
        'management-plane-tooling' {
            foreach ($tool in @($Manifest.domains.managementTools.tools | Select-Object -First 10)) {
                $toolId = 'tool-' + (Get-RangerSafeName -Value $tool.name)
                [void]$nodes.Add([ordered]@{ id = $toolId; label = $tool.name; kind = 'management'; detail = $tool.status; group = 'management' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $toolId; label = 'management' })
            }
        }
        'workload-family-placement' {
            foreach ($family in @($Manifest.domains.virtualMachines.workloadFamilies)) {
                $familyId = 'family-' + (Get-RangerSafeName -Value $family.name)
                [void]$nodes.Add([ordered]@{ id = $familyId; label = $family.name; kind = 'workload'; detail = ('family count {0}' -f $family.count); group = 'workload' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $familyId; label = 'family' })
            }
            foreach ($placement in @($Manifest.domains.virtualMachines.placement | Select-Object -First 10)) {
                $placementNode = 'place-' + (Get-RangerSafeName -Value $placement.vm)
                [void]$nodes.Add([ordered]@{ id = $placementNode; label = $placement.vm; kind = 'vm'; detail = $placement.hostNode; group = 'workload' })
                [void]$edges.Add([ordered]@{ source = ('family-' + (Get-RangerSafeName -Value (($Manifest.domains.virtualMachines.workloadFamilies | Select-Object -First 1).name))); target = $placementNode; label = $placement.state })
            }
        }
        'fabric-map' {
            foreach ($faultDomain in @($Manifest.domains.clusterNode.faultDomains | Select-Object -First 10)) {
                $faultId = 'fd-' + (Get-RangerSafeName -Value $faultDomain.name)
                [void]$nodes.Add([ordered]@{ id = $faultId; label = $faultDomain.name; kind = 'fabric'; detail = $faultDomain.location; group = 'network' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $faultId; label = $faultDomain.faultDomainType })
            }
        }
        'disconnected-control-plane' {
            [void]$nodes.Add([ordered]@{ id = 'ops'; label = 'Disconnected operations'; kind = 'control'; detail = 'local control plane'; group = 'management' })
            [void]$edges.Add([ordered]@{ source = 'cluster'; target = 'ops'; label = 'operates' })
            foreach ($node in @($Manifest.domains.identitySecurity.nodes | Select-Object -First 10)) {
                $nodeId = 'local-' + (Get-RangerSafeName -Value $node.node)
                [void]$nodes.Add([ordered]@{ id = $nodeId; label = $node.node; kind = 'identity'; detail = ('domain joined: {0}' -f $node.partOfDomain); group = 'identity' })
                [void]$edges.Add([ordered]@{ source = 'ops'; target = $nodeId; label = 'trust boundary' })
            }
        }
        default {
            foreach ($required in @($Definition.Required)) {
                $requiredId = 'domain-' + (Get-RangerSafeName -Value $required)
                [void]$nodes.Add([ordered]@{ id = $requiredId; label = $required; kind = 'domain'; detail = $Definition.Title; group = 'platform' })
                [void]$edges.Add([ordered]@{ source = 'cluster'; target = $requiredId; label = $Definition.Title })
            }
        }
    }

    return [ordered]@{
        nodes = @($nodes)
        edges = @($edges)
    }
}

function ConvertTo-RangerDrawIoXml {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

        [Parameter(Mandatory = $true)]
        [object]$Definition,

        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Model
    )

    $cells    = New-Object System.Collections.Generic.List[string]
    $cells.Add('<mxCell id="0" />')
    $cells.Add('<mxCell id="1" parent="0" />')

    # Group layout with labelled swim-lane containers (#140)
    $groupColumns = [ordered]@{ platform = 40; storage = 320; network = 600; workload = 880; azure = 1160; management = 1440; identity = 1720; hardware = 2000 }
    $groupTitles  = @{ platform = 'Platform'; storage = 'Storage'; network = 'Network'; workload = 'Workload'; azure = 'Azure'; management = 'Management'; identity = 'Identity'; hardware = 'Hardware' }
    $groupFills   = @{ platform = '#f0f9ff'; storage = '#f1f5f9'; network = '#fffbeb'; workload = '#fefce8'; azure = '#ecfeff'; management = '#f5f3ff'; identity = '#fff1f2'; hardware = '#f0fdf4' }
    $groupStrokes = @{ platform = '#bae6fd'; storage = '#cbd5e1'; network = '#fde68a'; workload = '#fef08a'; azure = '#a5f3fc'; management = '#ddd6fe'; identity = '#fecdd3'; hardware = '#bbf7d0' }
    $groupOffsets = @{}
    $nodePositions = @{}
    $containerIds  = @{}
    $containerIndex = 100

    # First pass: assign positions
    foreach ($node in @($Model.nodes)) {
        if ([string]::IsNullOrWhiteSpace($node.id)) { continue }
        $grp = if ($node.group -and $groupColumns.Contains([string]$node.group)) { [string]$node.group } else { 'platform' }
        if (-not $groupOffsets.ContainsKey($grp)) { $groupOffsets[$grp] = 50 }
        $nodePositions[$node.id] = [ordered]@{ x = $groupColumns[$grp]; y = $groupOffsets[$grp]; group = $grp }
        $groupOffsets[$grp] += 90
    }

    # Emit group container cells
    foreach ($grp in $groupColumns.Keys) {
        $grpNodes = @($nodePositions.Values | Where-Object { $_.group -eq $grp })
        if ($grpNodes.Count -eq 0) { continue }
        $minY  = ($grpNodes | Measure-Object -Property y -Minimum).Minimum - 30
        $maxY  = ($grpNodes | Measure-Object -Property y -Maximum).Maximum + 80
        $cid   = "container-$grp"
        $containerIds[$grp] = $cid
        $containerIndex++
        $fill   = if ($groupFills.ContainsKey($grp))   { $groupFills[$grp] }   else { '#f8fafc' }
        $stroke = if ($groupStrokes.ContainsKey($grp)) { $groupStrokes[$grp] } else { '#e2e8f0' }
        $title  = if ($groupTitles.ContainsKey($grp))  { $groupTitles[$grp] }  else { $grp }
        $cells.Add(('<mxCell id="{0}" value="{1}" style="swimlane;startSize=24;fillColor={2};strokeColor={3};fontStyle=1;fontSize=11;rounded=1;" vertex="1" parent="1"><mxGeometry x="{4}" y="{5}" width="240" height="{6}" as="geometry" /></mxCell>' -f $cid, [System.Security.SecurityElement]::Escape($title), $fill, $stroke, ($groupColumns[$grp] - 8), $minY, ($maxY - $minY)))
    }

    # Emit node cells (parented to their container)
    foreach ($node in @($Model.nodes)) {
        if ([string]::IsNullOrWhiteSpace($node.id) -or -not $nodePositions.ContainsKey($node.id)) { continue }
        $pos   = $nodePositions[$node.id]
        $grp   = $pos.group
        $relX  = 8
        $relY  = $pos.y - (($nodePositions.Values | Where-Object { $_.group -eq $grp } | Measure-Object -Property y -Minimum).Minimum - 30) + 24

        $labelText = if ($node.detail) { '{0}&#xa;{1}' -f $node.label, $node.detail } else { [string]$node.label }
        $lbl   = [System.Security.SecurityElement]::Escape($labelText)
        $style = switch ($node.kind) {
            'cluster'    { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#dbeafe;strokeColor=#2563eb;fontStyle=1;' }
            'node'       { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#dcfce7;strokeColor=#16a34a;' }
            'azure'      { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#cffafe;strokeColor=#0f766e;' }
            'storage'    { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#e2e8f0;strokeColor=#475569;' }
            'volume'     { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#f1f5f9;strokeColor=#64748b;' }
            'disk'       { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#f1f5f9;strokeColor=#94a3b8;' }
            'network'    { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#fde68a;strokeColor=#ca8a04;' }
            'adapter'    { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#fefce8;strokeColor=#a16207;' }
            'vm'         { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#fef3c7;strokeColor=#d97706;' }
            'workload'   { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#fefce8;strokeColor=#ca8a04;' }
            'management' { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#ede9fe;strokeColor=#7c3aed;' }
            'identity'   { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#fee2e2;strokeColor=#dc2626;' }
            'policy'     { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#fce7f3;strokeColor=#db2777;' }
            'bmc'        { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#e0f2fe;strokeColor=#0369a1;' }
            'hardware'   { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#f0fdf4;strokeColor=#15803d;' }
            'monitor'    { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#ecfdf5;strokeColor=#059669;' }
            'heat'       { if ($node.severity -eq 'warning') { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#fed7aa;strokeColor=#c2410c;' } else { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#dcfce7;strokeColor=#16a34a;' } }
            default      { 'rounded=1;whiteSpace=wrap;html=1;fillColor=#f8fafc;strokeColor=#64748b;' }
        }
        $parentId = if ($containerIds.ContainsKey($grp)) { $containerIds[$grp] } else { '1' }
        $cells.Add(('<mxCell id="{0}" value="{1}" style="{2}" vertex="1" parent="{3}"><mxGeometry x="{4}" y="{5}" width="224" height="62" as="geometry" /></mxCell>' -f $node.id, $lbl, $style, $parentId, $relX, $relY))
    }

    # Emit edges (always parented to root)
    $edgeIndex = 0
    foreach ($edge in @($Model.edges)) {
        $edgeIndex++
        $edgeLbl = [System.Security.SecurityElement]::Escape([string]$edge.label)
        $cells.Add(('<mxCell id="edge-{0}" value="{1}" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;endArrow=block;endFill=1;strokeColor=#94a3b8;" edge="1" parent="1" source="{2}" target="{3}"><mxGeometry relative="1" as="geometry" /></mxCell>' -f $edgeIndex, $edgeLbl, $edge.source, $edge.target))
    }

    @"
<mxfile host="app.diagrams.net" modified="$($Manifest.run.endTimeUtc)" agent="AzureLocalRanger" version="$($Manifest.run.toolVersion)">
  <diagram name="$([System.Security.SecurityElement]::Escape($Definition.Title))">
    <mxGraphModel dx="1330" dy="844" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1700" pageHeight="1100" math="0" shadow="0">
      <root>
        $($cells -join [Environment]::NewLine)
      </root>
    </mxGraphModel>
  </diagram>
</mxfile>
"@

}

function ConvertTo-RangerSvgDiagram {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

        [Parameter(Mandatory = $true)]
        [object]$Definition,

        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Model
    )

    # Skip near-empty diagrams — fewer than 2 non-root nodes produces no useful output (#140)
    $nonRootNodes = @($Model.nodes | Where-Object { $_.id -ne 'cluster' })
    if ($nonRootNodes.Count -lt 1) {
        return $null
    }

    $boxes      = New-Object System.Collections.Generic.List[string]
    $connLines  = New-Object System.Collections.Generic.List[string]
    $labels     = New-Object System.Collections.Generic.List[string]
    $containers = New-Object System.Collections.Generic.List[string]

    # Group layout: each group gets a labelled container background (#140)
    $groupColumns = [ordered]@{ platform = 20; storage = 280; network = 540; workload = 800; azure = 1060; management = 1320; identity = 1580; hardware = 1840 }
    $groupLabels  = @{ platform = 'Platform'; storage = 'Storage'; network = 'Network'; workload = 'Workload'; azure = 'Azure'; management = 'Management'; identity = 'Identity'; hardware = 'Hardware' }
    $groupColors  = @{ platform = '#f0f9ff'; storage = '#f1f5f9'; network = '#fffbeb'; workload = '#fefce8'; azure = '#ecfeff'; management = '#f5f3ff'; identity = '#fff1f2'; hardware = '#f8fafc' }
    $groupBorder  = @{ platform = '#bae6fd'; storage = '#cbd5e1'; network = '#fde68a'; workload = '#fef08a'; azure = '#a5f3fc'; management = '#ddd6fe'; identity = '#fecdd3'; hardware = '#e2e8f0' }
    $groupOffsets = @{}
    $positions    = @{}

    # First pass: assign positions
    foreach ($node in @($Model.nodes)) {
        if ([string]::IsNullOrWhiteSpace($node.id)) { continue }
        $grp = if ($node.group -and $groupColumns.Contains([string]$node.group)) { [string]$node.group } else { 'platform' }
        if (-not $groupOffsets.ContainsKey($grp)) { $groupOffsets[$grp] = 80 }
        $positions[$node.id] = [ordered]@{ x = $groupColumns[$grp]; y = $groupOffsets[$grp]; group = $grp }
        $groupOffsets[$grp] += 86
    }

    # Draw group containers (sized to fit their contents)
    foreach ($grp in $groupColumns.Keys) {
        $grpNodes = @($positions.Values | Where-Object { $_.group -eq $grp })
        if ($grpNodes.Count -eq 0) { continue }
        $minY = ($grpNodes | Measure-Object -Property y -Minimum).Minimum - 10
        $maxY = ($grpNodes | Measure-Object -Property y -Maximum).Maximum + 76
        $cx = $groupColumns[$grp] - 8
        $cy = $minY
        $cw = 240
        $ch = $maxY - $minY
        $fill   = if ($groupColors.ContainsKey($grp)) { $groupColors[$grp] } else { '#f8fafc' }
        $stroke = if ($groupBorder.ContainsKey($grp)) { $groupBorder[$grp] } else { '#e2e8f0' }
        $glabel = if ($groupLabels.ContainsKey($grp)) { $groupLabels[$grp] } else { $grp }
        $containers.Add(("<rect x='$cx' y='$cy' width='$cw' height='$ch' rx='12' fill='$fill' stroke='$stroke' stroke-dasharray='4 2' />"))
        $containers.Add(("<text x='$($cx + 8)' y='$($cy + 16)' font-family=""Segoe UI, Arial"" font-size=""11"" font-weight=""600"" fill=""#64748b"">$([System.Security.SecurityElement]::Escape($glabel))</text>"))
    }

    # Draw nodes
    foreach ($node in @($Model.nodes)) {
        if ([string]::IsNullOrWhiteSpace($node.id) -or -not $positions.ContainsKey($node.id)) { continue }
        $pos  = $positions[$node.id]
        $x    = $pos.x
        $y    = $pos.y
        $fill = switch ($node.kind) {
            'cluster'     { '#dbeafe' }
            'node'        { '#dcfce7' }
            'azure'       { '#cffafe' }
            'vm'          { '#fef3c7' }
            'storage'     { '#e2e8f0' }
            'volume'      { '#f1f5f9' }
            'disk'        { '#f1f5f9' }
            'network'     { '#fde68a' }
            'adapter'     { '#fefce8' }
            'management'  { '#ede9fe' }
            'identity'    { '#fee2e2' }
            'policy'      { '#fce7f3' }
            'bmc'         { '#e0f2fe' }
            'hardware'    { '#f0fdf4' }
            'heat'        { if ($node.severity -eq 'warning') { '#fed7aa' } else { '#dcfce7' } }
            'monitor'     { '#ecfdf5' }
            'workload'    { '#fefce8' }
            'service'     { '#cffafe' }
            default       { '#f8fafc' }
        }
        $stroke = switch ($node.kind) {
            'cluster'  { '#2563eb' }
            'node'     { '#16a34a' }
            'azure'    { '#0f766e' }
            'storage'  { '#475569' }
            'network'  { '#ca8a04' }
            'vm'       { '#d97706' }
            'identity' { '#dc2626' }
            'bmc'      { '#0369a1' }
            'hardware' { '#15803d' }
            default    { '#64748b' }
        }
        $strokeW = if ($node.kind -eq 'cluster') { '2' } else { '1.5' }
        $boxes.Add(("<rect x='$x' y='$y' width='224' height='58' rx='10' fill='$fill' stroke='$stroke' stroke-width='$strokeW' />"))
        $labels.Add(("<text x='$($x + 10)' y='$($y + 24)' font-family=""Segoe UI, Arial"" font-size=""13"" font-weight=""600"" fill=""#0f172a"">$([System.Security.SecurityElement]::Escape([string]$node.label))</text>"))
        if ($node.detail) {
            $detailText = [string]$node.detail
            if ($detailText.Length -gt 34) { $detailText = $detailText.Substring(0, 31) + '…' }
            $labels.Add(("<text x='$($x + 10)' y='$($y + 42)' font-family=""Segoe UI, Arial"" font-size=""11"" fill=""#475569"">$([System.Security.SecurityElement]::Escape($detailText))</text>"))
        }
    }

    # Draw edges
    foreach ($edge in @($Model.edges)) {
        if ([string]::IsNullOrWhiteSpace($edge.source) -or [string]::IsNullOrWhiteSpace($edge.target)) { continue }
        if (-not $positions.ContainsKey($edge.source) -or -not $positions.ContainsKey($edge.target)) { continue }
        $src = $positions[$edge.source]
        $tgt = $positions[$edge.target]
        $x1  = $src.x + 224
        $y1  = $src.y + 29
        $x2  = $tgt.x
        $y2  = $tgt.y + 29
        # Use a mid-point curve for cleaner routing
        $mx  = [math]::Round(($x1 + $x2) / 2, 0)
        $connLines.Add(("<path d='M $x1 $y1 C $mx $y1 $mx $y2 $x2 $y2' fill='none' stroke='#94a3b8' stroke-width='1.5' marker-end='url(#arrow)' />"))
        if ($edge.label) {
            $edgeLabelX = [math]::Round(($x1 + $x2) / 2, 0)
            $edgeLabelY = [math]::Round(($y1 + $y2) / 2 - 5, 0)
            $labels.Add(("<text x='$edgeLabelX' y='$edgeLabelY' font-family=""Segoe UI, Arial"" font-size=""10"" fill=""#64748b"" text-anchor=""middle"">$([System.Security.SecurityElement]::Escape([string]$edge.label))</text>"))
        }
    }

    # Calculate canvas height dynamically
    $maxY = 900
    if ($groupOffsets.Count -gt 0) {
        $maxGroupY = ($groupOffsets.Values | Measure-Object -Maximum).Maximum
        $maxY = [math]::Max(900, $maxGroupY + 60)
    }
    $canvasW = 2080

    @"
<svg xmlns="http://www.w3.org/2000/svg" width="$canvasW" height="$maxY" viewBox="0 0 $canvasW $maxY">
  <defs>
    <marker id="arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
      <polygon points="0 0, 8 3, 0 6" fill="#94a3b8" />
    </marker>
  </defs>
  <rect width="$canvasW" height="$maxY" fill="#f8fafc" />
  <rect x="0" y="0" width="$canvasW" height="62" fill="#1e3a5f" />
  <text x="20" y="34" font-family="Segoe UI, Arial" font-size="20" font-weight="700" fill="#ffffff">$([System.Security.SecurityElement]::Escape($Definition.Title))</text>
  <text x="20" y="54" font-family="Segoe UI, Arial" font-size="11" fill="#93c5fd">$([System.Security.SecurityElement]::Escape($Manifest.target.environmentLabel)) | $([System.Security.SecurityElement]::Escape($Manifest.run.endTimeUtc)) | Ranger $([System.Security.SecurityElement]::Escape($Manifest.run.toolVersion))</text>
  $($containers -join [Environment]::NewLine)
  $($connLines -join [Environment]::NewLine)
  $($boxes -join [Environment]::NewLine)
  $($labels -join [Environment]::NewLine)
</svg>
"@

}