Skip to content

colors.kt

src/jvmMain/kotlin/afield/graphics/color/colors.kt · 764 lines

package and imports — 60 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Graphics")

package afield.graphics.color

import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.times
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.gradient
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.round
import afield.math.autodiff.times
import afield.math.linalg.bar
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.threshold
import afield.runtime.execution.end
import afield.runtime.execution.go

import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.m
import afield.data.collections.r
import afield.data.collections.s
import afield.graphics.render.color
import afield.lang.stacksl.h
import afield.math.linalg.Vec4
import afield.math.linalg.bb
import afield.math.linalg.f
import afield.math.linalg.i
import afield.math.linalg.l
import afield.math.linalg.x
import afield.runtime.execution.start
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.ui.output.h
import afield.ui.output.ss
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cbrt
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt

Colour constructors for the stdlib. Everything here returns a Vec4 of sRGB-encoded components clamped to [0,1] with alpha last, so the results drop straight into FLine colours and uniforms.

rgb / rgba accept either 0..1 or 0..255 components — the scale is inferred per call from whether any component exceeds 1 — and rgb255 forces the 0..255 reading. hsl is HSL with the usual cube geometry, but the result is encoded through the sRGB transfer function on the way out, so it is not interchangeable with CSS hsl() — see hsl for what that costs. okhsl and okhsv are the Oklab-based equivalents; of the two only okhsl holds perceived lightness steady across hue, because okhsv scales v against the per-hue gamut cusp exactly as HSV always has. Hue may be given as a fraction of a turn or in degrees (magnitude above 1 is divided by 360) and is wrapped either way.

For sets: hueSetHsl, hueSetOkhsl and hueSetOkhsv (aliased hslSet, okhslSet, okhsvSet) space equally-separated hues around the wheel at a fixed saturation and lightness. paletteForBackground starts from the same okhsl ring but then walks each entry's lightness up or down until it clears a minimum WCAG contrast ratio against the supplied background, returning the highest-contrast candidate it found when it cannot reach the target.

source — 348 lines, from line 82
private data class LinearRgb(val r: Float, val g: Float, val b: Float)
private data class Oklab(val l: Float, val a: Float, val b: Float)
private data class LC(val l: Float, val c: Float)
private data class ST(val s: Float, val t: Float)
private data class Cs(val c0: Float, val cMid: Float, val cMax: Float)

private const val PI_F = 3.14159265358979323846f

private fun n(v: Number): Float = v.toFloat()
private fun clamp01(v: Float): Float = v.coerceIn(0f, 1f)
private fun wrap01(v: Float): Float {
    val w = v % 1f
    return if (w < 0f) w + 1f else w
}

private fun hue01(v: Number): Float {
    val h = n(v)
    return if (abs(h) > 1f) wrap01(h / 360f) else wrap01(h)
}

private fun srgbTransfer(linear: Float): Float =
    if (linear <= 0.0031308f) 12.92f * linear
    else 1.055f * linear.pow(1f / 2.4f) - 0.055f

private fun srgbTransferInv(srgb: Float): Float =
    if (srgb > 0.04045f) ((srgb + 0.055f) / 1.055f).pow(2.4f)
    else srgb / 12.92f

private fun linearSrgbToOklab(c: LinearRgb): Oklab {
    val l = 0.4122214708f * c.r + 0.5363325363f * c.g + 0.0514459929f * c.b
    val m = 0.2119034982f * c.r + 0.6806995451f * c.g + 0.1073969566f * c.b
    val s = 0.0883024619f * c.r + 0.2817188376f * c.g + 0.6299787005f * c.b

    val l_ = cbrt(l.toDouble()).toFloat()
    val m_ = cbrt(m.toDouble()).toFloat()
    val s_ = cbrt(s.toDouble()).toFloat()

    return Oklab(
        0.2104542553f * l_ + 0.7936177850f * m_ - 0.0040720468f * s_,
        1.9779984951f * l_ - 2.4285922050f * m_ + 0.4505937099f * s_,
        0.0259040371f * l_ + 0.7827717662f * m_ - 0.8086757660f * s_
    )
}

