Colormaps.kt
src/jvmMain/kotlin/afield/graphics/color/Colormaps.kt · 156 lines
package afield.graphics.color
import afield.app.diagnostics.named
import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.runtime.execution.plus
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.with
import afield.ui.visualization.scatter
import afield.ui.visualization.showHeatmap
import afield.ui.visualization.showSignals
import afield.ui.visualization.table
import afield.ui.visualization.viz
import afield.audio.synth.*
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.map
import kotlin.math.floor
The colour vocabulary the visualization helpers share: Color4, a [0,1]-component colour value
with no dependencies, and Colormaps, eight named ramps that can be sampled at any parameter.
A ramp here is a short table of evenly-spaced control points rather than a 256-entry lookup table
(nine colours for each of the matplotlib maps, seventeen for turbo, three for the house
cyan→orange), and Colormaps.sampleStops interpolates linearly between them. A caller that
colours one pixel at a time builds its own table from that: showHeatmap quantises the ramp into
1024 entries once per image rather than interpolating per pixel.
Names are matched after trimming and lowercasing, gray, greys and cyan-orange are aliases,
and an unknown name raises with Colormaps.names in the message rather than falling back to a
default. RectSL's colormap and cmap words, showHeatmap and the imaging visualizers all take
that same vocabulary, so a ramp named in one place is the same ramp in another.
data class Color4
A colour with [0,1] components. A small, dependency-free colour value shared across the
visualization helpers (showHeatmap, Colormaps) and the RectSL data-viz words.
(Lived in afield.lang.rectsl until 2026-07-22; promoted here so general colour/colormap code no longer
pulls in the RectSL microlanguage. RectSL re-adds its toVec4f() bridge as an extension.)
data class Color4(
val r: Double,
val g: Double,
val b: Double,
val a: Double = 1.0
) {
fun withAlpha(alpha: Double): Color4 = copy(a = alpha)
fun alphaTimes(alpha: Double): Color4 = copy(a = a * alpha)
fun mix(other: Color4, t: Double): Color4
fun mix(other: Color4, t: Double): Color4 {
val u = t.coerceIn(0.0, 1.0)
fun lerp(a: Double, b: Double): Double = a + (b - a) * u
return Color4(lerp(r, other.r), lerp(g, other.g), lerp(b, other.b), lerp(a, other.a))
}
companion object
val WHITE = Color4(1.0, 1.0, 1.0, 1.0)
val BLACK = Color4(0.0, 0.0, 0.0, 1.0)
fun fromHex(hex: Long): Color4
fun fromHex(hex: Long): Color4 {
val hasAlpha = hex > 0xffffffL
val r = ((hex shr if (hasAlpha) 24 else 16) and 0xff).toDouble() / 255.0
val g = ((hex shr if (hasAlpha) 16 else 8) and 0xff).toDouble() / 255.0
val b = ((hex shr if (hasAlpha) 8 else 0) and 0xff).toDouble() / 255.0
val a = if (hasAlpha) (hex and 0xff).toDouble() / 255.0 else 1.0
return Color4(r, g, b, a)
}
}
}
object Colormaps
Perceptually-uniform colormaps as named colour ramps — matplotlib's viridis / magma /
plasma / inferno, Google's turbo, Kenneth Moreland's coolwarm diverging map, and a plain
grayscale. Each map is a table of evenly-spaced RGB control points taken from the published
data (the viridisLite 9-class swatches for the matplotlib family, the turbo_srgb_floats LUT
sampled every 16th entry, Moreland's cool→warm table); sampleStops interpolates linearly
between them. This is reusable domain code — the single source of truth for the showHeatmap
visualizer and RectSL's colormap/cmap, bars, heat, and scatter words, and directly
callable from Kotlin.
object Colormaps {
private fun hex(vararg codes: Long): List<Color4> = codes.map { Color4.fromHex(it) }
private fun rgb255(vararg triples: Int): List<Color4> =
triples.toList().chunked(3).map { Color4(it[0] / 255.0, it[1] / 255.0, it[2] / 255.0) }
val viridis: List<Color4>
matplotlib's default: dark purple through blue and green to yellow, nine stops from the viridisLite 9-class swatches. The safe first choice for a quantity with no natural midpoint.
val viridis: List<Color4> = hex(0x440154, 0x472D7B, 0x3B528B, 0x2C718E, 0x21908C, 0x28AE80, 0x5EC962, 0xADDC30, 0xFDE725)
val magma: List<Color4>
Near-black through purple and red to pale cream, nine stops. Darker at the low end than viridis, which suits a heatmap whose background should read as empty.
val magma: List<Color4> = hex(0x000004, 0x1C1044, 0x4F127B, 0x812581, 0xB5367A, 0xE55064, 0xFB8861, 0xFEC287, 0xFCFDBF)
val inferno: List<Color4>
magma's hotter sibling: the same near-black start, but through crimson and orange to pale yellow, nine stops.
val inferno: List<Color4> = hex(0x000004, 0x1B0C42, 0x4B0C6B, 0x781C6D, 0xA52C60, 0xCF4446, 0xED6925, 0xFB9A06, 0xFCFFA4)
val plasma: List<Color4>
Deep blue through magenta and orange to yellow, nine stops. Begins at a saturated blue rather than near-black, so the low end stays visible against a dark page.
val plasma: List<Color4> = hex(0x0D0887, 0x47039F, 0x7301A8, 0x9C179E, 0xBD3786, 0xD8576B, 0xED7953, 0xFA9E3B, 0xF0F921)
val turbo: List<Color4>
Google's turbo, the rainbow redrawn to run smoothly: dark blue, cyan, green, yellow, orange, dark red, seventeen stops taken from turbo_srgb_floats every sixteenth entry plus the last. More steps are told apart than in a perceptually uniform ramp, at the cost of implying boundaries the data may not have.
val turbo: List<Color4> = listOf(
Color4(0.18995, 0.07176, 0.23217), Color4(0.25107, 0.25237, 0.63374),
Color4(0.27628, 0.42118, 0.89123), Color4(0.26592, 0.55979, 0.99583),
Color4(0.20708, 0.66866, 0.97423), Color4(0.09532, 0.84455, 0.79299),
Color4(0.12733, 0.91701, 0.67627), Color4(0.22142, 0.95692, 0.56914),
Color4(0.32006, 0.97974, 0.47654), Color4(0.43321, 0.99551, 0.37345),
Color4(0.54658, 0.99907, 0.29581), Color4(0.64362, 0.98999, 0.23356),
Color4(0.74617, 0.95593, 0.20406), Color4(0.85010, 0.89328, 0.21074),
Color4(0.93909, 0.80439, 0.22744), Color4(0.99314, 0.67408, 0.20348),
Color4(0.47960, 0.01583, 0.01055)
)
val coolwarm: List<Color4>
Kenneth Moreland's cool-to-warm: blue through light grey to red, nine stops sampled every eighth of the way along his table. The one to reach for when zero means something — a residual, a difference of two runs — since the middle of the ramp is the neutral grey, so map the data to put zero there.
val coolwarm: List<Color4> = rgb255(
59, 76, 192, 98, 130, 234, 141, 176, 254, 184, 208, 249,
221, 221, 221, 245, 196, 173, 244, 154, 123, 222, 96, 77, 180, 4, 38
)
val grayscale: List<Color4> = hex(0x000000, 0xFFFFFF)
Black to white, two stops, so a sample is the parameter itself in all three channels. Matched by the names gray and greys as well.
val cyanorange: List<Color4> = rgb255(10, 14, 20, 60, 190, 230, 255, 150, 50)
Field's house ramp: near-black, cyan through the middle, orange at the peaks, three stops. The same cyan and orange the signal plots use, and the default for the spectral heatmaps in the documentation. Also matched as cyan-orange.
private val maps: Map<String, List<Color4>> = linkedMapOf(
"viridis" to viridis, "magma" to magma, "inferno" to inferno, "plasma" to plasma,
"turbo" to turbo, "coolwarm" to coolwarm, "grayscale" to grayscale, "gray" to grayscale, "greys" to grayscale,
"cyanorange" to cyanorange, "cyan-orange" to cyanorange
)
val names: List<String>
The eight canonical names, in the order above, as stops quotes them when it is handed one it does not know. The three aliases gray, greys and cyan-orange are accepted by stops but are not among them.
val names: List<String> = listOf("viridis", "magma", "inferno", "plasma", "turbo", "coolwarm", "grayscale", "cyanorange")
fun stops(name: String): List<Color4>
fun stops(name: String): List<Color4> =
maps[name.trim().lowercase()]
?: throw IllegalArgumentException("unknown colormap '$name' (try ${names.joinToString(", ")})")
fun sample(name: String, t: Double): Color4 = sampleStops(stops(name), t)
Look up colormap name at parameter t in [0,1] (clamped).
fun sampleStops(stops: List<Color4>, t: Double): Color4
Linearly interpolate an evenly-spaced list of control points at t in [0,1] (clamped).
fun sampleStops(stops: List<Color4>, t: Double): Color4 {
require(stops.isNotEmpty()) { "colormap has no control points" }
if (stops.size == 1) return stops[0]
val scaled = t.coerceIn(0.0, 1.0) * (stops.size - 1)
val lo = floor(scaled).toInt().coerceIn(0, stops.size - 2)
return stops[lo].mix(stops[lo + 1], scaled - lo)
}
}