Selkie.git | lib/Selkie/Widget/ | LineChart.rakumod
=begin pod
=head1 NAME
Selkie::Widget::LineChart - Static multi-series line chart with axes and legend
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::LineChart;
use Selkie::Sizing;
# Single series — auto-derives Y range from the data
my $cpu = Selkie::Widget::LineChart.new(
series => [
{ label => 'cpu %', values => @cpu-history },
],
sizing => Sizing.flex,
);
# Multi-series with explicit colours
my $cmp = Selkie::Widget::LineChart.new(
series => [
{ label => 'p50', values => @p50, color => 0xE69F00 },
{ label => 'p99', values => @p99, color => 0xCC4444 },
],
fill-below => True,
sizing => Sizing.flex,
);
=end code
=head1 DESCRIPTION
A static-data line chart, hand-rolled with braille (U+2800-U+28FF)
sub-cell resolution. Each cell holds 2×4 braille dots; lines are
rasterised at 2× horizontal × 4× vertical resolution relative to the
plain cell grid.
For B<streaming> data, prefer L<Selkie::Widget::Plot> (uses the
native ncuplot / ncdplot ring buffer; better at high sample rates).
For one-row inline charts, use L<Selkie::Widget::Sparkline>.
=head2 What it composes
Internally LineChart manages three regions:
=item B<Body> — the chart area, drawn with braille dots
=item B<Y axis> (left edge) — labels + tick marks, when C<show-axis> is True
=item B<Legend> (bottom strip) — colour-coded series labels, when C<show-legend> is True and there's more than one series
Each region renders inline via direct ncplane calls; the widget
doesn't compose child widgets. Disable axis/legend to reclaim the
reserved cells and devote all cells to the body.
=head2 Multi-series colour collision
Each braille cell renders with a single foreground colour. When two
series cross in the same 2×4 sub-cell window, the last-drawn series'
colour wins ("z-order"). Series are drawn in order; in practice this
means the last series in your list "covers" earlier ones at
intersections.
This is a fundamental limit of single-foreground terminal cells.
For series that overlap heavily, a faceted layout (one chart per
series, stacked) gives clearer attribution.
=head2 Range
Y range auto-derives from C<min(0, min-data)> to C<max-data>. Pass
explicit C<:y-min> and C<:y-max> to fix it. X is always slot indices
C<0 .. (max-series-length - 1)>; series of differing lengths are
plotted against the full domain (longer series fill the X span,
shorter series stop before the right edge).
=head2 Fill below
Pass C<:fill-below> to fill the area between each series line and
the chart's baseline (the lower edge for positive-only data). Fill
uses the C<graph-fill> theme slot when the series has no color
override; with multiple series the fill stacks visually with z-order
priority.
=head1 EXAMPLES
=head2 Single static series
=begin code :lang<raku>
my @samples = (^60).map: { sin($_ * 0.1) * 100 };
my $chart = Selkie::Widget::LineChart.new(
series => [{ label => 'sine', values => @samples }],
sizing => Sizing.flex,
);
=end code
=head2 Multi-series comparison
=begin code :lang<raku>
my $chart = Selkie::Widget::LineChart.new(
series => [
{ label => 'reads', values => @read-rate, color => 0x4477AA },
{ label => 'writes', values => @write-rate, color => 0xEE6677 },
],
sizing => Sizing.flex,
);
=end code
=head2 Fill-below for area emphasis
=begin code :lang<raku>
my $chart = Selkie::Widget::LineChart.new(
series => [{ label => 'load', values => @load-1m }],
fill-below => True,
y-min => 0,
y-max => 4,
sizing => Sizing.flex,
);
=end code
=head2 Reactive — values bound to a store path
=begin code :lang<raku>
my $chart = Selkie::Widget::LineChart.new(
store-path-fn => -> $store {
[
{ label => 'series',
values => $store.get-in('metrics', 'history') // [] },
]
},
sizing => Sizing.flex,
);
=end code
=head1 SEE ALSO
=item L<Selkie::Widget::Plot> — streaming variant backed by native ncuplot
=item L<Selkie::Widget::Sparkline> — single-row inline chart
=item L<Selkie::Widget::ScatterPlot> — points without lines (also braille)
=item L<Selkie::Plot::Palette> — series colour palettes
=end pod
use Notcurses::Native;
use Notcurses::Native::Plane;
use Selkie::Widget;
use Selkie::Style;
use Selkie::Plot::Scaler;
use Selkie::Plot::Ticks;
use Selkie::Plot::Palette;
unit class Selkie::Widget::LineChart does Selkie::Widget;
# Bit positions for the 8 dots in a braille cell. Same layout as
# ScatterPlot — kept duplicated for now to avoid coupling. If a third
# widget needs it, factor into a shared helper.
my @BIT-FOR = (
[0, 1, 2, 6], # sub-col 0 → rows 0..3
[3, 4, 5, 7], # sub-col 1 → rows 0..3
);
#| List of series. Each entry is a hash with C<label> (Str),
#| C<values> (Positional of Real), and optional C<color> (UInt RGB).
has @.series;
#| Optional reactive data function: C<sub ($store --> List)>. Called
#| inside C<render()> to derive series. Mutually exclusive with
#| C<series>. Useful when the data is computed from store state.
has &.store-path-fn;
#| Series-color palette name. Used when individual series don't
#| specify C<color>.
has Str $.palette = 'okabe-ito';
#| Whether to draw the Y axis. Disable to reclaim ~5 columns.
has Bool $.show-axis = True;
#| Whether to draw the legend below the chart. Auto-disabled if
#| there's only one series. Disable to reclaim 1 row.
has Bool $.show-legend = True;
#| Whether to fill the area below each line down to the baseline.
has Bool $.fill-below = False;
#| How to handle cells where multiple series have dots. Currently
#| only C<z-order> is supported (last-drawn series wins the colour).
has Str $.overlap = 'z-order';
#| Optional explicit Y bounds. Auto-derived when unset.
has Real $.y-min;
has Real $.y-max;
#| Approximate tick count for the Y axis.
has UInt $.tick-count = 5;
#| Message rendered when there are no samples. 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::LineChart: pass at most one of :series or :store-path-fn"
if @!series.elems > 0 && &!store-path-fn.defined;
die "Selkie::Widget::LineChart: overlap must be 'z-order'"
unless $!overlap eq 'z-order';
if @!series.elems > 0 && @!series.all ~~ Pair {
@!series = (%( |@!series ),);
}
# Realise each series' values once at construction. Callers often
# build values via `(^N).map: { ... }` which yields a one-shot
# Seq; a long-lived TUI renders the same widget every frame, so
# without realisation the second render sees empty values.
@!series = @!series.map(-> %s {
my %copy = %s;
%copy<values> = (%s<values> // ()).list.Array if %s<values>:exists;
%copy;
});
}
#| Replace the chart's series array. Each series' values 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 line on subsequent renders. Throws when the chart
#| was constructed in C<:store-path-fn> mode.
method set-series(@new) {
die "Selkie::Widget::LineChart.set-series: only valid in :series mode"
if &!store-path-fn.defined;
@!series = @new.map(-> %s {
my %copy = %s;
%copy<values> = (%s<values> // ()).list.Array if %s<values>:exists;
%copy;
});
self.mark-dirty;
}
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-samples = @series.map({ (.<values> // ()).elems }).sum;
if @series.elems == 0 || $total-samples == 0 {
self!render-empty;
return self.clear-dirty;
}
# Allocate regions. Y axis on the left, legend on the bottom (if
# multi-series and show-legend), body fills the rest.
my $effective-show-legend = $!show-legend && @series.elems > 1;
my $legend-rows = $effective-show-legend ?? 1 !! 0;
my $axis-cols = $!show-axis ?? self!axis-width(@series) !! 0;
my $body-rows = self.rows - $legend-rows;
my $body-cols = self.cols - $axis-cols;
return self.clear-dirty if $body-rows <= 0 || $body-cols <= 0;
my ($y-lo, $y-hi) = self!effective-y-range(@series);
# Y-axis spans the whole body height.
self!draw-y-axis($y-lo, $y-hi, $body-rows, $axis-cols) if $!show-axis;
# Body — render each series via braille rasterisation.
self!draw-body(@series, $axis-cols, $body-rows, $body-cols, $y-lo, $y-hi);
# Legend — single row at the bottom.
self!draw-legend(@series, $body-rows) if $effective-show-legend;
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<values> = (%s<values> // ()).list.Array if %s<values>:exists;
%copy;
}).list;
}
if &!store-path-fn.defined && self.store {
my $result = &!store-path-fn(self.store);
return $result.defined ?? realise($result.list) !! ();
}
@!series.list;
}
method !effective-y-range(@series --> List) {
my @vals = @series.map({ (.<values> // ()).list }).flat
.grep({ .defined && $_ !=== NaN });
my $data-min = @vals.min // 0;
my $data-max = @vals.max // 1;
my $lo = $!y-min // min(0, $data-min);
my $hi = $!y-max // $data-max;
if $lo == $hi {
$hi = $lo + 1;
}
($lo, $hi);
}
method !axis-width(@series --> UInt) {
my ($lo, $hi) = self!effective-y-range(@series);
my $ticks = Selkie::Plot::Ticks.nice(
min => $lo, max => $hi, count => $!tick-count,
);
(($ticks.labels.map(*.chars).max // 0) + 1).UInt;
}
method !draw-y-axis(Real $lo, Real $hi, UInt $rows, UInt $axis-cols) {
my $axis-style = self.theme.graph-axis;
my $label-style = self.theme.graph-axis-label;
my $line-col = $axis-cols - 1;
my $scaler = Selkie::Plot::Scaler.linear(
min => $lo, max => $hi, cells => $rows, :invert,
);
my $ticks = Selkie::Plot::Ticks.nice(
min => $lo, max => $hi, count => $!tick-count,
);
self.apply-style($axis-style);
for ^$rows -> $r {
ncplane_putstr_yx(self.plane, $r, $line-col, '│');
}
for $ticks.values -> $v {
my $row = $scaler.value-to-cell($v);
next without $row.defined;
next if $row >= $rows;
ncplane_putstr_yx(self.plane, $row, $line-col, '┤');
}
self.apply-style($label-style);
for $ticks.values.kv -> $i, $v {
my $row = $scaler.value-to-cell($v);
next without $row.defined;
next if $row >= $rows;
my $text = $ticks.labels[$i];
my $start = max(0, $line-col - $text.chars);
ncplane_putstr_yx(self.plane, $row, $start, $text.substr(0, $line-col));
}
}
method !draw-body(@series, UInt $axis-cols, UInt $body-rows, UInt $body-cols,
Real $y-lo, Real $y-hi) {
# Per-cell state: bit pattern + colour. Composited at the end
# with z-order — last-drawn series wins the colour.
my %cells; # "$row,$col" => { bits => UInt, color => UInt }
# Determine the X range. Use the longest series as the domain.
my $max-len = (@series.map({ (.<values> // ()).elems }).max // 0).Int;
return if $max-len < 1;
# Sub-cell scalers — body extends across body-cols × 2 sub-columns
# and body-rows × 4 sub-rows.
my $sub-cols = $body-cols * 2;
my $sub-rows = $body-rows * 4;
my $x-scaler = Selkie::Plot::Scaler.linear(
min => 0, max => max($max-len - 1, 1), cells => $sub-cols,
);
my $y-scaler = Selkie::Plot::Scaler.linear(
min => $y-lo, max => $y-hi, cells => $sub-rows, :invert,
);
# Where is the baseline (y == 0 if in range, else the y-axis lower bound)?
my $baseline-sub-y = ($y-lo <= 0 <= $y-hi)
?? $y-scaler.value-to-cell(0).Int
!! ($sub-rows - 1);
for @series.kv -> $i, %s {
my $color = self!color-for($i, %s);
my @vals = (%s<values> // ()).list;
next if @vals.elems < 1;
# Convert each value to its sub-cell (x, y).
my @points;
for @vals.kv -> $j, $v {
next unless $v.defined;
next if $v === NaN;
my $sx = $x-scaler.value-to-cell($j).Int;
my $sy = $y-scaler.value-to-cell($v.Real).Int;
@points.push: ($sx, $sy);
}
next if @points.elems < 1;
# Optional fill-below: walk each sub-column along the line
# and fill from the line's y down to the baseline. Done
# before the line itself so the line draws on top.
my $fill-color = self.theme.graph-fill.fg // $color;
if $!fill-below {
for ^(@points.elems - 1) -> $k {
my ($x0, $y0) = @points[$k];
my ($x1, $y1) = @points[$k + 1];
my $dx = $x1 - $x0;
my $start = min($x0, $x1);
my $end = max($x0, $x1);
for $start .. $end -> $sx {
my $line-y = $dx == 0
?? $y0
!! ($y0 * (1 - ($sx - $x0) / $dx)
+ $y1 * (($sx - $x0) / $dx)).round.Int;
my $from = min($line-y, $baseline-sub-y);
my $to = max($line-y, $baseline-sub-y);
for $from .. $to -> $fy {
self!set-subcell(%cells, $sx, $fy,
$axis-cols, $fill-color);
}
}
}
}
# Lines between consecutive points.
for ^(@points.elems - 1) -> $k {
my ($x0, $y0) = @points[$k];
my ($x1, $y1) = @points[$k + 1];
self!draw-line(%cells, $x0, $y0, $x1, $y1, $axis-cols, $color);
}
# Single-point case: still place the dot.
if @points.elems == 1 {
my ($sx, $sy) = @points[0];
self!set-subcell(%cells, $sx, $sy, $axis-cols, $color);
}
}
# Composite cells.
for %cells.kv -> $key, %v {
my ($r, $c) = $key.split(',').map(*.Int);
next if $r >= $body-rows || $c >= self.cols;
my $glyph = chr(0x2800 + %v<bits>);
my $style = Selkie::Style.new(fg => %v<color>);
self.apply-style($style);
ncplane_putstr_yx(self.plane, $r, $c, $glyph);
}
}
# Set a single sub-cell by (sub-x, sub-y) in body-relative coordinates.
# %cells is keyed by (cell-row, cell-col) of the WIDGET plane (axis-cols
# already added).
method !set-subcell(%cells, Int $sx, Int $sy, UInt $axis-cols, UInt $color) {
return if $sx < 0 || $sy < 0;
my $cell-row = $sy div 4;
my $cell-col = $axis-cols + ($sx div 2);
my $sub-col = $sx mod 2;
my $sub-row = $sy mod 4;
my $bit = @BIT-FOR[$sub-col][$sub-row];
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
}
# DDA-style line rasterisation between two sub-cell points. Plots
# one dot per sub-column traversed, computing y by linear interpolation.
method !draw-line(%cells, Int $x0, Int $y0, Int $x1, Int $y1,
UInt $axis-cols, UInt $color) {
my $dx = $x1 - $x0;
if $dx == 0 {
# Vertical line — plot every sub-row
my $from = min($y0, $y1);
my $to = max($y0, $y1);
for $from .. $to -> $sy {
self!set-subcell(%cells, $x0, $sy, $axis-cols, $color);
}
return;
}
# Otherwise step along x, interpolate y
my $start = min($x0, $x1);
my $end = max($x0, $x1);
for $start .. $end -> $sx {
my $t = ($sx - $x0) / $dx;
my $sy = ($y0 * (1 - $t) + $y1 * $t).round.Int;
self!set-subcell(%cells, $sx, $sy, $axis-cols, $color);
}
}
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];
}
method !draw-legend(@series, UInt $row) {
return if $row >= self.rows;
my $col = 0;
my $label-style = self.theme.text;
for @series.kv -> $i, %s {
last if $col >= self.cols;
my $color = self!color-for($i, %s);
# Swatch
my $swatch-style = Selkie::Style.new(fg => $color);
self.apply-style($swatch-style);
ncplane_putstr_yx(self.plane, $row, $col, '■');
$col += 2;
last if $col >= self.cols;
# Label
self.apply-style($label-style);
my $text = (%s<label> // '').Str;
my $available = self.cols - $col;
my $clipped = $text.substr(0, $available);
ncplane_putstr_yx(self.plane, $row, $col, $clipped);
$col += $clipped.chars + 2;
}
}