private fun oklabToLinearSrgb(c: Oklab): LinearRgb {
    val l_ = c.l + 0.3963377774f * c.a + 0.2158037573f * c.b
    val m_ = c.l - 0.1055613458f * c.a - 0.0638541728f * c.b
    val s_ = c.l - 0.0894841775f * c.a - 1.2914855480f * c.b

    val l = l_ * l_ * l_
    val m = m_ * m_ * m_
    val s = s_ * s_ * s_

    return LinearRgb(
        +4.0767416621f * l - 3.3077115913f * m + 0.2309699292f * s,
        -1.2684380046f * l + 2.6097574011f * m - 0.3413193965f * s,
        -0.0041960863f * l - 0.7034186147f * m + 1.7076147010f * s
    )
}

private fun computeMaxSaturation(a: Float, b: Float): Float {
    val k0: Float
    val k1: Float
    val k2: Float
    val k3: Float
    val k4: Float
    val wl: Float
    val wm: Float
    val ws: Float

    if (-1.88170328f * a - 0.80936493f * b > 1f) {
        k0 = +1.19086277f; k1 = +1.76576728f; k2 = +0.59662641f; k3 = +0.75515197f; k4 = +0.56771245f
        wl = +4.0767416621f; wm = -3.3077115913f; ws = +0.2309699292f
    } else if (1.81444104f * a - 1.19445276f * b > 1f) {
        k0 = +0.73956515f; k1 = -0.45954404f; k2 = +0.08285427f; k3 = +0.12541070f; k4 = +0.14503204f
        wl = -1.2684380046f; wm = +2.6097574011f; ws = -0.3413193965f
    } else {
        k0 = +1.35733652f; k1 = -0.00915799f; k2 = -1.15130210f; k3 = -0.50559606f; k4 = +0.00692167f
        wl = -0.0041960863f; wm = -0.7034186147f; ws = +1.7076147010f
    }

    var sat = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b

    val kl = +0.3963377774f * a + 0.2158037573f * b
    val km = -0.1055613458f * a - 0.0638541728f * b
    val ks = -0.0894841775f * a - 1.2914855480f * b

    run {
        val l_ = 1f + sat * kl
        val m_ = 1f + sat * km
        val s_ = 1f + sat * ks

        val l = l_ * l_ * l_
        val m = m_ * m_ * m_
        val s = s_ * s_ * s_

        val lDs = 3f * kl * l_ * l_
        val mDs = 3f * km * m_ * m_
        val sDs = 3f * ks * s_ * s_

        val lDs2 = 6f * kl * kl * l_
        val mDs2 = 6f * km * km * m_
        val sDs2 = 6f * ks * ks * s_

        val f = wl * l + wm * m + ws * s
        val f1 = wl * lDs + wm * mDs + ws * sDs
        val f2 = wl * lDs2 + wm * mDs2 + ws * sDs2

        sat -= f * f1 / (f1 * f1 - 0.5f * f * f2)
    }

    return sat
}

private fun findCusp(a: Float, b: Float): LC {
    val sCusp = computeMaxSaturation(a, b)
    val rgbAtMax = oklabToLinearSrgb(Oklab(1f, sCusp * a, sCusp * b))
    val lCusp = cbrt((1f / max(max(rgbAtMax.r, rgbAtMax.g), rgbAtMax.b)).toDouble()).toFloat()
    return LC(lCusp, lCusp * sCusp)
}

