Selkie.git | lib/Selkie/Widget/ | ScatterPlot.rakumod
=begin pod
=head1 NAME
Selkie::Widget::ScatterPlot - 2D point plot using braille sub-cell dots
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::ScatterPlot;
use Selkie::Sizing;
# Single-series scatter — auto-derives axis ranges from the data.
# Points are Pairs (x => y) so Raku doesn't flatten the list.
my @points = (1..50).map: { (rand * 100) => (rand * 100) };
my $sp = Selkie::Widget::ScatterPlot.new(
series => [
{ label => 'samples', points => @points },
],
sizing => Sizing.flex,
);
# Multi-series with explicit colours
my $sp2 = Selkie::Widget::ScatterPlot.new(
series => [
{ label => 'group A', points => @group-a, color => 0xE69F00 },
{ label => 'group B', points => @group-b, color => 0x56B4E9 },
],
sizing => Sizing.flex,
);
# Tip: use Pair (x => y), [x, y] arrays, or hash {x => , y => } per
# point. Don't use bare lists `(x, y)` — Raku flattens them in
# array context and your single-point scatter becomes two
# independent values.
# Reactive
my $live = Selkie::Widget::ScatterPlot.new(
store-path => <viz scatter-data>,
sizing => Sizing.flex,
);
=end code
=head1 DESCRIPTION
A scatter plot of 2D points. Uses Unicode braille (U+2800-U+28FF)
for B<sub-cell> resolution: each terminal cell holds a 2×4 grid of
dot positions (8 dots per cell). A 50-cell-wide plot can resolve
100 distinct x-positions, and a 20-cell-tall plot can resolve 80
distinct y-positions.
=head2 The braille dot grid
Each braille codepoint encodes which of 8 sub-cell dots are filled:
0 3
1 4
2 5
6 7
The codepoint is C<U+2800 + bit-pattern>, where bit N controls dot N.
A cell with all 8 dots filled is C<⣿> (U+28FF). A cell with no dots
is C<⠀> (U+2800).
=head2 Multi-series colour collision
Each braille cell renders with a single foreground colour. When two
series have dots in the same 2×4 sub-cell window, the cell's colour
is determined by the C<:overlap> setting:
=item C<z-order> (default) — the last-drawn series wins the cell's colour. The earlier series' dots are still drawn but they take the later series' colour.
This is a documented limitation of single-foreground terminal
rendering. For non-overlapping multi-series, the colour assignment
is always correct. For overlapping data, prefer faceted layouts
(separate scatter plots per series) over single-plot overlay.
=head2 Range
Each axis range auto-derives from the data extent. Pass explicit
C<:x-min>, C<:x-max>, C<:y-min>, C<:y-max> to fix any of them. Useful
when streaming so the axes don't jitter as new points expand the
range.
=head1 EXAMPLES
=head2 Single cluster
=begin code :lang<raku>
my @cluster = (1..50).map: {
(50 + rand * 20 - 10, 50 + rand * 20 - 10);
};
my $sp = Selkie::Widget::ScatterPlot.new(
series => [{ label => 'cluster', points => @cluster }],
x-min => 0, x-max => 100,
y-min => 0, y-max => 100,
sizing => Sizing.flex,
);
=end code
=head2 Two clusters with distinct colours
=begin code :lang<raku>
my $sp = Selkie::Widget::ScatterPlot.new(
series => [
{ label => 'cluster A', points => @a, color => 0xE69F00 },
{ label => 'cluster B', points => @b, color => 0x009E73 },
],
sizing => Sizing.flex,
);
=end code
=head1 SEE ALSO
=item L<Selkie::Widget::LineChart> — connects points with lines (also braille)
=item L<Selkie::Widget::Heatmap> — for 2D data on a regular grid
=item L<Selkie::Plot::Palette> — colourblind-safe series palettes
=end pod
use Notcurses::Native;
use Notcurses::Native::Plane;
use Selkie::Widget;
use Selkie::Style;
use Selkie::Plot::Scaler;
use Selkie::Plot::Palette;
unit class Selkie::Widget::ScatterPlot does Selkie::Widget;
# Bit positions for the 8 dots in a braille cell. Layout (column,row):
# bit 0 = (0, 0) bit 3 = (1, 0)
# bit 1 = (0, 1) bit 4 = (1, 1)
# bit 2 = (0, 2) bit 5 = (1, 2)
# bit 6 = (0, 3) bit 7 = (1, 3)
#
# Sub-cell coordinates within a 2×4 window. Used to pack a (sub-row,
# sub-col) location into the bit position.
my @BIT-FOR = (
# [sub-col][sub-row] → bit
[0, 1, 2, 6], # sub-col 0
[3, 4, 5, 7], # sub-col 1
);
#| List of series. Each series is a hash with C<label> (Str),
#| C<points> (list of (x, y) pairs), and optional C<color> (UInt RGB).
has @.series;
#| Reactive store path. Mutually exclusive with C<series>.
has Str @.store-path;
#| Series-color palette. Used when individual series don't specify
#| C<color>.
has Str $.palette = 'okabe-ito';
#| Optional explicit X axis bounds. Auto-derived when unset.
has Real $.x-min;
has Real $.x-max;
#| Optional explicit Y axis bounds. Auto-derived when unset.
has Real $.y-min;
has Real $.y-max;
#| How to handle cells where multiple series have dots. Currently
#| only C<z-order> is supported (last-drawn wins the colour).
has Str $.overlap = 'z-order';
#| Message rendered when there are no points. The default is the
#| expected startup state for monitoring dashboards. Set to the
#| empty string to suppress.
has Str $.empty-message = 'No data';
submethod TWEAK {
die "Selkie::Widget::ScatterPlot: pass at most one of :series or :store-path"
if @!series.elems > 0 && @!store-path.elems > 0;
die "Selkie::Widget::ScatterPlot: overlap must be 'z-order'"
unless $!overlap eq 'z-order';
if @!series.elems > 0 && @!series.all ~~ Pair {
# %( |@!series ) is the right way to construct a Hash from a
# flattened list of pairs; { |@!series } parses as a Block.
@!series = (%( |@!series ),);
}
# Realise each series' points once at construction. Callers
# commonly build points via `.map: { ... }`, which returns a
# one-shot Seq. Long-lived TUI apps re-render every frame; after
# the first render consumed the Seq, subsequent frames would see
# an empty points list.
@!series = @!series.map(-> %s {
my %copy = %s;
%copy<points> = (%s<points> // ()).list.Array if %s<points>:exists;
%copy;
});
}
#| Hook called when the widget is attached to a store. Wires up a
#| subscription against C<:store-path> so the chart re-renders on
#| state changes. No-op in C<:series> mode.
method on-store-attached($store) {
return unless @!store-path.elems > 0;
self.once-subscribe(
'scatter-' ~ self.widget-id,
|@!store-path,
);
}
#| Replace the chart's series array. Each series' points are realised
#| into an Array up-front because callers often pass Seqs from C<.map>
#| chains, and a TUI re-renders every frame — an exhausted Seq would
#| produce an empty plot on subsequent renders. Throws when constructed
#| in C<:store-path> mode.
method set-series(@new) {
die "Selkie::Widget::ScatterPlot.set-series: only valid in :series mode"
if @!store-path.elems > 0;
@!series = @new.map(-> %s {
my %copy = %s;
%copy<points> = (%s<points> // ()).list.Array if %s<points>:exists;
%copy;
});
self.mark-dirty;
}
#|( Compute the braille codepoint for a given bit pattern (0..255).
Pure function, exhaustively unit-testable. )
method braille-glyph(UInt $bits where 0..255 --> Str) {
chr(0x2800 + $bits);
}
#|( Compute the bit position within a braille cell for a sub-cell
coordinate. C<$sub-col> is 0 or 1; C<$sub-row> is 0..3. Returns
a bit index 0..7 suitable for use with C<braille-glyph>. )
method braille-bit(UInt $sub-col where 0..1, UInt $sub-row where 0..3 --> UInt) {
@BIT-FOR[$sub-col][$sub-row];
}
method render() {
return without self.plane;
ncplane_erase(self.plane);
return self.clear-dirty if self.rows == 0 || self.cols == 0;
my @series = self!current-series;
my $total-points = @series.map({ (.<points> // ()).elems }).sum;
if @series.elems == 0 || $total-points == 0 {
self!render-empty;
return self.clear-dirty;
}
# Per-cell state: bit pattern + colour. We re-render each cell at
# the end with z-order semantics for colour.
my %cells; # "$row,$col" => { bits => UInt, color => UInt }
my ($x-lo, $x-hi, $y-lo, $y-hi) = self!effective-ranges(@series);
my $x-scaler = Selkie::Plot::Scaler.linear(
min => $x-lo, max => $x-hi, cells => self.cols * 2,
);
my $y-scaler = Selkie::Plot::Scaler.linear(
min => $y-lo, max => $y-hi, cells => self.rows * 4, :invert,
);
for @series.kv -> $i, %s {
my $color = self!color-for($i, %s);
for (%s<points> // ()).list -> $point {
my ($x, $y) = self!unpack-point($point);
next unless $x.defined && $y.defined;
next if $x === NaN || $y === NaN;
my $sub-x = $x-scaler.value-to-cell($x.Real);
my $sub-y = $y-scaler.value-to-cell($y.Real);
next unless $sub-x.defined && $sub-y.defined;
my $cell-row = $sub-y div 4;
my $cell-col = $sub-x div 2;
my $sub-row = $sub-y mod 4;
my $sub-col = $sub-x mod 2;
my $bit = self.braille-bit($sub-col.UInt, $sub-row.UInt);
my $key = "$cell-row,$cell-col";
unless %cells{$key}:exists {
%cells{$key} = { bits => 0, color => $color };
}
%cells{$key}<bits> +|= 1 +< $bit;
%cells{$key}<color> = $color; # z-order: last wins
}
}
for %cells.kv -> $key, %v {
my ($r, $c) = $key.split(',').map(*.Int);
my $glyph = self.braille-glyph(%v<bits>);
my $style = Selkie::Style.new(fg => %v<color>);
self.apply-style($style);
ncplane_putstr_yx(self.plane, $r, $c, $glyph);
}
self.clear-dirty;
}
method !render-empty() {
return if $!empty-message eq '';
self.apply-style(self.theme.text-dim);
my $msg = $!empty-message.substr(0, self.cols);
my $row = self.rows div 2;
my $col = max(0, (self.cols - $msg.chars) div 2);
ncplane_putstr_yx(self.plane, $row, $col, $msg);
}
method !current-series(--> List) {
# Static series were realised in TWEAK; store data may still be
# Seq-shaped so realise on read.
sub realise(@list --> List) {
@list.map(-> %s {
my %copy = %s;
%copy<points> = (%s<points> // ()).list.Array if %s<points>:exists;
%copy;
}).list;
}
if @!store-path.elems > 0 && self.store {
my $val = self.store.get-in(|@!store-path);
return $val.defined ?? realise($val.list) !! ();
}
@!series.list;
}
method !effective-ranges(@series --> List) {
my @xs;
my @ys;
for @series -> %s {
for (%s<points> // ()).list -> $point {
my ($x, $y) = self!unpack-point($point);
@xs.push($x) if $x.defined && $x !=== NaN;
@ys.push($y) if $y.defined && $y !=== NaN;
}
}
my $x-lo = $!x-min // (@xs.min // 0);
my $x-hi = $!x-max // (@xs.max // 1);
my $y-lo = $!y-min // (@ys.min // 0);
my $y-hi = $!y-max // (@ys.max // 1);
if $x-lo == $x-hi { $x-hi = $x-lo + 1 }
if $y-lo == $y-hi { $y-hi = $y-lo + 1 }
($x-lo, $x-hi, $y-lo, $y-hi);
}
method !unpack-point($p --> List) {
given $p {
when Pair { return $p.key, $p.value }
when Positional { return $p[0], $p[1] }
when Associative { return $p<x>, $p<y> }
default { return Real, Real }
}
}
method !color-for(Int $i, %series --> UInt) {
return %series<color> if %series<color>:exists;
my @palette = Selkie::Plot::Palette.series($!palette);
@palette[$i mod @palette.elems];
}