Selkie.git | lib/Selkie/Plot/ | Palette.rakumod
=begin pod
=head1 NAME
Selkie::Plot::Palette - Colorblind-friendly series palettes and color ramps for chart widgets
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Plot::Palette;
# Series palettes — discrete colors for multi-series charts
my @colors = Selkie::Plot::Palette.series('okabe-ito');
# (0xE69F00, 0x56B4E9, 0x009E73, 0xF0E442, 0x0072B2,
# 0xD55E00, 0xCC79A7, 0x999999)
# Color ramps — continuous gradients for heatmaps
my @stops = Selkie::Plot::Palette.ramp('viridis');
# (0.0 => 0x440154, 0.25 => 0x3B528B, 0.5 => 0x21908C, ...)
# Sample a ramp at any position in [0, 1]
my $color = Selkie::Plot::Palette.sample('viridis', 0.42);
# → 0x2E6A8E (interpolated between 0.25 and 0.5 stops)
=end code
=head1 DESCRIPTION
Two abstractions for chart colors:
=item B<Series palettes> — discrete lists of distinct colors for
multi-series charts (BarChart with N categories, LineChart with N
series). Defaults to L<Okabe-Ito|https://jfly.uni-koeln.de/color/>,
designed to be distinguishable for the most common forms of
colorblindness.
=item B<Color ramps> — continuous gradients sampled by a normalised
position in C<[0, 1]>, for heatmaps and other value-encoded color use.
Defaults to L<viridis|https://bids.github.io/colormap/>, the
perceptually uniform colormap that's been the matplotlib default
since 2.0.
Both are I<separate from> L<Selkie::Theme>. Theme slots cover named
chart elements (axis, gridlines, legend background); palettes cover
data colors. Different access patterns, different homes.
=head2 Series palettes
=item C<okabe-ito> (default, 8 colors) — Okabe & Ito's palette,
optimised for deuteranopia / protanopia / tritanopia. The original
palette starts with pure black, which is invisible on dark
backgrounds; this implementation substitutes C<0x999999> as the first
color so the palette works on either light or dark themes.
=item C<tol-bright> (7 colors) — Paul Tol's "bright" qualitative
palette (L<personal.sron.nl/~pault|https://personal.sron.nl/~pault/>).
Higher saturation, also colorblind-safe.
=item C<tableau-10> (10 colors) — Tableau's category10 palette.
Vivid and well-tested in business dashboards. Less colorblind-friendly
than Okabe-Ito but maximises distinct hues for many series.
If a chart needs more series than its palette provides, the colors
cycle. For more than ~8 series consider redesigning the chart
(faceting, stacked layout, on-hover series isolation) — at a glance,
the human eye can't reliably distinguish more than ~7 chart series by
color alone.
=head2 Color ramps
All ramps are 5-stop. Sampling between stops uses straight linear
interpolation in RGB space — perceptually correct interpolation would
need OkLab or Lab conversion, which is overkill for terminal cells
where adjacent values blur visually anyway.
=item C<viridis> (default for heatmaps) — perceptually uniform,
colorblind-safe, prints reasonably in greyscale. The matplotlib
default since 2.0.
=item C<magma> — like viridis but warmer (purple → red → cream).
=item C<plasma> — high-saturation gradient (deep blue → pink → orange).
=item C<coolwarm> — diverging blue→white→red, useful for signed data
where 0 is special (correlations, deltas).
=item C<grayscale> — five steps of gray. Mostly for accessibility
fallback or print contexts.
=head1 EXAMPLES
=head2 Coloring a multi-series LineChart
=begin code :lang<raku>
my @palette = Selkie::Plot::Palette.series('okabe-ito');
my @series = (
{ label => 'cpu', values => @cpu, color => @palette[0] },
{ label => 'memory', values => @mem, color => @palette[1] },
{ label => 'iowait', values => @iowait, color => @palette[2] },
);
my $chart = Selkie::Widget::LineChart.new(:@series, :show-legend);
=end code
=head2 Driving a Heatmap with a custom ramp stop
=begin code :lang<raku>
my $heatmap = Selkie::Widget::Heatmap.new(
data => @grid,
ramp => 'coolwarm',
);
# Or, for one-off color lookups in custom widget code:
my $color = Selkie::Plot::Palette.sample('viridis', $normalised-value);
=end code
=head2 Cycling a palette beyond its length
=begin code :lang<raku>
my @palette = Selkie::Plot::Palette.series('tol-bright'); # 7 colors
my $color-for = sub ($i) { @palette[$i mod @palette.elems] };
# Series 0..6 get distinct colors; 7 wraps to series 0's color.
=end code
=head1 SEE ALSO
=item L<Selkie::Plot::Scaler> — value→cell mapping
=item L<Selkie::Plot::Ticks> — nice-number axis labels
=item L<Selkie::Theme> — chart-element styling slots (axis, legend bg, etc.)
=end pod
unit class Selkie::Plot::Palette;
# === Series palettes ===
#
# Discrete colors for multi-series charts. All values are 24-bit RGB
# integers (0xRRGGBB), as expected by Selkie::Style.
# Okabe & Ito's colorblind-safe qualitative palette. Original first
# entry was 0x000000 (pure black) which is invisible against dark
# backgrounds; substituted with 0x999999 for theme-agnostic use.
# Reference: https://jfly.uni-koeln.de/color/
my @OKABE-ITO = (
0x999999, # gray (substituted from black)
0xE69F00, # orange
0x56B4E9, # sky blue
0x009E73, # bluish green
0xF0E442, # yellow
0x0072B2, # blue
0xD55E00, # vermillion
0xCC79A7, # reddish purple
);
# Paul Tol's "bright" qualitative palette.
# Reference: https://personal.sron.nl/~pault/
my @TOL-BRIGHT = (
0x4477AA, # blue
0x66CCEE, # cyan
0x228833, # green
0xCCBB44, # yellow
0xEE6677, # red
0xAA3377, # purple
0xBBBBBB, # gray
);
# Tableau's category10 palette.
my @TABLEAU10 = (
0x4E79A7, # blue
0xF28E2B, # orange
0xE15759, # red
0x76B7B2, # teal
0x59A14F, # green
0xEDC948, # yellow
0xB07AA1, # purple
0xFF9DA7, # pink
0x9C755F, # brown
0xBAB0AC, # gray
);
my %SERIES-PALETTES =
'okabe-ito' => @OKABE-ITO,
'tol-bright' => @TOL-BRIGHT,
'tableau-10' => @TABLEAU10,
;
# === Color ramps ===
#
# 5-stop continuous gradients keyed by normalised position 0..1.
# Stops are stored as ordered lists of Pairs so we can binary-search
# by position.
# Viridis — perceptually uniform, the matplotlib default since 2.0.
# Stops sampled at 0/0.25/0.5/0.75/1 from the canonical 256-step LUT.
my @VIRIDIS = (
0.00 => 0x440154,
0.25 => 0x3B528B,
0.50 => 0x21908C,
0.75 => 0x5DC863,
1.00 => 0xFDE725,
);
# Magma — purple → magenta → cream, complements viridis.
my @MAGMA = (
0.00 => 0x000004,
0.25 => 0x3B0F70,
0.50 => 0x8C2981,
0.75 => 0xDE4968,
1.00 => 0xFCFDBF,
);
# Plasma — deep blue → magenta → orange, more saturated than viridis.
my @PLASMA = (
0.00 => 0x0D0887,
0.25 => 0x6A00A8,
0.50 => 0xB12A90,
0.75 => 0xE16462,
1.00 => 0xFCA636,
);
# Coolwarm — blue → white → red diverging, useful for signed data.
my @COOLWARM = (
0.00 => 0x3B4CC0,
0.25 => 0x6688EE,
0.50 => 0xDDDDDD,
0.75 => 0xEE8866,
1.00 => 0xB40426,
);
# Grayscale — dark to light, for accessibility / print fallback.
my @GRAYSCALE = (
0.00 => 0x101010,
0.25 => 0x404040,
0.50 => 0x808080,
0.75 => 0xC0C0C0,
1.00 => 0xF0F0F0,
);
my %RAMPS =
'viridis' => @VIRIDIS,
'magma' => @MAGMA,
'plasma' => @PLASMA,
'coolwarm' => @COOLWARM,
'grayscale' => @GRAYSCALE,
;
#|( Return the named series palette as a list of 24-bit RGB integers.
Defaults to C<okabe-ito>. Throws on unknown names. )
method series(::?CLASS:U: Str:D $name = 'okabe-ito' --> List) {
%SERIES-PALETTES{$name}
or die "Selkie::Plot::Palette.series: unknown palette '$name'."
~ " Known: {%SERIES-PALETTES.keys.sort.join(', ')}";
%SERIES-PALETTES{$name}.list;
}
#|( Return the named color ramp as a list of C<Real => UInt> Pairs,
each pair being a position in C<[0, 1]> mapped to a 24-bit RGB.
Defaults to C<viridis>. Throws on unknown names. )
method ramp(::?CLASS:U: Str:D $name = 'viridis' --> List) {
%RAMPS{$name}
or die "Selkie::Plot::Palette.ramp: unknown ramp '$name'."
~ " Known: {%RAMPS.keys.sort.join(', ')}";
%RAMPS{$name}.list;
}
#|( Sample a ramp at C<$t ∈ [0, 1]>, returning the interpolated
24-bit RGB color. Out-of-range C<$t> is clamped. Interpolation is
linear in RGB space (not OkLab) — adequate for terminal cells. )
method sample(::?CLASS:U: Str:D $name, Real $t --> UInt) {
my @stops = self.ramp($name);
my $clamped = ($t max 0.0) min 1.0;
# Trivial case: hit a stop exactly.
for @stops -> $stop {
return $stop.value.UInt if $stop.key == $clamped;
}
# Find the bracketing pair and lerp.
for ^(@stops.elems - 1) -> $i {
my $lo = @stops[$i];
my $hi = @stops[$i + 1];
if $clamped > $lo.key && $clamped < $hi.key {
my $local-t = ($clamped - $lo.key) / ($hi.key - $lo.key);
return lerp-rgb($lo.value, $hi.value, $local-t);
}
}
# Defensive — clamp guarantees $t ∈ [0, 1] which is covered above.
@stops[*-1].value.UInt;
}
# Linear interpolate two 24-bit RGB colors, channel-wise.
sub lerp-rgb(UInt $a, UInt $b, Real $t --> UInt) {
my $ar = ($a +> 16) +& 0xFF;
my $ag = ($a +> 8) +& 0xFF;
my $ab = $a +& 0xFF;
my $br = ($b +> 16) +& 0xFF;
my $bg = ($b +> 8) +& 0xFF;
my $bb = $b +& 0xFF;
my $r = ($ar * (1 - $t) + $br * $t).round.Int;
my $g = ($ag * (1 - $t) + $bg * $t).round.Int;
my $bo = ($ab * (1 - $t) + $bb * $t).round.Int;
(($r +< 16) +| ($g +< 8) +| $bo).UInt;
}