private fun findGamutIntersection(a: Float, b: Float, l1: Float, c1: Float, l0: Float, cusp: LC): Float {
    var t: Float

    if (((l1 - l0) * cusp.c - (cusp.l - l0) * c1) <= 0f) {
        t = cusp.c * l0 / (c1 * cusp.l + cusp.c * (l0 - l1))
    } else {
        t = cusp.c * (l0 - 1f) / (c1 * (cusp.l - 1f) + cusp.c * (l0 - l1))

        val dL = l1 - l0
        val dC = c1

        val kl = +0.3963377774f * a + 0.2158037573f * b
        val km = -0.1055613458f * a - 0.0638541728f * b
        val ks = -0.0894841775f * a - 1.2914855480f * b

        val ldt = dL + dC * kl
        val mdt = dL + dC * km
        val sdt = dL + dC * ks

        run {
            val l = l0 * (1f - t) + t * l1
            val c = t * c1

            val l_ = l + c * kl
            val m_ = l + c * km
            val s_ = l + c * ks

            val l3 = l_ * l_ * l_
            val m3 = m_ * m_ * m_
            val s3 = s_ * s_ * s_

            val ldt1 = 3f * ldt * l_ * l_
            val mdt1 = 3f * mdt * m_ * m_
            val sdt1 = 3f * sdt * s_ * s_

            val ldt2 = 6f * ldt * ldt * l_
            val mdt2 = 6f * mdt * mdt * m_
            val sdt2 = 6f * sdt * sdt * s_

            val r = 4.0767416621f * l3 - 3.3077115913f * m3 + 0.2309699292f * s3 - 1f
            val r1 = 4.0767416621f * ldt1 - 3.3077115913f * mdt1 + 0.2309699292f * sdt1
            val r2 = 4.0767416621f * ldt2 - 3.3077115913f * mdt2 + 0.2309699292f * sdt2

            val g = -1.2684380046f * l3 + 2.6097574011f * m3 - 0.3413193965f * s3 - 1f
            val g1 = -1.2684380046f * ldt1 + 2.6097574011f * mdt1 - 0.3413193965f * sdt1
            val g2 = -1.2684380046f * ldt2 + 2.6097574011f * mdt2 - 0.3413193965f * sdt2

            val bb = -0.0041960863f * l3 - 0.7034186147f * m3 + 1.7076147010f * s3 - 1f
            val b1 = -0.0041960863f * ldt1 - 0.7034186147f * mdt1 + 1.7076147010f * sdt1
            val b2 = -0.0041960863f * ldt2 - 0.7034186147f * mdt2 + 1.7076147010f * sdt2

            val ur = r1 / (r1 * r1 - 0.5f * r * r2)
            val ug = g1 / (g1 * g1 - 0.5f * g * g2)
            val ub = b1 / (b1 * b1 - 0.5f * bb * b2)

            val tr = if (ur >= 0f) -r * ur else Float.MAX_VALUE
            val tg = if (ug >= 0f) -g * ug else Float.MAX_VALUE
            val tb = if (ub >= 0f) -bb * ub else Float.MAX_VALUE

            t += min(tr, min(tg, tb))
        }
    }

    return t
}

private fun toe(x: Float): Float {
    val k1 = 0.206f
    val k2 = 0.03f
    val k3 = (1f + k1) / (1f + k2)
    return 0.5f * (k3 * x - k1 + sqrt((k3 * x - k1) * (k3 * x - k1) + 4f * k2 * k3 * x))
}

private fun toeInv(x: Float): Float {
    val k1 = 0.206f
    val k2 = 0.03f
    val k3 = (1f + k1) / (1f + k2)
    return (x * x + k1 * x) / (k3 * (x + k2))
}

private fun toSt(cusp: LC): ST = ST(cusp.c / cusp.l, cusp.c / (1f - cusp.l))

