Selkie.git | lib/Selkie/Widget/ | Sparkline.rakumod


=begin pod

=head1 NAME

Selkie::Widget::Sparkline - Inline single-row chart using Unicode block glyphs

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Widget::Sparkline;
use Selkie::Sizing;

# Static data — fixed series
my $sl = Selkie::Widget::Sparkline.new(
    data   => [1, 4, 2, 8, 5, 9, 3, 7],
    sizing => Sizing.fixed(1),
);

# Streaming — push samples as they arrive
my $stream = Selkie::Widget::Sparkline.new(sizing => Sizing.fixed(1));
$cpu-supply.tap: -> $sample { $stream.push-sample($sample) };

# Reactive — read from a store path that holds the array
my $bound = Selkie::Widget::Sparkline.new(
    store-path => <metrics latency-history>,
    sizing     => Sizing.fixed(1),
);

=end code

=head1 DESCRIPTION

A single-row inline chart that maps numeric values to the Unicode
"lower one-eighth block" series:

  ▁ ▂ ▃ ▄ ▅ ▆ ▇ █

Each cell shows one sample. The widget's width determines how many
samples are visible — when the buffer overflows, the oldest sample
is discarded (FIFO ring buffer).

Sparklines are designed to live inline with text or in table cells,
not as standalone visualisations. For a full-fledged line chart with
axes and legends, use L<Selkie::Widget::LineChart> (static data) or
L<Selkie::Widget::Plot> (streaming).

=head2 Construction modes

=item B<Static> — pass C<:data(@arr)> for a fixed sample series. The widget renders the same data every frame.
=item B<Streaming> — construct without C<:data>, then call C<.push-sample($v)> as new samples arrive. Internal ring buffer caps at the widget's column count.
=item B<Reactive> — pass C<:store-path<a b c>> to read the sample array from a store path on each render. Subscription marks the widget dirty when the value changes.

The three modes are mutually exclusive: pass exactly one of C<:data>
or C<:store-path>, or neither (streaming). Mixing throws at TWEAK.

=head2 Value mapping

Values are mapped linearly from C<[min, max]> (auto-derived from the
buffer) onto the eight glyph levels. By default C<min> is the
minimum sample seen and C<max> the maximum; pass explicit C<:min> /
C<:max> to fix the range across renders (useful when streaming so
the heights don't jitter as new samples shift the auto-range).

C<NaN> samples render as a space (the cell is skipped). Negative or
positive infinities clamp to the corresponding edge glyph.

=head1 EXAMPLES

=head2 Inline in a status bar

=begin code :lang<raku>

use Selkie::Layout::HBox;
use Selkie::Widget::Text;
use Selkie::Widget::Sparkline;
use Selkie::Sizing;

my $status = Selkie::Layout::HBox.new(sizing => Sizing.fixed(1));
$status.add: Selkie::Widget::Text.new(text => 'CPU: ', sizing => Sizing.fixed(5));
$status.add: $cpu-sparkline,                         sizing => Sizing.fixed(20);
$status.add: Selkie::Widget::Text.new(text => '', sizing => Sizing.flex);

=end code

=head2 In a Table cell

Embed sparklines in a table column to show per-row history. The
hand-rolled implementation has no native handle, so it's cheap to
instantiate one per row:

=begin code :lang<raku>

use Selkie::Widget::Table;

my $table = Selkie::Widget::Table.new(...);
$table.add-column(
    name     => 'history',
    width    => 20,
    renderer => -> %row {
        Selkie::Widget::Sparkline.new(
            data   => %row<latency-samples>,
            sizing => Sizing.fixed(1),
        );
    },
);

=end code

=head2 Streaming with a fixed range

Auto-range jitter is annoying when you want to see absolute trends.
Pin the range to your domain knowledge:

=begin code :lang<raku>

my $cpu-spark = Selkie::Widget::Sparkline.new(
    min    => 0,           # CPU is 0..100%
    max    => 100,
    sizing => Sizing.fixed(1),
);
$cpu-supply.tap: -> $sample { $cpu-spark.push-sample($sample) };

=end code

=head1 SEE ALSO

=item L<Selkie::Widget::LineChart> — full chart with axes, legends, multi-series
=item L<Selkie::Widget::Plot> — streaming chart with native ncuplot ring buffer
=item L<Selkie::Plot::Scaler> — the value→cell mapping primitive

=end pod

use Notcurses::Native;
use Notcurses::Native::Plane;

use Selkie::Widget;
use Selkie::Plot::Scaler;

unit class Selkie::Widget::Sparkline does Selkie::Widget;

# The eight block-glyph levels, lowest to highest. A NaN sample maps
# to ' ' (rendered as a blank cell).
constant @LEVELS = ' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█';

#| Static sample list. Mutually exclusive with C<store-path>.
has Real @.data;

#| Reactive store path — list-of-strings forming the lookup. Mutually
#| exclusive with C<data>.
has Str @.store-path;

#| Optional fixed range lower bound. When unset, the minimum across
#| the current buffer is used (auto-range).
has Real $.min;

#| Optional fixed range upper bound. See C<min>.
has Real $.max;

#| Message rendered when there are no samples yet. This is the
#| expected startup state for monitoring dashboards, so defaults to
#| a calm placeholder rather than nothing. Disable by setting to the
#| empty string.
has Str $.empty-message = 'No data';

# Internal ring buffer for streaming mode. Sized lazily to match
# widget cols on first push.
has Real @!ring;

submethod TWEAK {
    die "Selkie::Widget::Sparkline: pass at most one of :data or :store-path"
        if @!data.elems > 0 && @!store-path.elems > 0;

    if @!data.elems > 0 {
        @!ring = @!data;
    }
}

#| Hook called when the widget is attached to a store. Subscribes
#| against C<:store-path> so the sparkline re-renders when the
#| underlying samples change. No-op in C<:data> or
#| C<push-sample>-driven mode.
method on-store-attached($store) {
    return unless @!store-path.elems > 0;
    self.once-subscribe(
        'sparkline-' ~ self.widget-id,
        |@!store-path,
    );
}

#|( Append a single sample to the streaming ring buffer. When the
    buffer reaches the widget's column count, the oldest sample is
    discarded. No-op in C<:data> or C<:store-path> mode (the buffer
    is owned by the data source, not the widget). )
