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


=begin pod

=head1 NAME

Selkie::Widget::BarChart - Categorical bar chart, vertical or horizontal

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Widget::BarChart;
use Selkie::Sizing;

# Vertical bars (default)
my $bars = Selkie::Widget::BarChart.new(
    data => [
        { label => 'apples',  value => 12 },
        { label => 'pears',   value =>  7 },
        { label => 'cherries',value => 15 },
        { label => 'plums',   value =>  4 },
    ],
    sizing => Sizing.flex,
);

# Horizontal bars
my $hbars = Selkie::Widget::BarChart.new(
    data        => @data,
    orientation => 'horizontal',
    sizing      => Sizing.flex,
);

# Reactive — read from a store path
my $live = Selkie::Widget::BarChart.new(
    store-path => <stats counts>,
    sizing     => Sizing.flex,
);

=end code

=head1 DESCRIPTION

A categorical bar chart. Each entry is a labelled value; entries are
laid out across the chart body with one bar per entry. The
B<orientation> determines the bar direction:

=item B<vertical> (default) — bars rise from the bottom; labels along the bottom edge; values along the left edge.
=item B<horizontal> — bars extend rightward from the left; labels along the left edge; values along the top edge.

Bar heights / widths use 1/8-cell precision via the Unicode block
glyphs (C<▁▂▃▄▅▆▇█> vertically, C<▏▎▍▌▋▊▉█> horizontally) so a bar
can be C<3.625> cells tall, not just integer cells.

=head2 Construction modes

Same as L<Selkie::Widget::Sparkline>:

=item B<Static> — pass C<:data([...])> with one hash per bar (C<label>, C<value>, optional C<color>).
=item B<Reactive> — pass C<:store-path<a b c>> to read the data array from a store path; the widget re-renders when the value changes.

The two modes are mutually exclusive.

=head2 Coloring

Each bar's color comes from one of three sources, in priority order:

=item Per-bar override: C<{ label => 'foo', value => 12, color => 0xFF0000 }>
=item The named palette specified by C<:palette> (default C<okabe-ito>) — colors cycle if there are more bars than palette entries
=item C<self.theme.graph-line> as a fallback for any bar without a color and no palette match

See L<Selkie::Plot::Palette> for the available palettes.

=head2 Range

Y-range (vertical) / X-range (horizontal) auto-derives from the data:
the lower bound is C<0> (or the data minimum if negative), the upper
bound is the data maximum padded outward by Heckbert's nice-number
choice (so the top tick lands on a round number).

Pass C<:min> and C<:max> to fix the range.

=head1 EXAMPLES

=head2 Simple categorical comparison

=begin code :lang<raku>

my $chart = Selkie::Widget::BarChart.new(
    data => [
        { label => 'Q1', value => 1230 },
        { label => 'Q2', value => 1875 },
        { label => 'Q3', value => 2042 },
        { label => 'Q4', value => 1611 },
    ],
    sizing => Sizing.flex,
);

=end code

=head2 Multi-color with a palette override

=begin code :lang<raku>

my $chart = Selkie::Widget::BarChart.new(
    data    => @data,
    palette => 'tol-bright',
    sizing  => Sizing.flex,
);

=end code

=head2 Per-bar color (status indicator)

=begin code :lang<raku>

my @data = $tasks.map: -> $t {
    {
        label => $t.name,
        value => $t.duration-ms,
        color => $t.status eq 'failed' ?? 0xCC4444 !! 0x44AA44,
    }
};
my $chart = Selkie::Widget::BarChart.new(:@data, sizing => Sizing.flex);

=end code

=head1 SEE ALSO

=item L<Selkie::Widget::Histogram> — bins a numeric series and feeds it into BarChart
=item L<Selkie::Widget::Sparkline> — for a single inline trend bar
=item L<Selkie::Plot::Palette> — series colors

=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::BarChart does Selkie::Widget;

# Vertical 1/8-cell glyphs, lowest to highest (index 0 = empty, 8 = full)
constant @V-LEVELS = ' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█';

# Horizontal 1/8-cell glyphs, narrowest to widest
constant @H-LEVELS = ' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█';

#| List of bar entries. Each entry is a hash with C<label> (Str),
#| C<value> (Real), and optional C<color> (UInt RGB).
has @.data;

#| Reactive store path. Mutually exclusive with C<data>.
has Str @.store-path;

#| C<vertical> (bars rise from the bottom) or C<horizontal> (bars
#| extend right from the left).
has Str $.orientation = 'vertical';

#| Named series palette for bar colors. See L<Selkie::Plot::Palette>.
has Str $.palette = 'okabe-ito';

#| Whether to draw the value axis (left for vertical, top for
#| horizontal). Disable when the chart is composed in a layout that
#| supplies its own axis.
has Bool $.show-axis = True;

#| Whether to draw category labels (bottom for vertical, left for
#| horizontal).
has Bool $.show-labels = True;

#| Optional explicit lower bound. When unset, derived from the data
#| (C<min(0, min-data)>).
has Real $.min;