private fun getStMid(a: Float, b: Float): ST {
    val s = 0.11516993f + 1f / (
        +7.44778970f + 4.15901240f * b +
            a * (-2.19557347f + 1.75198401f * b +
                a * (-2.13704948f - 10.02301043f * b +
                    a * (-4.24894561f + 5.38770819f * b + 4.69891013f * a)))
        )

    val t = 0.11239642f + 1f / (
        +1.61320320f - 0.68124379f * b +
            a * (+0.40370612f + 0.90148123f * b +
                a * (-0.27087943f + 0.61223990f * b +
                    a * (+0.00299215f - 0.45399568f * b - 0.14661872f * a)))
        )

    return ST(s, t)
}

private fun getCs(l: Float, a: Float, b: Float): Cs {
    val cusp = findCusp(a, b)
    val cMax = findGamutIntersection(a, b, l, 1f, l, cusp)
    val stMax = toSt(cusp)
    val k = cMax / min(l * stMax.s, (1f - l) * stMax.t)

    val cMid = run {
        val stMid = getStMid(a, b)
        val cA = l * stMid.s
        val cB = (1f - l) * stMid.t
        0.9f * k * sqrt(sqrt(1f / (1f / (cA * cA * cA * cA) + 1f / (cB * cB * cB * cB))))
    }

    val c0 = run {
        val cA = l * 0.4f
        val cB = (1f - l) * 0.8f
        sqrt(1f / (1f / (cA * cA) + 1f / (cB * cB)))
    }

    return Cs(c0, cMid, cMax)
}

private fun okhslToSrgb(h: Float, s: Float, l: Float): LinearRgb {
    val hh = wrap01(h)
    val ss = clamp01(s)
    val ll = clamp01(l)

    if (ll >= 1f) return LinearRgb(1f, 1f, 1f)
    if (ll <= 0f) return LinearRgb(0f, 0f, 0f)

    val a = cos(2f * PI_F * hh)
    val b = sin(2f * PI_F * hh)

    val lOk = toeInv(ll)
    val cs = getCs(lOk, a, b)

    val mid = 0.8f
    val midInv = 1.25f

    val c = if (ss < mid) {
        val t = midInv * ss
        val k1 = mid * cs.c0
        val k2 = 1f - k1 / cs.cMid
        t * k1 / (1f - k2 * t)
    } else {
        val t = (ss - mid) / (1f - mid)
        val k0 = cs.cMid
        val k1 = (1f - mid) * cs.cMid * cs.cMid * midInv * midInv / cs.c0
        val k2 = 1f - k1 / (cs.cMax - cs.cMid)
        k0 + t * k1 / (1f - k2 * t)
    }

    return oklabToLinearSrgb(Oklab(lOk, c * a, c * b))
}

private fun okhsvToSrgb(h: Float, s: Float, v: Float): LinearRgb {
    val hh = wrap01(h)
    val ss = clamp01(s)
    val vv = clamp01(v)

    if (vv <= 0f) return LinearRgb(0f, 0f, 0f)

    val a = cos(2f * PI_F * hh)
    val b = sin(2f * PI_F * hh)

    val cusp = findCusp(a, b)
    val stMax = toSt(cusp)
    val sMax = stMax.s
    val tMax = stMax.t
    val s0 = 0.5f
    val k = 1f - s0 / sMax

    val lV = 1f - ss * s0 / (s0 + tMax - tMax * k * ss)
    val cV = ss * tMax * s0 / (s0 + tMax - tMax * k * ss)

    var l = vv * lV
    var c = vv * cV

    val lVt = toeInv(lV)
    val cVt = cV * lVt / lV

    if (l > 1e-8f) {
        val lNew = toeInv(l)
        c = c * lNew / l
        l = lNew
    } else {
        l = 0f
        c = 0f
    }

    val rgbScale = oklabToLinearSrgb(Oklab(lVt, a * cVt, b * cVt))
    val scaleL = cbrt((1f / max(max(rgbScale.r, rgbScale.g), max(rgbScale.b, 0f))).toDouble()).toFloat()

    l *= scaleL
    c *= scaleL

    return oklabToLinearSrgb(Oklab(l, c * a, c * b))
}

