Ternary colormaps

using Karmana, CairoMakie, TernaryColormaps

Ternary diagrams are plots on the plane $x + y + z = 1$, in Cartesian space.

A ternary colormap is a sampler which takes as input a coordinate for which $x + y + z = 1$, and returns a color. The color is specified by three color gradients, one for each of x, y, z.

In Karmana, a ternary colormap is created by the TernaryColormap constructor, which has various keyword arguments that you can see in its documentation.

Here's what a ternary colormap looks like:

fig = Figure()
ax, im = Karmana.TernaryColorlegend(fig[1, 1], TernaryColormap())
fig

This is pretty cool, but some of the colors seem wrong. Let's inspect the data:

fig = Figure()
ax = Axis(fig[1, 1]; aspect = AxisAspect(96/71))
hidedecorations!(ax); hidespines!(ax)
TernaryDiagrams.ternaryscatter!(ax, (getindex.(data, i) for i in 1:3)...; color = colors, markersize = 30)

ternaryaxis!(ax);

fig

Any ternary colormap can be called like a function to return a color, as in tmap(x, y, z) or tmap(::Point3), where tmap is a TernaryColormap. This handles NaNs and missings by setting their values to zero.

Using ternary colormaps

Let's say I have some (fake) survey data across India, with three responses: good, bad, and unsure. Unsure is not actually in between good and bad, since it can mean a lot of things, so we want to incorporate it as a third variable.

First, we create some random points:

random_data = rand(Point3f, 36)

f, a, p = scatter(random_data)

rotate_cam!(a.scene, π/6, π/6, 0, 0)

f

Then, we project them onto the $x+y+z=1$ plane (this is normalizing to the $L_1$ norm, which is effectively what I said earlier - ensuring that the sum of $x$, $y$, and $z$ is 1.)

using LinearAlgebra
data = LinearAlgebra.normalize.(random_data, 1)

scatter!(a, data; color = :red)
f

triplane = mesh!(a, Point3f[(0,0,1), (0, 1, 0), (1, 0, 0)]; color = (:blue, 0.5))
f

Now, we can find the colors:

colors = tmap.(Point3f.(data))

We can even plot this using the indiaoutline recipe, which can take colors in place of values:

f, a, p = indiaoutline(:State, 1:36, colors; axis = (aspect = DataAspect(),))

Let's also add a legend to this:

ta, ip = Karmana.TernaryColorlegend(f[1, 2], tmap; xlabel = "Bad", ylabel = "Good", zlabel = "Uncertain")

f

ta.width = Relative(0.7)
f

What is a ternary colormap?

The resulting color is created by adding the colors from each colormap at the value of the variable. So, a color would be defined as xmap(x) + ymap(y) + zmap(z), where the *map functions take in a number and return a color.

A TernaryColormap is made of three color gradients, xmap, ymap, and zmap. These are Julia color gradient objects which can be created by the cgrad function - see its documentation for more details.

Let's explore these gradients in more detail:

tmap = TernaryColormap()
fig = Figure()
with_theme(Attributes(
    Colorbar = (
        vertical = false, flipaxis = false, height = 40,
        ticks = Makie.LinearTicks(5)
    ))) do
    xcb = Colorbar(fig[1, 1], label = "x", colorrange = (0, 1), colormap = tmap.xmap)
    xlb = Label(fig[1, 0], text = "x", font = :bold, fontsize = 35, tellheight = false)
    ycb = Colorbar(fig[2, 1], label = "y", colorrange = (0, 1), colormap = tmap.ymap)
    ylb = Label(fig[2, 0], text = "y", font = :bold, fontsize = 35, tellheight = false)
    zcb = Colorbar(fig[3, 1], label = "z", colorrange = (0, 1), colormap = tmap.zmap)
    zlb = Label(fig[3, 0], text = "z", font = :bold, fontsize = 35, tellheight = false)
end
fig

These are the individual gradients of the ternary colormap.

Custom ternary colormaps

Care must be taken when creating a ternary colormap, to ensure that the colors add up, even at their maximum, to something reasonable.

For example,

fig = Figure()
ax, im = TernaryColorlegend(fig[1, 1], TernaryColormap(cgrad(:Oranges), cgrad(:Purples), cgrad(:Greens)))
fig

This looks white, because the output of the sum of these colors is too high!

We can fix this by making the colors darker:

ocg = cgrad(:Oranges)
ocg_hsl = ocg.colors.colors .|> Makie.Colors.HSL
ocg_dark = map(x -> Makie.Colors.HSL(x.h, x.s, x.l * 0.3), ocg_hsl) |> cgrad

pcg = cgrad(:Purples)
pcg_hsl = pcg.colors.colors .|> Makie.Colors.HSL
pcg_dark = map(x -> Makie.Colors.HSL(x.h, x.s, x.l * 0.3), pcg_hsl) |> cgrad

gcg = cgrad(:Greens)
gcg_hsl = gcg.colors.colors .|> Makie.Colors.HSL
gcg_dark = map(x -> Makie.Colors.HSL(x.h, x.s, x.l * 0.3), gcg_hsl) |> cgrad

Let's see what this gave us.

fig = Figure()
ax, im = TernaryColorlegend(fig[1, 1], TernaryColormap(ocg_dark, pcg_dark, gcg_dark))
fig

Well, it technically works...but isn't that good and won't really show you anything.

Another option is to use PerceptualColourMaps.jl, by Peter Kovesi (of colorcet fame). This is where Karmana.jl gets its own ternary colour map from.

These are perceptually uniform colour gradients, where no one colour looks extra bright or dark. This allows humans to more easily understand what's going on, instead of being led astray by false trends in luminosity (see the many scathing reviews of Matlab's rainbow colormap for more info on this).

You can usually generate any colormap by using PerceptuallyUniformColourmaps.equalisecolourmap (note the British English spelling here).

TernaryColormap(
    cgrad([:cyan, :black]),
    cgrad([:magenta, :black]),
    cgrad([:yellow, :black]),
) |> image

This page was generated using Literate.jl.