MakieOrg / Makie.jl

Interactive data visualizations and plotting in Julia
https://docs.makie.org/stable
MIT License
2.37k stars 302 forks source link

divergent colormaps centered at specified value (e.g. zero) #2485

Open haakon-e opened 1 year ago

haakon-e commented 1 year ago

Following up from a discussion of slack, I've spent some time thinking about how I can take a divergent colormap (e.g. "RdBu") center it at zero, and let the lo and hi points vary independently.

Currently, if you want to visualize data with a divergent colormap centered at zero, you must set a symmetric colorrange=(vmin, vmax) where vmin = -vmax. This can result in uneccessarily large colorbar ranges (e.g. if your data is in (-0.1, 100), the colorrange would have to be (-100, 100)).

The best solution I found to this is the following:

# colors are sampled in [0,1], so define a function that linearly transforms x ∈ [lo, hi] to [0, 1].
to_unitrange(x, lo, hi) = (x - lo) / (hi - lo)

# cols is for simplicity here assumed to be a `ColorScheme` (e.g. `Makie.ColorSchemes.RdBu`)
function create_cmap(cols, lo, hi; categorical=true)
    absmax = maximum(abs, (lo, hi));
    lo_m, hi_m = to_unitrange.([lo, hi], -absmax, absmax);  # map lo, hi ∈ [-absmax, absmax] onto [0,1] to sample their corresponding colors
    colsvals = range(0, 1; length=length(cols))  # values on [0,1] where each color in cols is defined
    filter_colsvals = filter(∈(lo_m..hi_m), unique([lo_m; colsvals; hi_m]))  # filter colsvals, keep only values in [lo_m, hi_m] + the endpoints lo_m and hi_m.
    newcols = get(cols, filter_colsvals);  # select colors in filtered range; interpolate new low and hi colors.
    new_colsvals = to_unitrange.(filter_colsvals, lo_m, hi_m)  # values on [0,1] where the new colors are defined
    cmap = cgrad(newcols, new_colsvals; categorical)  # for continous cmap, set categorical=false
end

This approach essentially "cuts" the original colormap cols to match your actual data range in such a way that quantities with the same absolute value has the same color "strength" (given an input colormap intended to function as such).

This approach preserves each discrete color in the original colormap cols, and interpolates only the new endpoints.

Obviously the code above assumes the desired center is zero, but extensions for arbitrary center values is trivial.


Some example outputs: ```julia cols = ColorSchemes.RdYlBu # RdYlBu is a vector of 11 perceptually uniform sequential colors for (lo, hi) in [(-5, 5), (-4.123, 5), (-1, 5), (-1.652462, 5), (-0.111,5), (0, 5), (0.111,5), (1,5), (3,5)] cmap = create_cmap(cols, lo, hi; categorical=true) cb = Colorbar(fig[1,end+1]; colormap = cmap, limits=(lo, hi), size=100, ticks=-5:5) end for (lo, hi) in [(-5, 5), (-4.123, 5), (-1, 5), (-1.652462, 5), (-0.111,5), (0, 5), (0.111,5), (1,5), (3,5)] cmap = create_cmap(cols, lo, hi; categorical=false) cb = Colorbar(fig[1,end+1]; colormap = cmap, limits=(lo, hi), size=100, ticks=-5:5) end ``` ![image](https://user-images.githubusercontent.com/45243236/206951397-278fda1c-ff3f-47d7-a7f5-6044a695c631.png)
berjine commented 6 months ago

I nedded to do this on a heatmap so I am happy it is already a feature request !

My data has small negative points and big positive points, but I want to show the placement of the negative areas compared to the positive areas

At first I used a periodic colormap map like :flag or :tab20 which are great to show small variations in data with big dynamic range, but it can be complex to interpret

The easiest solution is probably to normalize the data to a symmetric interval or just apply a gain on the negative points, but this would have to be compensated somehow on the colorbar ticks

I ended up superposing two heatmaps each with the data of one sign and one half of the same colormap (code and example below)

It works good for me even though the split colorbar can look strange, it for sure would be cleaner with a unique skewed divergent colormap

It also works with InteractiveViz albeit with two minor problems :

using GLMakie, NaNStatistics
data = rand(1000,1000).-0.01; #something with a big difference between the positive and negative ranges
data_pos = map(x-> x >= 0 ? x : NaN, data);
data_neg = map(x-> x < 0 ? x : NaN, data;
low, high = nanminimum(data_neg), nanmaximum(data_pos)
cmap = :seismic
cmap_high = to_colormap(cmap)[end>>1+1:end]
cmap_low = to_colormap(cmap)[1:end>>1]

f = Figure()
  ax1 = GLMakie.Axis(f[1:2, 1])
  heatmap!(ax1, data_pos, colormap = cmap_high)
  heatmap!(ax1, data_neg, colormap = cmap_low)
  Colorbar(f[1, 2], limits = (0,high), colormap = cmap_high)
  Colorbar(f[2, 2], limits = (low,0), colormap = cmap_low)
  f

Image