private fun hslToSrgb(h: Float, s: Float, l: Float): LinearRgb {
    val hh = wrap01(h)
    val ss = clamp01(s)
    val ll = clamp01(l)

    val c = (1f - abs(2f * ll - 1f)) * ss
    val h6 = hh * 6f
    val x = c * (1f - abs((h6 % 2f) - 1f))

    val (r1, g1, b1) = when {
        h6 < 1f -> Triple(c, x, 0f)
        h6 < 2f -> Triple(x, c, 0f)
        h6 < 3f -> Triple(0f, c, x)
        h6 < 4f -> Triple(0f, x, c)
        h6 < 5f -> Triple(x, 0f, c)
        else -> Triple(c, 0f, x)
    }

    val m = ll - c * 0.5f
    return LinearRgb(r1 + m, g1 + m, b1 + m)
}

private fun linearToVec4(rgb: LinearRgb, alpha: Float): Vec4 = Vec4(
    clamp01(srgbTransfer(rgb.r)),
    clamp01(srgbTransfer(rgb.g)),
    clamp01(srgbTransfer(rgb.b)),
    clamp01(alpha)
)

fun rgb(r: Number, g: Number, b: Number, a: Number = 1f): Vec4

A colour from sRGB components, on whichever of the two usual scales you happened to type.

The scale is inferred per call: if any of r, g, b exceeds 1 the three are read as 0..255, otherwise as 0..1. Alpha is inferred separately on the same rule, so rgb(255, 128, 0) and rgb(1, 0.5, 0) both work and rgb(255, 128, 0, 0.5) means half-transparent. Everything is clamped into [0,1] on the way out, and the components are taken as already sRGB-encoded — no transfer function is applied, unlike hsl and okhsl.

Reach for rgb255 instead when a component genuinely might be 0 or 1 on the 0..255 scale and the guess would go the wrong way.

fun rgb(r: Number, g: Number, b: Number, a: Number = 1f): Vec4 {
    val rf = n(r)
    val gf = n(g)
    val bf = n(b)
    val af = n(a)

    val rgbScale = if (max(max(rf, gf), bf) > 1f) 1f / 255f else 1f
    val aScaled = if (af > 1f) af / 255f else af

    return Vec4(
        clamp01(rf * rgbScale),
        clamp01(gf * rgbScale),
        clamp01(bf * rgbScale),
        clamp01(aScaled)
    )
}

fun rgba(r: Number, g: Number, b: Number, a: Number = 1f): Vec4 = rgb(r, g, b, a)

rgb under the name that reads better when you are passing alpha explicitly. Identical.

fun rgb255(r: Number, g: Number, b: Number, a: Number = 255f): Vec4

A colour from 0..255 sRGB components, with no scale guessing — the unambiguous form of rgb.

Alpha is 0..255 here too and defaults to opaque, so rgb255(18, 18, 18) is the near-black a dark theme wants and rgb255(255, 0, 0, 128) is a half-transparent red.

fun rgb255(r: Number, g: Number, b: Number, a: Number = 255f): Vec4 = Vec4(
    clamp01(n(r) / 255f),
    clamp01(n(g) / 255f),
    clamp01(n(b) / 255f),
    clamp01(n(a) / 255f)
)

fun hsl(h: Number, s: Number, l: Number, a: Number = 1f): Vec4

A colour from classic hue / saturation / lightness.

Hue may be a fraction of a turn (0.33) or degrees (120) — a magnitude above 1 is divided by 360, and then wrapped. Note what that rule costs at the boundary: 1.25 is above 1, so it is read as 1.25 degrees and lands on red, not a quarter turn round. Keep turns strictly below 1 and degrees at 1 or above. Saturation and lightness are 0..1 and clamped; l = 0.5 is the fully saturated band, 0 is black and 1 white.

