Public/Export-EGIGroupBlastRadiusSvg.ps1

function Export-EGIGroupBlastRadiusSvg {
    <#
    .SYNOPSIS
        Renders a Get-EGIGroupBlastRadius result as a standalone SVG relationship diagram.

    .DESCRIPTION
        Draws the group as a hub node in the center-left, and every downstream
        dependency (Conditional Access policies, license SKUs, app role
        assignments, PIM eligibility) as spoke nodes grouped into color-coded
        columns, connected back to the hub with curved lines.

        The output is a plain, self-contained .svg file - no external tools or
        libraries required. Open it directly in a browser, embed it in a wiki
        page, or attach it to a change-ticket as evidence of the blast radius
        before a rule change.

    .PARAMETER BlastRadius
        A result object from Get-EGIGroupBlastRadius.

    .PARAMETER Path
        Output file path, e.g. './reports/sales-de-blast-radius.svg'.

    .EXAMPLE
        Get-EGIGroupBlastRadius -GroupId $id | Export-EGIGroupBlastRadiusSvg -Path './report.svg'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [pscustomobject] $BlastRadius,

        [Parameter(Mandatory)]
        [string] $Path
    )

    # ---- Build the category list ---------------------------------------
    $categories = @(
        [pscustomobject]@{
            Name  = 'Conditional Access'
            Color = '#2563eb'
            Items = @($BlastRadius.ConditionalAccessPolicies | ForEach-Object {
                    "$($_.DisplayName) [$($_.Reference) / $($_.State)]"
                })
        }
        [pscustomobject]@{
            Name  = 'Licenses'
            Color = '#16a34a'
            Items = @($BlastRadius.AssignedLicenseSkuIds)
        }
        [pscustomobject]@{
            Name  = 'App role assignments'
            Color = '#d97706'
            Items = @($BlastRadius.AppRoleAssignments | ForEach-Object { $_.resourceDisplayName })
        }
        [pscustomobject]@{
            Name  = 'PIM eligibility'
            Color = '#dc2626'
            Items = @(if ($BlastRadius.PimEligibleRoleCount -gt 0) {
                    "$($BlastRadius.PimEligibleRoleCount) eligible role assignment(s)"
                })
        }
    )

    # ---- Layout constants ------------------------------------------------
    $colWidth   = 260
    $colGap     = 60
    $itemHeight = 34
    $itemGap    = 10
    $topMargin  = 60
    $hubWidth   = 220
    $hubHeight  = 70

    $colX = @(0..3) | ForEach-Object { 340 + $_ * ($colWidth + $colGap) }

    $maxItemsInAnyColumn = ($categories | ForEach-Object { [Math]::Max($_.Items.Count, 1) } | Measure-Object -Maximum).Maximum
    $height = [Math]::Max(($maxItemsInAnyColumn * ($itemHeight + $itemGap)) + $topMargin + 80, 260)
    $width = 340 + (4 * ($colWidth + $colGap))

    $hubY = [Math]::Round($height / 2 - $hubHeight / 2)
    $hubX = 20
    $hubCenterY = $hubY + ($hubHeight / 2)
    $hubRightEdgeX = $hubX + $hubWidth

    function ConvertTo-SafeXml {
        param([string] $Text)
        if ($null -eq $Text) { return '' }
        return [System.Security.SecurityElement]::Escape($Text)
    }

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine("<svg xmlns=`"http://www.w3.org/2000/svg`" viewBox=`"0 0 $width $height`" font-family=`"Segoe UI, Arial, sans-serif`">")
    [void]$sb.AppendLine("<rect x=`"0`" y=`"0`" width=`"$width`" height=`"$height`" fill=`"#ffffff`"/>")

    # Title + risk badge
    $title = ConvertTo-SafeXml "Blast radius: $($BlastRadius.DisplayName)"
    [void]$sb.AppendLine("<text x=`"20`" y=`"28`" font-size=`"18`" font-weight=`"600`" fill=`"#111827`">$title</text>")
    $riskColor = switch ($BlastRadius.RiskLevel) {
        'Critical' { '#dc2626' }
        'High'     { '#d97706' }
        'Medium'   { '#ca8a04' }
        'Low'      { '#16a34a' }
        default    { '#6b7280' }
    }
    [void]$sb.AppendLine("<text x=`"20`" y=`"48`" font-size=`"13`" fill=`"$riskColor`" font-weight=`"600`">Risk: $($BlastRadius.RiskLevel) | Total dependencies: $($BlastRadius.TotalDependencyCount)</text>")

    # Hub node (the group itself)
    [void]$sb.AppendLine("<rect x=`"$hubX`" y=`"$hubY`" width=`"$hubWidth`" height=`"$hubHeight`" rx=`"10`" fill=`"#eef2ff`" stroke=`"#4338ca`" stroke-width=`"1.5`"/>")
    [void]$sb.AppendLine("<text x=`"$($hubX + $hubWidth/2)`" y=`"$($hubY + $hubHeight/2 - 4)`" text-anchor=`"middle`" font-size=`"13`" font-weight=`"600`" fill=`"#312e81`">$(ConvertTo-SafeXml $BlastRadius.DisplayName)</text>")
    [void]$sb.AppendLine("<text x=`"$($hubX + $hubWidth/2)`" y=`"$($hubY + $hubHeight/2 + 16)`" text-anchor=`"middle`" font-size=`"11`" fill=`"#4338ca`">Dynamic group</text>")

    for ($c = 0; $c -lt $categories.Count; $c++) {
        $cat = $categories[$c]
        $x = $colX[$c]

        [void]$sb.AppendLine("<text x=`"$x`" y=`"$($topMargin - 20)`" font-size=`"13`" font-weight=`"600`" fill=`"$($cat.Color)`">$(ConvertTo-SafeXml $cat.Name) ($($cat.Items.Count))</text>")

        if ($cat.Items.Count -eq 0) {
            [void]$sb.AppendLine("<text x=`"$x`" y=`"$topMargin`" font-size=`"12`" fill=`"#9ca3af`">(none)</text>")
            continue
        }

        for ($r = 0; $r -lt $cat.Items.Count; $r++) {
            $y = $topMargin + $r * ($itemHeight + $itemGap)
            $labelRaw = [string]$cat.Items[$r]
            if ($labelRaw.Length -gt 34) { $labelRaw = $labelRaw.Substring(0, 31) + '...' }
            $label = ConvertTo-SafeXml $labelRaw

            $itemCenterY = $y + ($itemHeight / 2)
            $ctrlX = ($hubRightEdgeX + $x) / 2

            [void]$sb.AppendLine("<path d=`"M $hubRightEdgeX $hubCenterY Q $ctrlX $itemCenterY $x $itemCenterY`" fill=`"none`" stroke=`"$($cat.Color)`" stroke-width=`"1`" opacity=`"0.45`"/>")
            [void]$sb.AppendLine("<rect x=`"$x`" y=`"$y`" width=`"$colWidth`" height=`"$itemHeight`" rx=`"6`" fill=`"#ffffff`" stroke=`"$($cat.Color)`" stroke-width=`"1`"/>")
            [void]$sb.AppendLine("<text x=`"$($x + 10)`" y=`"$($itemCenterY + 4)`" font-size=`"12`" fill=`"#111827`">$label</text>")
        }
    }

    [void]$sb.AppendLine('</svg>')

    $sb.ToString() | Set-Content -Path $Path -Encoding utf8
    Write-Verbose "Wrote blast-radius SVG report to $Path"
    return (Get-Item -Path $Path)
}