#| Optional explicit upper bound. When unset, derived from the data
#| (C<max-data>, padded by Heckbert).
has Real $.max;

#| Approximate tick count for the value axis.
has UInt $.tick-count = 5;

#| Message rendered when there are no bars. 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::BarChart: pass at most one of :data or :store-path"
        if @!data.elems > 0 && @!store-path.elems > 0;
    die "Selkie::Widget::BarChart: orientation must be vertical|horizontal"
        unless $!orientation eq 'vertical'|'horizontal';

    # %(...) constructs a Hash from a flat pair list; {...} would parse
    # as a Block in this position. Same Raku gotcha Legend handles.
    if @!data.elems > 0 && @!data.all ~~ Pair {
        @!data = (%( |@!data ),);
    }
}

#| Hook called when the widget is attached to a store. Wires up a
#| subscription against the chart's C<:store-path> so it re-renders
#| whenever the underlying state changes. No-op in C<:data> mode.
method on-store-attached($store) {
    return unless @!store-path.elems > 0;
    self.once-subscribe(
        'barchart-' ~ self.widget-id,
        |@!store-path,
    );
}

#| Replace the chart's data with a new array of bars / grouped bars.
#| Throws when the chart was constructed in C<:store-path> mode — pick
#| one feed source up front (data array vs. store path) and stick with
#| it for the lifetime of the widget.
method set-data(@new) {
    die "Selkie::Widget::BarChart.set-data: only valid in :data mode"
        if @!store-path.elems > 0;
    @!data = @new;
    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 @entries = self!current-data;
    if @entries.elems == 0 {
        self!render-empty;
        return self.clear-dirty;
    }

    given $!orientation {
        when 'vertical'   { self!render-vertical(@entries) }
        when 'horizontal' { self!render-horizontal(@entries) }
    }

    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-data(--> List) {
    if @!store-path.elems > 0 && self.store {
        my $val = self.store.get-in(|@!store-path);
        return ($val.defined ?? $val.list !! ()).list;
    }
    @!data.list;
}

method !effective-range(@entries --> List) {
    my @vals = @entries.map(*.<value>).grep(*.defined);
    my $data-min = @vals.min // 0;
    my $data-max = @vals.max // 1;
    my $lo = $!min // min(0, $data-min);
    my $hi = $!max // $data-max;
    if $lo == $hi {
        $hi = $lo + 1;
    }
    ($lo, $hi);
}

method !color-for(Int $i, %entry --> UInt) {
    return %entry<color> if %entry<color>:exists;
    my @palette = Selkie::Plot::Palette.series($!palette);
    return @palette[$i mod @palette.elems];
}

method !render-vertical(@entries) {
    my ($lo, $hi) = self!effective-range(@entries);

    # Reserve rows for labels (bottom) and cols for axis (left)
    my $label-rows = $!show-labels ?? 1 !! 0;
    my $axis-cols  = $!show-axis ?? self!axis-width($lo, $hi) !! 0;

    my $body-rows = self.rows - $label-rows;
    my $body-cols = self.cols - $axis-cols;
    return if $body-rows <= 0 || $body-cols <= 0;

    # Draw value axis on the left
    if $!show-axis {
        self!draw-vertical-axis($lo, $hi, $body-rows, $axis-cols);
    }

    # Allocate bar columns
    my $n = @entries.elems;
    my $bar-width = max($body-cols div ($n * 2), 1);
    my $total-bar-cols = $bar-width * $n;
    my $gap = $n > 1
        ?? ($body-cols - $total-bar-cols) div ($n + 1)
        !! ($body-cols - $bar-width) div 2;
    $gap = max($gap, 0);

    # Draw bars
    for @entries.kv -> $i, %e {
        my $color = self!color-for($i, %e);
        my $val   = (%e<value> // 0).Real;

        my $start-col = $axis-cols + $gap + $i * ($bar-width + $gap);
        next if $start-col >= self.cols;
        my $width = min($bar-width, self.cols - $start-col);

        # Fractional height in 1/8-cells
        my $eighths = (($val - $lo) / ($hi - $lo) * $body-rows * 8).round.Int;
        $eighths = 0           if $eighths < 0;
        $eighths = $body-rows * 8 if $eighths > $body-rows * 8;
        my $full-cells = $eighths div 8;
        my $remainder  = $eighths mod 8;

        my $style = Selkie::Style.new(fg => $color);
        self.apply-style($style);

        # Bottom-up: full cells from row (body_rows-1) up to (body_rows - full_cells)
        for ^$full-cells -> $r {
            my $row = $body-rows - 1 - $r;
            for ^$width -> $w {
                ncplane_putstr_yx(self.plane, $row, $start-col + $w, '█');
            }
        }
        if $remainder > 0 {
            my $row = $body-rows - 1 - $full-cells;
            if $row >= 0 {
                my $glyph = @V-LEVELS[$remainder];
                for ^$width -> $w {
                    ncplane_putstr_yx(self.plane, $row, $start-col + $w, $glyph);
                }
            }
        }
    }

    # Draw labels in the bottom row
    if $!show-labels && $label-rows > 0 {
        my $label-style = self.theme.graph-axis-label;
        self.apply-style($label-style);
        for @entries.kv -> $i, %e {
            my $start-col = $axis-cols + $gap + $i * ($bar-width + $gap);
            next if $start-col >= self.cols;
            my $text = (%e<label> // '').Str;
            my $available = min($bar-width, self.cols - $start-col);
            my $clipped = $text.substr(0, $available);
            ncplane_putstr_yx(self.plane, self.rows - 1, $start-col, $clipped);
        }
    }
}

method !render-horizontal(@entries) {
    my ($lo, $hi) = self!effective-range(@entries);

    # Reserve cols for labels (left) and rows for axis (top)
    my $label-cols = $!show-labels ?? self!horizontal-label-width(@entries) !! 0;
    my $axis-rows  = $!show-axis ?? 1 !! 0;

    my $body-rows = self.rows - $axis-rows;
    my $body-cols = self.cols - $label-cols;
    return if $body-rows <= 0 || $body-cols <= 0;

    # Draw value axis on the top
    if $!show-axis {
        self!draw-horizontal-axis($lo, $hi, $body-cols, $label-cols);
    }

    # Allocate bar rows
    my $n = @entries.elems;
    my $bar-height = max($body-rows div ($n * 2), 1);
    my $total-bar-rows = $bar-height * $n;
    my $gap = $n > 1
        ?? ($body-rows - $total-bar-rows) div ($n + 1)
        !! ($body-rows - $bar-height) div 2;
    $gap = max($gap, 0);

    # Draw bars
    for @entries.kv -> $i, %e {
        my $color = self!color-for($i, %e);
        my $val   = (%e<value> // 0).Real;

        my $start-row = $axis-rows + $gap + $i * ($bar-height + $gap);
        next if $start-row >= self.rows;
        my $height = min($bar-height, self.rows - $start-row);

        my $eighths = (($val - $lo) / ($hi - $lo) * $body-cols * 8).round.Int;
        $eighths = 0              if $eighths < 0;
        $eighths = $body-cols * 8 if $eighths > $body-cols * 8;
        my $full-cells = $eighths div 8;
        my $remainder  = $eighths mod 8;

        my $style = Selkie::Style.new(fg => $color);
        self.apply-style($style);

        for ^$height -> $h {
            my $row = $start-row + $h;
            for ^$full-cells -> $c {
                ncplane_putstr_yx(self.plane, $row, $label-cols + $c, '█');
            }
            if $remainder > 0 && ($label-cols + $full-cells) < self.cols {
                ncplane_putstr_yx(self.plane, $row, $label-cols + $full-cells,
                                  @H-LEVELS[$remainder]);
            }
        }
    }

    # Draw labels on the left
    if $!show-labels && $label-cols > 0 {
        my $label-style = self.theme.graph-axis-label;
        self.apply-style($label-style);
        for @entries.kv -> $i, %e {
            my $start-row = $axis-rows + $gap + $i * ($bar-height + $gap);
            next if $start-row >= self.rows;
            my $text = (%e<label> // '').Str;
            my $clipped = $text.substr(0, $label-cols);
            # Center vertically across bar-height
            my $row = $start-row + $bar-height div 2;
            $row = min($row, self.rows - 1);
            ncplane_putstr_yx(self.plane, $row, 0, $clipped);
        }
    }
}

method !axis-width(Real $lo, Real $hi --> UInt) {
    my $ticks = Selkie::Plot::Ticks.nice(min => $lo, max => $hi, count => $!tick-count);
    (($ticks.labels.map(*.chars).max // 0) + 1).UInt;
}

method !horizontal-label-width(@entries --> UInt) {
    my $widest = @entries.map({ (.<label> // '').chars }).max // 0;
    min($widest, max(self.cols div 4, 6));
}

method !draw-vertical-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-horizontal-axis(Real $lo, Real $hi, UInt $cols, UInt $label-cols) {
    my $axis-style = self.theme.graph-axis;
    my $label-style = self.theme.graph-axis-label;

    # Top-edge line at row 0; we'd render labels below the line if
    # there's room, but reserved 1 row only — so no labels in v1
    # for the horizontal axis. Just the line + tick marks.
    self.apply-style($axis-style);
    ncplane_putstr_yx(self.plane, 0, $label-cols, '─' x $cols);

    my $scaler = Selkie::Plot::Scaler.linear(
        min => $lo, max => $hi, cells => $cols,
    );
    my $ticks = Selkie::Plot::Ticks.nice(min => $lo, max => $hi, count => $!tick-count);

    for $ticks.values -> $v {
        my $col = $scaler.value-to-cell($v);
        next without $col.defined;
        next if $col >= $cols;
        ncplane_putstr_yx(self.plane, 0, $label-cols + $col, '┬');
    }
}