The HSL cube is treated as linear light and then encoded to sRGB, the same final step okhsl takes. That makes this not CSS's hsl(): any component that lands strictly between 0 and 1 is gamma-shifted upwards, so hsl(30, 1, 0.5) is (1, 0.735, 0) where CSS gives (1, 0.502, 0), and hsl(0, 0, 0.5) is 0.735 grey rather than 0.5. Only colours whose every component is exactly 0 or 1 survive unchanged — the six 60°-multiple corners at l = 0.5, plus black and white — because those are the fixed points of the transfer. If you want perceptually even lightness across hues — a ramp where the yellow does not jump out of the row — use okhsl.

fun hsl(h: Number, s: Number, l: Number, a: Number = 1f): Vec4 {
    val linear = hslToSrgb(hue01(h), clamp01(n(s)), clamp01(n(l)))
    return linearToVec4(linear, n(a))
}

fun hsla(h: Number, s: Number, l: Number, a: Number = 1f): Vec4 = hsl(h, s, l, a)

hsl under the name that reads better when you are passing alpha explicitly. Identical.

fun cssHsla(h: Number, s: Number, l: Number, a: Number = 1f): Vec4

CSS's hsla(): the colour a browser draws for hsla(h, s%, l%, a).

h is always degrees (wrapped, so 370 is 10), and s, l and a are 0..1 (clamped), so cssHsla(205, 0.96, 0.68, 0.88) is hsla(205, 96%, 68%, 0.88). Unlike hsl there is no linear-light step and no turns-or-degrees guess: the result is the sRGB value CSS defines, which is what a colour copied from a web sketch needs in order to look the same here.

fun cssHsla(h: Number, s: Number, l: Number, a: Number = 1f): Vec4 {
    val hue = ((h.toDouble() % 360.0) + 360.0) % 360.0
    val sat = s.toDouble().coerceIn(0.0, 1.0)
    val light = l.toDouble().coerceIn(0.0, 1.0)
    val chroma = (1 - abs(2 * light - 1)) * sat
    val x = chroma * (1 - abs((hue / 60.0) % 2.0 - 1))
    val m = light - chroma / 2
    val (r, g, b) = when {
        hue < 60 -> Triple(chroma, x, 0.0)
        hue < 120 -> Triple(x, chroma, 0.0)
        hue < 180 -> Triple(0.0, chroma, x)
        hue < 240 -> Triple(0.0, x, chroma)
        hue < 300 -> Triple(x, 0.0, chroma)
        else -> Triple(chroma, 0.0, x)
    }
    return Vec4((r + m).toFloat(), (g + m).toFloat(), (b + m).toFloat(), a.toFloat().coerceIn(0f, 1f))
}

fun okhsl(h: Number, s: Number, l: Number, a: Number = 1f): Vec4

A colour from Oklab-based hue / saturation / lightness — HSL with the perceptual kinks taken out.

Same argument conventions as hsl (hue in turns or degrees, wrapped; saturation and lightness 0..1, clamped) but l is perceived lightness, so a set of colours sharing one l really does read as one tonal step. s = 1 is the most saturated colour the sRGB gamut holds at that hue and lightness rather than a fixed distance, which is why okhsl ramps stay in gamut without clipping.

This is the one to reach for when building a palette by hand; hueSetOkhsl does the ring for you and paletteForBackground additionally makes it legible against a known background.

fun okhsl(h: Number, s: Number, l: Number, a: Number = 1f): Vec4 {
    val linear = okhslToSrgb(hue01(h), clamp01(n(s)), clamp01(n(l)))
    return linearToVec4(linear, n(a))
}

fun okhsv(h: Number, s: Number, v: Number, a: Number = 1f): Vec4

A colour from Oklab-based hue / saturation / value — the HSV-shaped sibling of okhsl.

Conventions as hsl. The difference from okhsl is the one HSV has always had from HSL: v = 1 with s = 1 is the vivid, full-strength colour rather than the mid-lightness one, and v scales the whole thing down toward black. Use it when you want "same colour, dimmer" and okhsl when you want "same lightness, different hue".