method push-sample(Real $v) {
    return if @!data.elems > 0 || @!store-path.elems > 0;
    @!ring.push: $v;
    # Cap to current width. cols may be 0 before plane attach — use
    # a generous fallback so we don't lose samples then.
    my $cap = self.cols > 0 ?? self.cols !! 1024;
    @!ring.shift while @!ring.elems > $cap;
    self.mark-dirty;
}

#|( Replace the static data array. Only valid in C<:data> mode. )
method set-data(@new) {
    die "Selkie::Widget::Sparkline.set-data: only valid in :data mode"
        if @!store-path.elems > 0;
    @!data = @new.list;
    @!ring = @new.list;
    self.mark-dirty;
}

method render() {
    return without self.plane;
    ncplane_erase(self.plane);

    my @samples = self!current-samples;
    if @samples.elems == 0 {
        self!render-empty;
        return self.clear-dirty;
    }

    self.apply-style(self.theme.graph-line);

    my ($lo, $hi) = self!effective-range(@samples);
    my $scaler = self!scaler-for($lo, $hi);

    # Take the rightmost-N samples (N = cols) so streaming sparklines
    # show the most recent. For static :data, this is the whole array
    # if it fits, or the rightmost cols if it overflows.
    my $width = self.cols;
    my $start = max(@samples.elems - $width, 0);
    my @visible = @samples[$start ..^ @samples.elems];

    my $line = '';
    for @visible -> $v {
        $line ~= self!glyph-for($v, $scaler);
    }
    ncplane_putstr_yx(self.plane, 0, 0, $line);

    self.clear-dirty;
}

method !current-samples(--> List) {
    if @!store-path.elems > 0 && self.store {
        my $val = self.store.get-in(|@!store-path);
        return ($val.defined ?? $val.list !! ()).list;
    }
    @!ring.list;
}

method !effective-range(@samples --> List) {
    my $lo = $!min // (@samples.grep(*.defined).grep({ $_ !=== NaN }).min // 0);
    my $hi = $!max // (@samples.grep(*.defined).grep({ $_ !=== NaN }).max // 1);
    # Avoid degenerate range (all samples identical) — pad to a small
    # span so the renderer picks a stable mid-level glyph rather than
    # crashing in the scaler.
    if $lo == $hi {
        $lo -= 0.5;
        $hi += 0.5;
    }
    ($lo, $hi);
}

method !scaler-for(Real $lo, Real $hi --> Selkie::Plot::Scaler) {
    # 9 levels: index 0 = ' ' for NaN, indices 1..8 = ▁..█. Map
    # cleanly onto 8 cells.
    Selkie::Plot::Scaler.linear(min => $lo, max => $hi, cells => 8);
}

method !glyph-for(Real $v, Selkie::Plot::Scaler $scaler --> Str) {
    return @LEVELS[0] if $v === NaN;
    my $idx = $scaler.value-to-cell($v);
    # value-to-cell yields 0..7 (cells - 1); shift to 1..8 so 0
    # remains the NaN placeholder.
    return @LEVELS[0] without $idx.defined;
    @LEVELS[$idx + 1];
}

method !render-empty() {
    return if $!empty-message eq '' || self.rows == 0 || self.cols == 0;
    self.apply-style(self.theme.text-dim);
    my $msg = $!empty-message.substr(0, self.cols);
    ncplane_putstr_yx(self.plane, 0, 0, $msg);
}