private/Find-WtwContrastColor.ps1

function Find-WtwContrastColor {
    <#
    .SYNOPSIS
        Pick a color that is visibly different from other assignments, with hue
        variety (not clustered in teal/cyan).
    .DESCRIPTION
        Builds a candidate set from the palette plus 15 evenly spaced vivid hues
        and supplemental HSL samples. Filters by minimum perceptual distance to
        repulsion points (other assignments + current color when re-rolling).
        Stratifies survivors by hue into 15 bins and picks uniformly at random
        among the best candidate per bin — so `wtw color random` cycles through
        reds, oranges, purples, etc., not only blue-green.
    #>

    param(
        [PSObject] $Colors,
        [string] $ExcludeKey
    )

    function Get-WtwHueBin {
        param(
            [string] $Hex,
            [int] $BinCount
        )
        $rgb = ConvertTo-WtwRgbArray $Hex
        $r = $rgb[0] / 255.0
        $g = $rgb[1] / 255.0
        $b = $rgb[2] / 255.0
        $max = [math]::Max($r, [math]::Max($g, $b))
        $min = [math]::Min($r, [math]::Min($g, $b))
        $d = $max - $min
        $h = 0.0
        if ($d -ge 1e-9) {
            if ([math]::Abs($max - $r) -lt 1e-9) {
                $h = ((60 * (($g - $b) / $d)) + 360) % 360
            } elseif ([math]::Abs($max - $g) -lt 1e-9) {
                $h = (60 * (($b - $r) / $d) + 120) % 360
            } else {
                $h = (60 * (($r - $g) / $d) + 240) % 360
            }
        }
        $w = 360.0 / $BinCount
        return [int]([math]::Floor($h / $w)) % $BinCount
    }

    $assigned = @()
    $excludedColor = $null
    foreach ($prop in $Colors.assignments.PSObject.Properties) {
        if ($ExcludeKey -and $prop.Name -eq $ExcludeKey) {
            $excludedColor = $prop.Value
            continue
        }
        $assigned += $prop.Value
    }

    if ($assigned.Count -eq 0 -and -not $excludedColor) {
        $cands = @($Colors.palette)
        return $cands | Get-Random
    }

    $repulsionSet = @($assigned)
    if ($excludedColor) { $repulsionSet += $excludedColor }
    $assignedRgb = @()
    foreach ($hex in $repulsionSet) { $assignedRgb += , (ConvertTo-WtwRgbArray $hex) }

    $hueSlots = 15
    $step = 360.0 / $hueSlots

    # Dedupe candidates (case-insensitive hex)
    $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    $candidates = [System.Collections.Generic.List[string]]::new()

    foreach ($c in @($Colors.palette)) {
        if ($seen.Add($c)) { $candidates.Add($c) }
    }

    # 15 vivid, evenly spaced hues — guarantees red/orange/yellow/... not only cyan
    for ($i = 0; $i -lt $hueSlots; $i++) {
        $hx = Convert-HslToHex ($i * $step) 0.78 0.47
        if ($seen.Add($hx)) { $candidates.Add($hx) }
    }

    # Mid-step hues + second saturation/lightness ring for extra separation options
    for ($i = 0; $i -lt $hueSlots; $i++) {
        $hx = Convert-HslToHex (($i * $step) + ($step / 2)) 0.72 0.52
        if ($seen.Add($hx)) { $candidates.Add($hx) }
    }

    for ($h = 0; $h -lt 360; $h += 10) {
        $hx = Convert-HslToHex $h 0.70 0.46
        if ($seen.Add($hx)) { $candidates.Add($hx) }
    }

    if ($excludedColor) {
        $exLower = $excludedColor.ToLower()
        $filteredList = [System.Collections.Generic.List[string]]::new()
        foreach ($cx in $candidates) {
            if ($cx.ToLower() -ne $exLower) { $filteredList.Add($cx) }
        }
        $candidates = $filteredList
    }

    function Get-MinDist {
        param([int[]] $Rgb)
        $minDist = [double]::MaxValue
        foreach ($a in $assignedRgb) {
            $d = Get-PerceptualDistance $Rgb $a
            if ($d -lt $minDist) { $minDist = $d }
        }
        return $minDist
    }

    function Build-StratifiedPool {
        param(
            [double] $MinSep
        )
        $scored = @()
        foreach ($c in $candidates) {
            $rgb = ConvertTo-WtwRgbArray $c
            $minDist = Get-MinDist $rgb
            if ($minDist -ge $MinSep) {
                $scored += @{ Color = $c; Score = $minDist; Bin = (Get-WtwHueBin $c $hueSlots) }
            }
        }
        if ($scored.Count -eq 0) { return @() }

        $byBin = @{}
        foreach ($row in $scored) {
            $b = $row.Bin
            if (-not $byBin.ContainsKey($b) -or $row.Score -gt $byBin[$b].Score) {
                $byBin[$b] = $row
            }
        }
        return @($byBin.Values | ForEach-Object { $_.Color })
    }

    foreach ($trySep in @(58, 45, 32, 20, 0)) {
        $pool = Build-StratifiedPool -MinSep $trySep
        if ($pool.Count -gt 0) {
            return $pool | Get-Random
        }
    }

    # Last resort: any candidate with best min-distance, no stratification
    $best = @()
    $bestScore = -1.0
    foreach ($c in $candidates) {
        $rgb = ConvertTo-WtwRgbArray $c
        $s = Get-MinDist $rgb
        if ($s -gt $bestScore) {
            $bestScore = $s
            $best = @($c)
        } elseif ([math]::Abs($s - $bestScore) -lt 1e-6) {
            $best += $c
        }
    }
    if ($best.Count -gt 0) {
        return $best | Get-Random
    }

    return $null
}