fun okhsv(h: Number, s: Number, v: Number, a: Number = 1f): Vec4 {
    val linear = okhsvToSrgb(hue01(h), clamp01(n(s)), clamp01(n(v)))
    return linearToVec4(linear, n(a))
}

private fun equallySpacedHues(count: Int, startHue: Float): List<Float> {
    require(count > 0) { "count must be > 0" }
    val step = 1f / count.toFloat()
    val start = wrap01(startHue)
    return List(count) { i -> wrap01(start + i * step) }
}

fun hueSetHsl

count colours whose hues are spaced evenly right round the wheel, all at one HSL saturation and lightness — the one-liner for "give me n distinguishable colours".

The step is 1/count of a turn from startHue, so the last entry is one step short of coming back round. startHue follows the hsl convention (turns or degrees, wrapped). Consecutive entries are neighbours on the wheel, which is what you want for a gradient and not what you want for a legend — shuffle, or take every other one, if the categories must not be confusable.

hueSetOkhsl is the same idea with even perceived lightness, and is usually the better default.

fun hueSetHsl(
    count: Int,
    saturation: Number,
    lightness: Number,
    alpha: Number = 1f,
    startHue: Number = 0f
): List<Vec4> {
    val s = clamp01(n(saturation))
    val l = clamp01(n(lightness))
    val a = clamp01(n(alpha))
    return equallySpacedHues(count, hue01(startHue)).map { h -> hsl(h, s, l, a) }
}

fun hueSetOkhsl

count evenly spaced hues at one okhsl saturation and lightness — the palette generator to reach for first.

Spacing and the startHue convention are as hueSetHsl; the difference is that because okhsl's l is perceived lightness, no entry in the returned set is conspicuously brighter than its neighbours the way an HSL yellow is. If the set has to sit on a particular background and stay readable, use paletteForBackground instead.

fun hueSetOkhsl(
    count: Int,
    saturation: Number,
    lightness: Number,
    alpha: Number = 1f,
    startHue: Number = 0f
): List<Vec4> {
    val s = clamp01(n(saturation))
    val l = clamp01(n(lightness))
    val a = clamp01(n(alpha))
    return equallySpacedHues(count, hue01(startHue)).map { h -> okhsl(h, s, l, a) }
}

fun hueSetOkhsv

count evenly spaced hues at one okhsv saturation and value — hueSetOkhsl for the cases where you want the vivid end of the gamut rather than a fixed lightness.

Spacing and the startHue convention are as hueSetHsl.

fun hueSetOkhsv(
    count: Int,
    saturation: Number,
    value: Number,
    alpha: Number = 1f,
    startHue: Number = 0f
): List<Vec4> {
    val s = clamp01(n(saturation))
    val v = clamp01(n(value))
    val a = clamp01(n(alpha))
    return equallySpacedHues(count, hue01(startHue)).map { h -> okhsv(h, s, v, a) }
}

fun hslSet

Short alias for hueSetHsl.

fun hslSet(
    count: Int,
    saturation: Number,
    lightness: Number,
    alpha: Number = 1f,
    startHue: Number = 0f
): List<Vec4> = hueSetHsl(count, saturation, lightness, alpha, startHue)

fun okhslSet

Short alias for hueSetOkhsl.

fun okhslSet(
    count: Int,
    saturation: Number,
    lightness: Number,
    alpha: Number = 1f,
    startHue: Number = 0f
): List<Vec4> = hueSetOkhsl(count, saturation, lightness, alpha, startHue)

fun okhsvSet

Short alias for hueSetOkhsv.

body — 66 lines, from line 647
fun okhsvSet(
    count: Int,
    saturation: Number,
    value: Number,
    alpha: Number = 1f,
    startHue: Number = 0f
): List<Vec4> = hueSetOkhsv(count, saturation, value, alpha, startHue)

private fun relativeLuminanceSrgb(color: Vec4): Float {
    val r = srgbTransferInv(clamp01(color.x))
    val g = srgbTransferInv(clamp01(color.y))
    val b = srgbTransferInv(clamp01(color.z))
    return 0.2126f * r + 0.7152f * g + 0.0722f * b
}

private fun contrastRatio(a: Vec4, b: Vec4): Float {
    val la = relativeLuminanceSrgb(a)
    val lb = relativeLuminanceSrgb(b)
    val hi = max(la, lb)
    val lo = min(la, lb)
    return (hi + 0.05f) / (lo + 0.05f)
}

private fun contrastAdjustedOkhsl(
    hue: Float,
    saturation: Float,
    baseLightness: Float,
    alpha: Float,
    background: Vec4,
    minContrast: Float,
    maxLightnessShift: Float,
    lightnessSteps: Int
): Vec4 {
    val base = okhsl(hue, saturation, baseLightness, alpha)
    var best = base
    var bestContrast = contrastRatio(base, background)
    if (bestContrast >= minContrast) return base

    val preferLighter = relativeLuminanceSrgb(background) < 0.5f
    val shift = clamp01(maxLightnessShift)
    val upper = min(1f, baseLightness + shift)
    val lower = max(0f, baseLightness - shift)

    fun scan(toLightness: Float): Vec4? {
        for (i in 1..lightnessSteps) {
            val t = i.toFloat() / lightnessSteps.toFloat()
            val l = baseLightness + (toLightness - baseLightness) * t
            val candidate = okhsl(hue, saturation, l, alpha)
            val contrast = contrastRatio(candidate, background)
            if (contrast > bestContrast) {
                bestContrast = contrast
                best = candidate
            }
            if (contrast >= minContrast) return candidate
        }
        return null
    }

    val passed = if (preferLighter) {
        scan(upper) ?: scan(lower)
    } else {
        scan(lower) ?: scan(upper)
    }

    return passed ?: best
}

fun paletteForBackground

count evenly spaced okhsl hues, each nudged in lightness until it is actually legible against background.

Starts from the same ring as hueSetOkhsl, then for any entry whose WCAG contrast ratio against the background falls short of minContrast it walks that entry's lightness in up to lightnessSteps steps, at most maxLightnessShift away from lightness, and takes the first candidate that clears the bar. The direction is chosen from the background: a dark background is searched lighter first, a light one darker first, and the other direction is tried if the first runs out of room. When nothing reaches minContrast the best candidate found is returned rather than an exception — the result is always count colours.

minContrast is a ratio in the WCAG sense, where 3 is the large-text threshold, 4.5 body text and 7 the strict one; the default of 3 suits the graph strokes and labels this is usually used for. Contrast is measured against the background alone, so semi-transparent alpha values are not accounted for.

fun paletteForBackground(
    background: Vec4,
    count: Int,
    saturation: Number = 0.72f,
    lightness: Number = 0.62f,
    alpha: Number = 1f,
    startHue: Number = 0f,
    minContrast: Number = 3f,
    maxLightnessShift: Number = 0.30f,
    lightnessSteps: Int = 24
): List<Vec4> {
    require(count > 0) { "count must be > 0" }
    require(lightnessSteps > 0) { "lightnessSteps must be > 0" }

    val s = clamp01(n(saturation))
    val l = clamp01(n(lightness))
    val a = clamp01(n(alpha))
    val minCr = max(1f, n(minContrast))
    val maxShift = n(maxLightnessShift)

    return equallySpacedHues(count, hue01(startHue)).map { h ->
        contrastAdjustedOkhsl(
            hue = h,
            saturation = s,
            baseLightness = l,
            alpha = a,
            background = background,
            minContrast = minCr,
            maxLightnessShift = maxShift,
            lightnessSteps = lightnessSteps
        )
    }
}